diff --git a/.opencode/plugins/orchestrator.ts b/.opencode/plugins/orchestrator.ts deleted file mode 100644 index 10d4173ff5..0000000000 --- a/.opencode/plugins/orchestrator.ts +++ /dev/null @@ -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" }) - }) - }) - }, -} diff --git a/.opencode/skills/debug-opencode/SKILL.md b/.opencode/skills/debug-opencode/SKILL.md index fd7cae09d2..01e0ca3523 100644 --- a/.opencode/skills/debug-opencode/SKILL.md +++ b/.opencode/skills/debug-opencode/SKILL.md @@ -104,6 +104,25 @@ bun dev api --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. diff --git a/AGENTS.md b/AGENTS.md index 703bae8912..0ea4055ce2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -151,13 +151,15 @@ 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. - Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics. -- Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop. +- Preserve one explicit `llm.stream(request)` call per step and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop. - Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary. -- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once. +- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe step boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's step allowance; a batch of steers resets it once. +- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle. - Keep EventV2 replay owner claims separate from clustered Session execution ownership. - Keep the System Context algebra and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Checkpoint persistence Session-owned. The runner composes all context producers explicitly in `loadSystemContext`; there is no context registry. - The durable Applied record is what the model was last told, per source. Reconcile narrates drift as chronological System updates and never rewrites the baseline; only completed compaction rebaselines, and move or committed revert resets the checkpoint. Unavailable sources keep the model's prior belief, blocking only a session's first baseline. diff --git a/CONTEXT.md b/CONTEXT.md index 5e5955d344..712e91bf10 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -9,7 +9,7 @@ The structured collection of contextual facts presented to the model as initial _Avoid_: System prompt **Session History**: -The projected chronological conversation selected for a provider turn after applying the active compaction and **Context Epoch** cutoffs. +The projected chronological conversation selected for a **Step** after applying the active compaction and **Context Epoch** cutoffs. _Avoid_: Session Context **Context Source**: @@ -31,13 +31,13 @@ The full **System Context** rendered at the start of a **Context Epoch**. _Avoid_: Live system prompt **Context Snapshot**: -The overwriteable model-hidden JSON state used to compare each **Context Source** with the value last admitted to a provider turn. +The overwriteable model-hidden JSON state used to compare each **Context Source** with the value last admitted to a **Step**. **Unavailable Context**: An expected temporary inability to observe a **Context Source** value; the runtime retains its prior effective state and emits no update, or omits it until first successfully loaded. -**Safe Provider-Turn Boundary**: -The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically. +**Safe Step Boundary**: +The point immediately before a provider request, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically. **Admitted Prompt**: A durable user input accepted into the Session inbox but not yet included in **Session History**. @@ -45,11 +45,24 @@ A durable user input accepted into the Session inbox but not yet included in **S **Prompt Promotion**: The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**. -**Provider Turn**: -One request to a model provider and the response projected from that request. +**Step**: +One logical LLM call spanning pre-flight context checkpoint preparation, input promotion, request build, and compaction check; the provider stream; and tool settlement. +_Avoid_: provider turn, turn (unqualified) + +**Physical Attempt**: +One actual provider request on the wire in service of a **Step**; most Steps have one Physical Attempt, while overflow-triggered compaction recovery may give one Step two. + +**Assistant Turn**: +A reserved name for the not-yet-modeled unit containing all **Steps** from prompt promotion until the assistant yields the floor; do not reify it until something durable needs it. + +**Settlement**: +The terminal transition for a unit of work: Step and tool settlement are durable, while drain and execution settlement are coordinator-observed. + +**Execution**: +One session-scoped coordinator busy period from first wake until idle. An Execution is process-local coordination rather than a durable domain entity. **Session Drain**: -One process-local execution span that promotes eligible input and runs required **Provider Turns** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary. +One process-local execution span that promotes eligible input and runs required **Steps** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary. **Model Tool Output**: The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit. @@ -89,23 +102,25 @@ _Avoid_: Response envelope - A **System Context** is an opaque carrier composed from zero or more **Context Sources**. - **Session History** contains projected conversational messages and admitted **Mid-Conversation System Messages**; the active **Baseline System Context** remains separate provider-request state. -- The **System Context Registry** uses stable-keyed scoped contributions to assemble the current **System Context**; contributor removal naturally removes its sources at the next **Safe Provider-Turn Boundary**. +- The **System Context Registry** uses stable-keyed scoped contributions to assemble the current **System Context**; contributor removal naturally removes its sources at the next **Safe Step Boundary**. - A changed **Context Source** may produce one **Mid-Conversation System Message** containing its newly effective state. - A **Mid-Conversation System Message** persists the exact combined rendered text sent to the model. - The current **Context Snapshot** advances atomically with the corresponding durable **Mid-Conversation System Message**. - A **Context Snapshot** stores one codec-encoded JSON value and, for removable dynamic sources, a pre-rendered removal message per stable **Context Source** key. - Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**. -- Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes. -- At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**. +- Context changes are sampled and admitted lazily at a **Safe Step Boundary**, never pushed asynchronously when their source changes. +- At a **Safe Step Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**. - An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**. - **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message. -- Steering prompts promote at the next **Safe Provider-Turn Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's provider-turn allowance; multiple prompts promoted at one boundary reset it once. +- Steering prompts promote at the next **Safe Step Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's step allowance; multiple prompts promoted at one boundary reset it once. - A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another. -- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, provider attempts, and tool state rather than inventing an enclosing execution identity. -- The first provider turn renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the turn instead of persisting an incomplete baseline. +- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, physical attempts, and tool state rather than inventing an enclosing execution identity. +- An **Execution** contains one or more **Session Drains**; a **Session Drain** contains one reserved assistant-turn span at a time; that span contains **Steps**; and each **Step** contains one or more **Physical Attempts** plus any tool calls it requires. +- A **Step** record covers only the model-visible span from first assistant output through tool settlement; pre-flight leaves no record, and one Step settles at most one record. +- The first **Step** renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the Step instead of persisting an incomplete baseline. - Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion. - Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history. -- A newly registered core or plugin-defined **Context Source** absent from the current snapshot emits its baseline rendering once at the next **Safe Provider-Turn Boundary**. +- A newly registered core or plugin-defined **Context Source** absent from the current snapshot emits its baseline rendering once at the next **Safe Step Boundary**. - **Context Source** keys are stable and namespaced; duplicate keys fail composition. `SystemContext.combine(...)` preserves caller order; the **System Context Registry** evaluates producers concurrently and combines them in stable contribution-key order so rendered context remains deterministic. - Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed. - `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**. @@ -113,26 +128,26 @@ _Avoid_: Response envelope - `SystemContext.replace(...)` renders a fresh generation after completed compaction or another baseline-replacing transition; it reports replacement blocked while previously admitted context is unavailable. - **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text. - Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**. -- Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**. +- Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Step Boundary**. - Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location. - Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote. - Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values. -- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**. +- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Step Boundary**. - Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam. - Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool. -- The selected agent and model are sampled when a provider turn starts. Changes admitted after that boundary apply to the next provider turn and do not restart the current turn. +- The selected agent and model are sampled when a **Step** starts. Changes admitted after that boundary apply to the next Step and do not restart the current Step. - Selected-agent available-skill guidance remains a **Context Source**. An agent switch that changes that guidance produces a **Mid-Conversation System Message** while preserving the current baseline. -- Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy. -- Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily. -- Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry. +- Local tool authorization and pending permission requests retain the effective agent of the **Step** that issued the call; a later agent switch cannot change that call's policy. +- Context source changes never wake idle sessions; the next naturally scheduled **Safe Step Boundary** loads and compares current values lazily. +- Once admitted, a **Mid-Conversation System Message** remains durable even if the following **Physical Attempt** fails and is replayed unchanged on retry. - **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them. - The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later. - A **Context Epoch** begins with one immutable **Baseline System Context**. - A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**. - A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix. -- Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history. -- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn. -- **Native Continuation Metadata** remains in durable history. Provider-turn projection includes it only for a successful exact originating provider/model match; failed turns and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility. +- Completed compaction starts a new **Context Epoch** on the next **Physical Attempt**, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history. +- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next **Step**. +- **Native Continuation Metadata** remains in durable history. Step projection includes it only for a successful exact originating provider/model match; failed Steps and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility. - **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding. - **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing. - The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`. diff --git a/bun.lock b/bun.lock index 6a203012b5..a692e1119f 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ }, "devDependencies": { "@actions/artifact": "5.0.1", + "@ast-grep/cli": "0.44.0", "@tsconfig/bun": "catalog:", "@types/mime-types": "3.0.1", "@typescript/native-preview": "catalog:", @@ -100,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:*", @@ -121,14 +128,14 @@ }, "packages/client": { "name": "@opencode-ai/client", + "version": "1.17.13", "dependencies": { "@opencode-ai/protocol": "workspace:*", "@opencode-ai/schema": "workspace:*", }, "devDependencies": { - "@opencode-ai/core": "workspace:*", + "@effect/platform-node": "catalog:", "@opencode-ai/httpapi-codegen": "workspace:*", - "@opencode-ai/server": "workspace:*", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@typescript/native-preview": "catalog:", @@ -141,6 +148,20 @@ "effect", ], }, + "packages/codemode": { + "name": "@opencode-ai/codemode", + "version": "0.0.1", + "dependencies": { + "acorn": "8.15.0", + "effect": "catalog:", + "typescript": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/console/app": { "name": "@opencode-ai/console-app", "version": "1.17.13", @@ -306,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:*", @@ -514,6 +536,7 @@ }, "packages/httpapi-codegen": { "name": "@opencode-ai/httpapi-codegen", + "version": "0.0.0", "dependencies": { "effect": "catalog:", "prettier": "3.6.2", @@ -582,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:*", @@ -679,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:*", @@ -778,8 +803,10 @@ "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:*", "drizzle-orm": "catalog:", "effect": "catalog:", }, @@ -833,6 +860,21 @@ "vite": "catalog:", }, }, + "packages/simulation": { + "name": "@opencode-ai/simulation", + "version": "1.17.13", + "dependencies": { + "@opencode-ai/core": "workspace:*", + "@opencode-ai/llm": "workspace:*", + "@opentui/core": "catalog:", + "effect": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/slack": { "name": "@opencode-ai/slack", "version": "1.17.13", @@ -947,6 +989,7 @@ "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/simulation": "workspace:*", "@opencode-ai/ui": "workspace:*", "@opentui/core": "catalog:", "@opentui/keymap": "catalog:", @@ -1221,6 +1264,22 @@ "@anycable/core": ["@anycable/core@0.9.2", "", { "dependencies": { "nanoevents": "^7.0.1" } }, "sha512-x5ZXDcW/N4cxWl93CnbHs/u7qq4793jS2kNPWm+duPrXlrva+ml2ZGT7X9tuOBKzyIHf60zWCdIK7TUgMPAwXA=="], + "@ast-grep/cli": ["@ast-grep/cli@0.44.0", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.44.0", "@ast-grep/cli-darwin-x64": "0.44.0", "@ast-grep/cli-linux-arm64-gnu": "0.44.0", "@ast-grep/cli-linux-x64-gnu": "0.44.0", "@ast-grep/cli-win32-arm64-msvc": "0.44.0", "@ast-grep/cli-win32-ia32-msvc": "0.44.0", "@ast-grep/cli-win32-x64-msvc": "0.44.0" }, "bin": { "sg": "sg", "ast-grep": "ast-grep" } }, "sha512-Jf4PuP7XjzsMa3m9gYxmzV8KyWZc4w1ZzKe/t0+90wWxmSasQJe6AtMkJxHEi98MGgfAF1nWziqjDd0/6EsBjA=="], + + "@ast-grep/cli-darwin-arm64": ["@ast-grep/cli-darwin-arm64@0.44.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bF7euu/hF/cYg4510z8110vh60rrqfrBdsfRqVGd6xqNSPENu7CJnTVN/Z4Nk5U1NM8YKzUD+dYx1ySUJ0CUNQ=="], + + "@ast-grep/cli-darwin-x64": ["@ast-grep/cli-darwin-x64@0.44.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-0fI9caQGp1dFcmBATNlVytIRdAeYb91v1D2xjMIi1bSX+l8Uj846JUiaimUGBuBZmyFq+BScoWM4RnprEmZMpQ=="], + + "@ast-grep/cli-linux-arm64-gnu": ["@ast-grep/cli-linux-arm64-gnu@0.44.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-JB6EUnqEtGGtyg1GqNquld/++1CvaWD7r84IwwhddX1qx0NmDoHyn2mKd8vnQ24Z0RkV3g7y7foMLakELbGtDw=="], + + "@ast-grep/cli-linux-x64-gnu": ["@ast-grep/cli-linux-x64-gnu@0.44.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rNL0LsI682D9EMzfaGVEtZa1xaqTtGb2I+Zk4ZzidX6u+fF7f79wdqyKahKjXzoIrGkuhkoL3gcyLKAtQd9+qg=="], + + "@ast-grep/cli-win32-arm64-msvc": ["@ast-grep/cli-win32-arm64-msvc@0.44.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-lqD0MhGQAddh2YoV/brKQ6GVcFLmRiTBwIElutwedaUvRCdasTGukFPYuSWk/iI8Kv19xom6s7l+mGuZ7v+xwQ=="], + + "@ast-grep/cli-win32-ia32-msvc": ["@ast-grep/cli-win32-ia32-msvc@0.44.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-ZJrnS+2OkNfwyr6yrN69glP67uybBxDvl9mqZvh1J44vB3OFn9U9c+cVAoZIAo7JD5F4rZNxwyu3gcy4+xuwEA=="], + + "@ast-grep/cli-win32-x64-msvc": ["@ast-grep/cli-win32-x64-msvc@0.44.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OJEo7f95YYaSuS1byUB7ZctbzxoA7/wCoAol+pt6pvfdW/8Wq+L1qU28glwx7dQ0HgTsnPZbWpXQwmZpCBHhZg=="], + "@astrojs/check": ["@astrojs/check@0.9.6", "", { "dependencies": { "@astrojs/language-server": "^2.16.1", "chokidar": "^4.0.1", "kleur": "^4.1.5", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": "^5.0.0" }, "bin": { "astro-check": "bin/astro-check.js" } }, "sha512-jlaEu5SxvSgmfGIFfNgcn5/f+29H61NJzEMfAZ82Xopr4XBchXB1GVlcJsE+elUlsYSbXlptZLX+JMG3b/wZEA=="], "@astrojs/cloudflare": ["@astrojs/cloudflare@12.6.3", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.1", "@astrojs/underscore-redirects": "1.0.0", "@cloudflare/workers-types": "^4.20250507.0", "tinyglobby": "^0.2.13", "vite": "^6.3.5", "wrangler": "^4.14.1" }, "peerDependencies": { "astro": "^5.0.0" } }, "sha512-xhJptF5tU2k5eo70nIMyL1Udma0CqmUEnGSlGyFflLqSY82CRQI6nWZ/xZt0ZvmXuErUjIx0YYQNfZsz5CNjLQ=="], @@ -1939,6 +1998,8 @@ "@opencode-ai/client": ["@opencode-ai/client@workspace:packages/client"], + "@opencode-ai/codemode": ["@opencode-ai/codemode@workspace:packages/codemode"], + "@opencode-ai/console-app": ["@opencode-ai/console-app@workspace:packages/console/app"], "@opencode-ai/console-core": ["@opencode-ai/console-core@workspace:packages/console/core"], @@ -1985,6 +2046,8 @@ "@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"], + "@opencode-ai/simulation": ["@opencode-ai/simulation@workspace:packages/simulation"], + "@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"], "@opencode-ai/stats-app": ["@opencode-ai/stats-app@workspace:packages/stats/app"], @@ -3025,7 +3088,7 @@ "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -3473,7 +3536,7 @@ "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], - "detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], @@ -5707,6 +5770,8 @@ "@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="], + "@astrojs/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "@astrojs/sitemap/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], @@ -5917,6 +5982,8 @@ "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + "@mdx-js/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "@modelcontextprotocol/sdk/hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="], @@ -6027,6 +6094,8 @@ "@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@parcel/watcher/detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], + "@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], "@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], @@ -6093,8 +6162,6 @@ "@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], - "@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], @@ -6165,6 +6232,8 @@ "astro/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="], + "astro/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="], "astro/diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="], @@ -6251,6 +6320,8 @@ "engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], + "esast-util-from-js/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "esbuild-plugin-copy/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "esbuild-plugin-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], @@ -6303,12 +6374,12 @@ "light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="], - "lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="], + "micromark-extension-mdxjs/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], @@ -6333,8 +6404,6 @@ "node-gyp/undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="], - "node-gyp-build-optional-packages/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "nypm/citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], @@ -6401,8 +6470,6 @@ "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], - "sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "shiki/@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="], "shiki/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], @@ -6429,6 +6496,8 @@ "tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + "terser/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], @@ -6447,6 +6516,8 @@ "unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], + "unplugin/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "unplugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="], @@ -6461,6 +6532,8 @@ "verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="], + "vite-plugin-dynamic-import/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], "vitest/@vitest/expect": ["@vitest/expect@4.1.7", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w=="], @@ -6885,6 +6958,8 @@ "@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@storybook/csf-plugin/unplugin/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], diff --git a/package.json b/package.json index 226a86ff2c..9da29ab6ba 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev", "dev:storybook": "bun --cwd packages/storybook storybook", "lint": "oxlint", + "lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/core/src packages/server/src packages/protocol/src packages/cli/src", + "test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml", "typecheck": "bun turbo typecheck", "upgrade-opentui": "bun run script/upgrade-opentui.ts", "postinstall": "bun run --cwd packages/core fix-node-pty", @@ -95,6 +97,7 @@ }, "devDependencies": { "@actions/artifact": "5.0.1", + "@ast-grep/cli": "0.44.0", "@tsconfig/bun": "catalog:", "@types/mime-types": "3.0.1", "@typescript/native-preview": "catalog:", diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 647e5002a2..dfc1bb8c22 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -177,7 +177,7 @@ export function DialogCustomProvider(props: Props) { >
- +
{language.t("provider.custom.title")}
diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index 24e7a60104..ca6413e4ba 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -226,7 +226,7 @@ const SettingsProvidersContent: Component = () => { >
- + {language.t("provider.custom.title")} {language.t("settings.providers.tag.custom")}
diff --git a/packages/app/src/components/settings-v2/providers.tsx b/packages/app/src/components/settings-v2/providers.tsx index cd24bbd455..e244581b97 100644 --- a/packages/app/src/components/settings-v2/providers.tsx +++ b/packages/app/src/components/settings-v2/providers.tsx @@ -223,7 +223,7 @@ export const SettingsProvidersV2: Component = () => {
{ 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", diff --git a/packages/app/src/context/file/watcher.ts b/packages/app/src/context/file/watcher.ts index fbf7199279..1dcaeffd25 100644 --- a/packages/app/src/context/file/watcher.ts +++ b/packages/app/src/context/file/watcher.ts @@ -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) : undefined const rawPath = typeof props?.file === "string" ? props.file : undefined diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 36be14546b..25b2459954 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -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) diff --git a/packages/cli/package.json b/packages/cli/package.json index 122cd993ba..2b26f52ec2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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:*", diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index b8101f4ae5..a3b49b3d06 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -3,6 +3,31 @@ import { Spec } from "../framework/spec" declare const OPENCODE_CLI_NAME: string | undefined +const MiniParams = { + continue: Flag.boolean("continue").pipe( + Flag.withAlias("c"), + Flag.withDescription("Continue the last session"), + Flag.withDefault(false), + ), + session: Flag.string("session").pipe( + Flag.withAlias("s"), + Flag.withDescription("Session ID to continue"), + Flag.optional, + ), + fork: Flag.boolean("fork").pipe( + Flag.withDescription("Fork the session when continuing"), + Flag.withDefault(false), + ), + replay: Flag.boolean("replay").pipe( + Flag.withDescription("Replay session history on resume and after resize"), + Flag.withDefault(true), + ), + replayLimit: Flag.integer("replay-limit").pipe( + Flag.withDescription("Cap visible replay to the newest N messages"), + Flag.optional, + ), +} + export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", { description: "OpenCode 2.0 preview command line interface", params: { @@ -88,6 +113,86 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO ], }), Spec.make("migrate", { description: "Migrate v1 data to v2" }), + Spec.make("mini", { + description: "Start the minimal interactive interface", + params: { + ...MiniParams, + project: Argument.string("project").pipe( + Argument.withDescription("Path to start OpenCode in"), + Argument.optional, + ), + model: Flag.string("model").pipe( + Flag.withAlias("m"), + Flag.withDescription("Model to use in the format provider/model"), + Flag.optional, + ), + agent: Flag.string("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional), + prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional), + server: Flag.string("server").pipe( + Flag.withDescription("Connect to a server URL instead of the background service"), + Flag.optional, + ), + demo: Flag.boolean("demo").pipe(Flag.withDefault(false), Flag.withHidden), + }, + }), + Spec.make("run", { + description: "Run OpenCode with a message", + params: { + message: Argument.string("message").pipe( + Argument.withDescription("Message to send"), + Argument.variadic({ min: 0 }), + ), + continue: Flag.boolean("continue").pipe( + Flag.withAlias("c"), + Flag.withDescription("Continue the last session"), + Flag.withDefault(false), + ), + session: Flag.string("session").pipe( + Flag.withAlias("s"), + Flag.withDescription("Session ID to continue"), + Flag.optional, + ), + fork: Flag.boolean("fork").pipe( + Flag.withDescription("Fork the session before continuing"), + Flag.withDefault(false), + ), + model: Flag.string("model").pipe( + Flag.withAlias("m"), + Flag.withDescription("Model to use in the format provider/model"), + Flag.optional, + ), + agent: Flag.string("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional), + format: Flag.choice("format", ["default", "json"]).pipe( + Flag.withDescription("Output format"), + Flag.withDefault("default"), + ), + file: Flag.string("file").pipe( + Flag.withAlias("f"), + Flag.withDescription("File to attach to the message"), + Flag.atMost(100), + ), + title: Flag.string("title").pipe(Flag.withDescription("Session title"), Flag.optional), + server: Flag.string("server").pipe( + Flag.withDescription("Connect to a server URL instead of the background service"), + Flag.optional, + ), + dir: Flag.string("dir").pipe(Flag.withDescription("Directory to run in"), Flag.optional), + variant: Flag.string("variant").pipe(Flag.withDescription("Model variant"), Flag.optional), + thinking: Flag.boolean("thinking").pipe( + Flag.withDescription("Show thinking blocks"), + Flag.withDefault(false), + ), + auto: Flag.boolean("auto").pipe( + Flag.withDescription("Auto-approve permissions that are not explicitly denied"), + Flag.withDefault(false), + ), + yolo: Flag.boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden), + dangerouslySkipPermissions: Flag.boolean("dangerously-skip-permissions").pipe( + Flag.withDefault(false), + Flag.withHidden, + ), + }, + }), Spec.make("service", { description: "Manage the background server", commands: [ diff --git a/packages/cli/src/commands/handlers/api.ts b/packages/cli/src/commands/handlers/api.ts index 82d273c25d..de4d1a2b87 100644 --- a/packages/cli/src/commands/handlers/api.ts +++ b/packages/cli/src/commands/handlers/api.ts @@ -4,7 +4,6 @@ import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" import { Service } from "@opencode-ai/client/effect" import { ServiceConfig } from "../../services/service-config" -import type { Transport } from "@opencode-ai/client/effect" const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"]) @@ -61,7 +60,7 @@ export function rawRequest(input: readonly string[]) { } function resolveRequest( - transport: Transport, + transport: Service.Transport, input: readonly string[], params: Record, ) { diff --git a/packages/cli/src/commands/handlers/debug/agents.ts b/packages/cli/src/commands/handlers/debug/agents.ts index ee241bcc1b..1dbaf4d801 100644 --- a/packages/cli/src/commands/handlers/debug/agents.ts +++ b/packages/cli/src/commands/handlers/debug/agents.ts @@ -1,5 +1,5 @@ import { EOL } from "os" -import * as Effect from "effect/Effect" +import { Effect } from "effect" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 98b0c9b3c9..d7ba88a824 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -1,9 +1,9 @@ import { NodeFileSystem } from "@effect/platform-node" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" -import { Effect, Option } from "effect" +import { Effect, Option, Redacted } from "effect" import { Service } from "@opencode-ai/client/effect" -import type { Transport } from "@opencode-ai/client/effect" +import { Env } from "../../env" import { ServiceConfig } from "../../services/service-config" import { Standalone } from "../../services/standalone" import { Updater } from "../../services/updater" @@ -19,11 +19,29 @@ export default Runtime.handler(Commands, (input) => return yield* Effect.fail(new Error("--server and --standalone cannot be combined")) const transport = yield* Effect.gen(function* () { if (server !== undefined) { - const password = process.env["OPENCODE_SERVER_PASSWORD"] - return { + const password = yield* Env.password + const explicit = { url: server, - headers: password ? { authorization: "Basic " + btoa("opencode:" + password) } : undefined, - } satisfies Transport + headers: password + ? { authorization: "Basic " + btoa("opencode:" + Redacted.value(password)) } + : undefined, + } satisfies Service.Transport + // Fail loudly before entering the TUI: an explicit server that is + // unreachable or rejects auth should not present as reconnect churn. + const response = yield* Effect.tryPromise(() => + fetch(new URL("/api/health", server), { headers: explicit.headers, signal: AbortSignal.timeout(5_000) }), + ).pipe(Effect.mapError((cause) => new Error(`Could not reach server at ${server}`, { cause }))) + if (response.status === 401) + return yield* Effect.fail( + new Error( + password + ? `Server at ${server} rejected the password` + : `Server at ${server} requires a password; set OPENCODE_PASSWORD`, + ), + ) + if (!response.ok) + return yield* Effect.fail(new Error(`Server at ${server} responded with status ${response.status}`)) + return explicit } if (input.standalone) return yield* Standalone.transport() const options = yield* ServiceConfig.options() @@ -45,6 +63,24 @@ export default Runtime.handler(Commands, (input) => }).pipe(Effect.provide(NodeFileSystem.layer)), ) : () => Promise.resolve(transport) - yield* runTui(transport, { continue: input.continue, sessionID: Option.getOrUndefined(input.session) }, discover) + // Restart the managed service in place; start() resolves once the + // replacement is healthy and the reconnect loop reattaches on its own. + // Only meaningful in service mode: --server is not ours to restart and a + // standalone child cannot be respawned. + const reload = serviceOptions + ? () => + Effect.runPromise( + Effect.gen(function* () { + yield* Service.stop(serviceOptions) + yield* Service.start(serviceOptions) + }).pipe(Effect.provide(NodeFileSystem.layer)), + ) + : undefined + yield* runTui( + transport, + { continue: input.continue, sessionID: Option.getOrUndefined(input.session) }, + discover, + reload, + ) }), ) diff --git a/packages/cli/src/commands/handlers/mcp/list.ts b/packages/cli/src/commands/handlers/mcp/list.ts index 167509fb70..2d38a6aca0 100644 --- a/packages/cli/src/commands/handlers/mcp/list.ts +++ b/packages/cli/src/commands/handlers/mcp/list.ts @@ -1,5 +1,5 @@ import { EOL } from "node:os" -import * as Effect from "effect/Effect" +import { Effect } from "effect" import { createOpencodeClient, type McpServer } from "@opencode-ai/sdk/v2/client" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" diff --git a/packages/cli/src/commands/handlers/migrate.ts b/packages/cli/src/commands/handlers/migrate.ts index c73c7750df..6ff6939aa1 100644 --- a/packages/cli/src/commands/handlers/migrate.ts +++ b/packages/cli/src/commands/handlers/migrate.ts @@ -1,4 +1,4 @@ -import * as Effect from "effect/Effect" +import { Effect } from "effect" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" diff --git a/packages/cli/src/commands/handlers/mini.ts b/packages/cli/src/commands/handlers/mini.ts new file mode 100644 index 0000000000..410757967f --- /dev/null +++ b/packages/cli/src/commands/handlers/mini.ts @@ -0,0 +1,35 @@ +import { Effect, Option, Redacted } from "effect" +import path from "node:path" +import { Commands } from "../commands" +import { Env } from "../../env" +import { Runtime } from "../../framework/runtime" + +export default Runtime.handler(Commands.commands.mini, (input) => + Effect.gen(function* () { + const { runMini } = yield* Effect.promise(() => import("../../mini")) + const project = Option.getOrUndefined(input.project) + const server = Option.getOrUndefined(input.server) + const password = yield* Env.password + yield* Effect.promise(() => + runMini({ + attach: server, + password: password ? Redacted.value(password) : undefined, + directory: + server !== undefined + ? project + : project === undefined + ? process.cwd() + : path.resolve(process.env.PWD ?? process.cwd(), project), + continue: input.continue, + session: Option.getOrUndefined(input.session), + fork: input.fork, + model: Option.getOrUndefined(input.model), + agent: Option.getOrUndefined(input.agent), + prompt: Option.getOrUndefined(input.prompt), + replay: input.replay, + replayLimit: Option.getOrUndefined(input.replayLimit), + demo: input.demo, + }), + ) + }), +) diff --git a/packages/cli/src/commands/handlers/run.ts b/packages/cli/src/commands/handlers/run.ts new file mode 100644 index 0000000000..aa09a32ea9 --- /dev/null +++ b/packages/cli/src/commands/handlers/run.ts @@ -0,0 +1,31 @@ +import { Effect, Option, Redacted } from "effect" +import { Commands } from "../commands" +import { Env } from "../../env" +import { Runtime } from "../../framework/runtime" + +export default Runtime.handler(Commands.commands.run, (input) => + Effect.gen(function* () { + const { runNonInteractive } = yield* Effect.promise(() => import("../../mini")) + const password = yield* Env.password + const separator = process.argv.indexOf("--", 2) + yield* Effect.promise(() => + runNonInteractive({ + message: [...input.message, ...(separator === -1 ? [] : process.argv.slice(separator + 1))], + continue: input.continue, + session: Option.getOrUndefined(input.session), + fork: input.fork, + model: Option.getOrUndefined(input.model), + agent: Option.getOrUndefined(input.agent), + format: input.format, + file: [...input.file], + title: Option.getOrUndefined(input.title), + server: Option.getOrUndefined(input.server), + password: password ? Redacted.value(password) : undefined, + directory: Option.getOrUndefined(input.dir), + variant: Option.getOrUndefined(input.variant), + thinking: input.thinking, + dangerouslySkipPermissions: input.auto || input.yolo || input.dangerouslySkipPermissions, + }), + ) + }), +) diff --git a/packages/cli/src/commands/handlers/serve.ts b/packages/cli/src/commands/handlers/serve.ts index 80cf6cba9f..bd3e0c1217 100644 --- a/packages/cli/src/commands/handlers/serve.ts +++ b/packages/cli/src/commands/handlers/serve.ts @@ -1,145 +1,16 @@ -import { NodeHttpServer } from "@effect/platform-node" -import { Credential } from "@opencode-ai/core/credential" -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { PermissionSaved } from "@opencode-ai/core/permission/saved" -import { Global } from "@opencode-ai/core/global" -import { Context, FileSystem, Layer, Option, Schedule, Schema } from "effect" -import * as Effect from "effect/Effect" -import { HttpRouter, HttpServer } from "effect/unstable/http" -import { createServer } from "node:http" -import { createRoutes } from "@opencode-ai/server/routes" -import { ServerAuth } from "@opencode-ai/server/auth" -import { InstallationVersion } from "@opencode-ai/core/installation/version" -import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { Effect, Option } from "effect" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" -import { ServiceConfig } from "../../services/service-config" -import { Updater } from "../../services/updater" -import { randomBytes, randomUUID } from "crypto" -import path from "path" +import { ServerProcess } from "../../server-process" export default Runtime.handler( Commands.commands.serve, Effect.fn("cli.serve")(function* (input) { - if (input.service) yield* Effect.sync(() => process.chdir(Global.Path.home)) - return yield* Effect.scoped( - Effect.gen(function* () { - const standalonePassword = process.env.OPENCODE_SERVER_PASSWORD - if (input.stdio) delete process.env.OPENCODE_SERVER_PASSWORD - const config = input.service ? yield* ServiceConfig.read() : {} - const password = input.service - ? yield* ServiceConfig.password() - : standalonePassword || randomBytes(32).toString("base64url") - if (!password) return yield* Effect.fail(new Error("Missing server password")) - const hostname = Option.getOrUndefined(input.hostname) ?? config.hostname ?? "127.0.0.1" - const port = Option.isSome(input.port) - ? input.port - : config.port === undefined - ? Option.none() - : Option.some(config.port) - const address = yield* listen(hostname, port, password) - yield* Effect.tryPromise(() => - createOpencodeClient({ - baseUrl: HttpServer.formatAddress(address), - headers: ServerAuth.headers({ password }), - }).v2.health.get({}), - ) - if (input.service) yield* register(address) - const url = HttpServer.formatAddress(address) - console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`) - if (!input.service && !input.stdio && !standalonePassword) console.log(`server password ${password}`) - const updater = yield* Updater.Service - yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped) - return yield* (input.stdio ? waitForStdinClose() : Effect.never) - }).pipe(Effect.annotateLogs({ role: "server" })), - ) + if (input.service && input.stdio) return yield* Effect.fail(new Error("--service and --stdio cannot be combined")) + return yield* ServerProcess.run({ + mode: input.service ? "service" : input.stdio ? "stdio" : "default", + hostname: Option.getOrUndefined(input.hostname), + port: Option.getOrUndefined(input.port), + }) }), ) - -// Server-side half of the registration protocol. The registration embeds the -// password so the file alone is enough for any client to discover and -// authenticate. The file arbitrates ownership after concurrent starts; it is -// not a startup lock: the atomic rename elects the latest writer, the watcher -// self-evicts losers, and the finalizer id-guard keeps an exiting server from -// deleting its successor's registration. -const RegistrationId = Schema.Struct({ id: Schema.optional(Schema.String) }) -const decodeRegistrationId = Schema.decodeUnknownEffect(Schema.fromJsonString(RegistrationId)) - -const register = Effect.fnUntraced(function* (address: HttpServer.Address) { - const fs = yield* FileSystem.FileSystem - const { file } = yield* ServiceConfig.options() - const id = randomUUID() - const secret = yield* ServiceConfig.password() - const temp = file + "." + id + ".tmp" - yield* fs.makeDirectory(path.dirname(file), { recursive: true }) - yield* fs.writeFileString( - temp, - JSON.stringify({ - id, - version: InstallationVersion, - url: HttpServer.formatAddress(address), - pid: process.pid, - password: secret, - }), - { mode: 0o600 }, - ) - yield* fs.rename(temp, file) - const currentID = fs.readFileString(file).pipe( - Effect.flatMap(decodeRegistrationId), - Effect.map((info) => info.id), - Effect.orElseSucceed(() => undefined), - ) - yield* currentID.pipe( - Effect.flatMap((current) => - current === id - ? Effect.void - : Effect.try({ try: () => process.kill(process.pid, "SIGTERM"), catch: (cause) => cause }).pipe(Effect.ignore), - ), - Effect.repeat(Schedule.spaced("10 seconds")), - Effect.forkScoped, - ) - yield* Effect.addFinalizer(() => - currentID.pipe( - Effect.flatMap((current) => (current === id ? fs.remove(file) : Effect.void)), - Effect.ignore, - ), - ) -}) - -function waitForStdinClose() { - return Effect.callback((resume) => { - const close = () => resume(Effect.void) - process.stdin.once("end", close) - process.stdin.once("close", close) - process.stdin.resume() - if (process.stdin.readableEnded || process.stdin.destroyed) close() - return Effect.sync(() => { - process.stdin.off("end", close) - process.stdin.off("close", close) - process.stdin.pause() - }) - }) -} - -function listen(hostname: string, port: Option.Option, password: string) { - if (Option.isSome(port)) return bind(hostname, port.value, password) - const next = (port: number): ReturnType => - bind(hostname, port, password).pipe( - Effect.catch((error) => (port === 65_535 ? Effect.fail(error) : next(port + 1))), - ) - return next(4096) -} - -function bind(hostname: string, port: number, password: string) { - const server = createServer() - return Layer.build( - HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe( - Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })), - Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node]))), - ), - ).pipe( - Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))), - Effect.map((context) => Context.get(context, HttpServer.HttpServer).address), - ) -} diff --git a/packages/cli/src/commands/handlers/service/get.ts b/packages/cli/src/commands/handlers/service/get.ts index 1b85faf6e9..fd4b9af384 100644 --- a/packages/cli/src/commands/handlers/service/get.ts +++ b/packages/cli/src/commands/handlers/service/get.ts @@ -1,6 +1,5 @@ import { EOL } from "os" -import { Option } from "effect" -import * as Effect from "effect/Effect" +import { Effect, Option } from "effect" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" import { ServiceConfig } from "../../../services/service-config" diff --git a/packages/cli/src/commands/handlers/service/restart.ts b/packages/cli/src/commands/handlers/service/restart.ts index c78273ed0f..93b8836acd 100644 --- a/packages/cli/src/commands/handlers/service/restart.ts +++ b/packages/cli/src/commands/handlers/service/restart.ts @@ -1,5 +1,5 @@ import { EOL } from "os" -import * as Effect from "effect/Effect" +import { Effect } from "effect" import { Service } from "@opencode-ai/client/effect" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" diff --git a/packages/cli/src/commands/handlers/service/set.ts b/packages/cli/src/commands/handlers/service/set.ts index 6ecde18d41..f761c02411 100644 --- a/packages/cli/src/commands/handlers/service/set.ts +++ b/packages/cli/src/commands/handlers/service/set.ts @@ -1,4 +1,4 @@ -import * as Effect from "effect/Effect" +import { Effect } from "effect" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" import { ServiceConfig } from "../../../services/service-config" diff --git a/packages/cli/src/commands/handlers/service/start.ts b/packages/cli/src/commands/handlers/service/start.ts index a3a7200bf9..602a26ecf1 100644 --- a/packages/cli/src/commands/handlers/service/start.ts +++ b/packages/cli/src/commands/handlers/service/start.ts @@ -1,5 +1,5 @@ import { EOL } from "os" -import * as Effect from "effect/Effect" +import { Effect } from "effect" import { Service } from "@opencode-ai/client/effect" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" diff --git a/packages/cli/src/commands/handlers/service/status.ts b/packages/cli/src/commands/handlers/service/status.ts index 807d7d749d..bf58968eef 100644 --- a/packages/cli/src/commands/handlers/service/status.ts +++ b/packages/cli/src/commands/handlers/service/status.ts @@ -1,5 +1,5 @@ import { EOL } from "os" -import * as Effect from "effect/Effect" +import { Effect } from "effect" import { Service } from "@opencode-ai/client/effect" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" diff --git a/packages/cli/src/commands/handlers/service/stop.ts b/packages/cli/src/commands/handlers/service/stop.ts index 53ad3615c3..5bf45ccabc 100644 --- a/packages/cli/src/commands/handlers/service/stop.ts +++ b/packages/cli/src/commands/handlers/service/stop.ts @@ -1,4 +1,4 @@ -import * as Effect from "effect/Effect" +import { Effect } from "effect" import { Service } from "@opencode-ai/client/effect" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" diff --git a/packages/cli/src/commands/handlers/service/unset.ts b/packages/cli/src/commands/handlers/service/unset.ts index ef5fdf9330..cc738125d3 100644 --- a/packages/cli/src/commands/handlers/service/unset.ts +++ b/packages/cli/src/commands/handlers/service/unset.ts @@ -1,4 +1,4 @@ -import * as Effect from "effect/Effect" +import { Effect } from "effect" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" import { ServiceConfig } from "../../../services/service-config" diff --git a/packages/cli/src/daemon.ts b/packages/cli/src/daemon.ts new file mode 100644 index 0000000000..fff1213f81 --- /dev/null +++ b/packages/cli/src/daemon.ts @@ -0,0 +1,70 @@ +import { Service } from "@opencode-ai/client/effect" +import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { ServerAuth } from "@opencode-ai/server/auth" +import { Effect } from "effect" +import { ServiceConfig } from "./services/service-config" + +export type SharedOptions = { + readonly mode: "shared" + readonly command?: ReadonlyArray +} +export type AttachOptions = { + readonly mode: "attach" + readonly url: string + readonly username?: string + readonly password?: string +} +export type Options = SharedOptions | AttachOptions + +const attach = Effect.fn("cli.daemon.attach")(function* (options: AttachOptions) { + const transport = { + url: options.url, + headers: + options.password === undefined + ? undefined + : ServerAuth.headers({ password: options.password, username: options.username }), + } satisfies Service.Transport + const client = OpenCode.make({ baseUrl: transport.url, headers: transport.headers }) + const health = yield* Effect.tryPromise({ + try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }), + catch: (cause) => attachError(options, cause), + }) + if (health.version !== InstallationVersion) + return yield* Effect.fail( + new Error(`Server at ${options.url} has version ${health.version}; this client requires ${InstallationVersion}`), + ) + return transport +}) + +const shared = Effect.fn("cli.daemon.shared")(function* (options: SharedOptions) { + const config = yield* ServiceConfig.options() + const service = options.command === undefined ? config : { ...config, command: options.command } + const found = yield* Service.discover(service) + if (found) return found + return yield* Service.start(service) +}) + +export function transport(options: AttachOptions): ReturnType +export function transport(options: SharedOptions): ReturnType +export function transport(options: Options): ReturnType | ReturnType +export function transport(options: Options) { + if (options.mode === "attach") return attach(options) + return shared(options) +} + +function attachError(options: AttachOptions, cause: unknown) { + if (isUnauthorizedError(cause)) { + return new Error( + options.password === undefined + ? `Server at ${options.url} requires authentication; provide a password` + : `Server at ${options.url} rejected the supplied credentials`, + { cause }, + ) + } + if (cause instanceof ClientError && cause.reason === "Transport") + return new Error(`Could not reach server at ${options.url}`, { cause }) + return new Error(`Server at ${options.url} did not provide a compatible V2 health response`, { cause }) +} + +export * as Daemon from "./daemon" diff --git a/packages/cli/src/env.ts b/packages/cli/src/env.ts new file mode 100644 index 0000000000..6cc76b793f --- /dev/null +++ b/packages/cli/src/env.ts @@ -0,0 +1,15 @@ +import { Config } from "effect" + +// Every environment variable the CLI reads, in one place. Consumers yield +// these instead of touching process.env so the full surface stays visible, +// typed, and redacted where secret. + +// The opencode server password: sent by clients connecting to an explicit +// --server, and adopted by a manually run or standalone server. The legacy +// name is still honored. +export const password = Config.redacted("OPENCODE_PASSWORD").pipe( + Config.orElse(() => Config.redacted("OPENCODE_SERVER_PASSWORD")), + Config.withDefault(undefined), +) + +export * as Env from "./env" diff --git a/packages/cli/src/framework/runtime.ts b/packages/cli/src/framework/runtime.ts index 902c89d716..2108da415d 100644 --- a/packages/cli/src/framework/runtime.ts +++ b/packages/cli/src/framework/runtime.ts @@ -1,9 +1,8 @@ -import * as Effect from "effect/Effect" -import * as Command from "effect/unstable/cli/Command" +import { Effect, FileSystem, Scope } from "effect" +import { Command } from "effect/unstable/cli" import { Spec } from "./spec" import { Global } from "@opencode-ai/core/global" import { Updater } from "../services/updater" -import { FileSystem, Scope } from "effect" export type Input = Value extends Spec.Node @@ -12,11 +11,21 @@ export type Input = ? Input : never -type RuntimeHandler = (input: unknown) => Effect.Effect +type RuntimeHandler = ( + input: unknown, +) => Effect.Effect type Loader = () => Promise<{ - default: (input: Input) => Effect.Effect + default: ( + input: Input, + ) => Effect.Effect }> -type ProvidedCommand = Command.Command +type ProvidedCommand = Command.Command< + string, + unknown, + unknown, + unknown, + FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope +> export type Handlers = keyof Node["commands"] extends never ? Loader diff --git a/packages/cli/src/framework/spec.ts b/packages/cli/src/framework/spec.ts index 3bb47e5e5e..345a0cfa85 100644 --- a/packages/cli/src/framework/spec.ts +++ b/packages/cli/src/framework/spec.ts @@ -1,4 +1,4 @@ -import * as Command from "effect/unstable/cli/Command" +import { Command } from "effect/unstable/cli" type Options> = { readonly description?: string diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 071fd37e1a..5b851d7f49 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,10 +1,7 @@ #!/usr/bin/env bun -import * as NodeRuntime from "@effect/platform-node/NodeRuntime" -import * as NodeServices from "@effect/platform-node/NodeServices" -import { NodeFileSystem } from "@effect/platform-node" -import * as Effect from "effect/Effect" -import { Layer, Logger, References } from "effect" +import { NodeFileSystem, NodeRuntime, NodeServices } from "@effect/platform-node" +import { Effect, Layer, Logger, References } from "effect" import { Commands } from "./commands/commands" import { Runtime } from "./framework/runtime" import { Logging } from "@opencode-ai/core/observability/logging" @@ -34,6 +31,8 @@ const Handlers = Runtime.handlers(Commands, { logout: () => import("./commands/handlers/mcp/logout"), }, migrate: () => import("./commands/handlers/migrate"), + mini: () => import("./commands/handlers/mini"), + run: () => import("./commands/handlers/run"), service: { start: () => import("./commands/handlers/service/start"), restart: () => import("./commands/handlers/service/restart"), @@ -46,7 +45,12 @@ const Handlers = Runtime.handlers(Commands, { serve: () => import("./commands/handlers/serve"), }) -Effect.logInfo("cli starting", { version: InstallationVersion, channel: InstallationChannel, local: InstallationLocal }).pipe( +Effect.logInfo("cli starting", { + version: InstallationVersion, + channel: InstallationChannel, + local: InstallationLocal, + args: process.argv.slice(2), +}).pipe( Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })), Effect.annotateLogs({ role: "cli" }), Effect.provide(Updater.layer), @@ -54,6 +58,6 @@ Effect.logInfo("cli starting", { version: InstallationVersion, channel: Installa Effect.provide(LoggingLayer), Effect.provide(NodeServices.layer), Effect.scoped, - Effect.tap(() => Effect.sync(() => process.exit(0))), + Effect.tap(() => Effect.sync(() => process.exit(process.exitCode ?? 0))), NodeRuntime.runMain, ) diff --git a/packages/opencode/src/cli/cmd/run/catalog.shared.ts b/packages/cli/src/mini/catalog.shared.ts similarity index 57% rename from packages/opencode/src/cli/cmd/run/catalog.shared.ts rename to packages/cli/src/mini/catalog.shared.ts index e4c8277fd9..d810c47077 100644 --- a/packages/opencode/src/cli/cmd/run/catalog.shared.ts +++ b/packages/cli/src/mini/catalog.shared.ts @@ -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>["data"]>["data"][number] -type CurrentCommand = NonNullable>["data"]>["data"][number] -type CurrentSkill = NonNullable>["data"]>["data"][number] -type CurrentProvider = NonNullable>["data"]>["data"][number] -type CurrentModel = NonNullable>["data"]>["data"][number] +type CurrentAgent = AgentListOutput["data"][number] +type CurrentCommand = CommandListOutput["data"][number] +type CurrentSkill = SkillListOutput["data"][number] +type CurrentProvider = ProviderListOutput["data"][number] +type CurrentModel = ModelListOutput["data"][number] -function location(directory: string) { +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 { - 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 { +export async function loadRunAgents(sdk: OpenCodeClient, directory: string): Promise { + const result = await sdk.agent.list(location(directory)) + return result.data.map(runAgent) +} + +export async function loadRunCommands(sdk: OpenCodeClient, directory: string): Promise { const [commands, skills] = await Promise.all([ - sdk.v2.command.list(location(directory), { throwOnError: true }), - sdk.v2.skill.list(location(directory), { throwOnError: true }), + sdk.command.list(location(directory)), + sdk.skill.list(location(directory)), ]) return [ - ...(commands.data?.data ?? []).map(runCommand), - ...(skills.data?.data ?? []).filter((skill) => skill.slash !== false).map(runSkill), + ...commands.data.map(runCommand), + ...skills.data.filter((skill) => skill.slash !== false).map(runSkill), ] } -export async function loadRunReferences(sdk: OpencodeClient, directory: string): Promise { - const result = await sdk.v2.reference.list(location(directory), { throwOnError: true }) - return (result.data?.data ?? []).filter((reference) => !reference.hidden) +export async function loadRunReferences(sdk: OpenCodeClient, directory: string): Promise { + const result = await sdk.reference.list(location(directory)) + return result.data.filter((reference) => !reference.hidden) } -export async function loadRunProviders(sdk: OpencodeClient, directory: string): Promise { +export async function loadRunProviders(sdk: OpenCodeClient, directory: string): Promise { const [providers, models] = await Promise.all([ - sdk.v2.provider.list(location(directory), { throwOnError: true }), - sdk.v2.model.list(location(directory), { throwOnError: true }), + sdk.provider.list(location(directory)), + sdk.model.list(location(directory)), ]) - return runProviders(providers.data?.data ?? [], models.data?.data ?? []) + return runProviders([...providers.data], [...models.data]) } diff --git a/packages/opencode/src/cli/cmd/run/demo.ts b/packages/cli/src/mini/demo.ts similarity index 99% rename from packages/opencode/src/cli/cmd/run/demo.ts rename to packages/cli/src/mini/demo.ts index 94450f1242..b06b3c4537 100644 --- a/packages/opencode/src/cli/cmd/run/demo.ts +++ b/packages/cli/src/mini/demo.ts @@ -678,7 +678,7 @@ function emitTask(state: State): void { state: { status: "running", input: { - filePath: "packages/opencode/src/cli/cmd/run/stream.ts", + filePath: "packages/cli/src/mini/stream.ts", offset: 1, limit: 200, }, diff --git a/packages/opencode/src/cli/cmd/run/entry.body.ts b/packages/cli/src/mini/entry.body.ts similarity index 100% rename from packages/opencode/src/cli/cmd/run/entry.body.ts rename to packages/cli/src/mini/entry.body.ts diff --git a/packages/opencode/src/cli/cmd/run/footer.command.tsx b/packages/cli/src/mini/footer.command.tsx similarity index 100% rename from packages/opencode/src/cli/cmd/run/footer.command.tsx rename to packages/cli/src/mini/footer.command.tsx diff --git a/packages/opencode/src/cli/cmd/run/footer.menu.tsx b/packages/cli/src/mini/footer.menu.tsx similarity index 99% rename from packages/opencode/src/cli/cmd/run/footer.menu.tsx rename to packages/cli/src/mini/footer.menu.tsx index 7e820cc6a7..ab9bea05ea 100644 --- a/packages/opencode/src/cli/cmd/run/footer.menu.tsx +++ b/packages/cli/src/mini/footer.menu.tsx @@ -3,7 +3,7 @@ import { TextAttributes, type ColorInput } from "@opentui/core" import { useTerminalDimensions } from "@opentui/solid" import { createEffect, createMemo, createSignal, type Accessor } from "solid-js" import { transparent, type RunFooterTheme } from "./theme" -import * as Locale from "@/util/locale" +import { Locale } from "@opencode-ai/tui/util/locale" export const FOOTER_MENU_ROWS = 8 diff --git a/packages/opencode/src/cli/cmd/run/footer.permission.tsx b/packages/cli/src/mini/footer.permission.tsx similarity index 100% rename from packages/opencode/src/cli/cmd/run/footer.permission.tsx rename to packages/cli/src/mini/footer.permission.tsx diff --git a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx b/packages/cli/src/mini/footer.prompt.tsx similarity index 97% rename from packages/opencode/src/cli/cmd/run/footer.prompt.tsx rename to packages/cli/src/mini/footer.prompt.tsx index 90efdc5695..6ba7940ab6 100644 --- a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx +++ b/packages/cli/src/mini/footer.prompt.tsx @@ -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 width: Accessor theme: Accessor - history?: RunPrompt[] + history?: Accessor onSubmit: (input: RunPrompt) => boolean | Promise 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 diff --git a/packages/opencode/src/cli/cmd/run/footer.question.tsx b/packages/cli/src/mini/footer.question.tsx similarity index 100% rename from packages/opencode/src/cli/cmd/run/footer.question.tsx rename to packages/cli/src/mini/footer.question.tsx diff --git a/packages/opencode/src/cli/cmd/run/footer.subagent.tsx b/packages/cli/src/mini/footer.subagent.tsx similarity index 100% rename from packages/opencode/src/cli/cmd/run/footer.subagent.tsx rename to packages/cli/src/mini/footer.subagent.tsx diff --git a/packages/opencode/src/cli/cmd/run/footer.ts b/packages/cli/src/mini/footer.ts similarity index 98% rename from packages/opencode/src/cli/cmd/run/footer.ts rename to packages/cli/src/mini/footer.ts index 6a4c03e797..ab53e9162e 100644 --- a/packages/opencode/src/cli/cmd/run/footer.ts +++ b/packages/cli/src/mini/footer.ts @@ -203,6 +203,8 @@ export class RunFooter implements FooterApi { private setSubagent: (next: FooterSubagentState) => void private queuedPrompts: Accessor private setQueuedPrompts: Setter + private history: Accessor + private setHistory: Setter private promptRoute: FooterPromptRoute = { type: "composer" } private subagentMenuRows = SUBAGENT_ROWS private autocomplete = false @@ -289,6 +291,9 @@ export class RunFooter implements FooterApi { const [queuedPrompts, setQueuedPrompts] = createSignal([]) this.queuedPrompts = queuedPrompts this.setQueuedPrompts = setQueuedPrompts + const [history, setHistory] = createSignal(options.history ?? []) + this.history = history + this.setHistory = setHistory this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS) this.scrollback = this.createScrollback(options.wrote ?? false) @@ -322,7 +327,7 @@ export class RunFooter implements FooterApi { diffStyle: options.diffStyle, tuiConfig: options.tuiConfig, backgroundSubagents: options.backgroundSubagents, - history: options.history, + history: footer.history, agent: options.agentLabel, onSubmit: footer.handlePrompt, onPermissionReply: footer.handlePermissionReply, @@ -390,6 +395,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() diff --git a/packages/opencode/src/cli/cmd/run/footer.view.tsx b/packages/cli/src/mini/footer.view.tsx similarity index 99% rename from packages/opencode/src/cli/cmd/run/footer.view.tsx rename to packages/cli/src/mini/footer.view.tsx index 245a24816d..b8f50659ba 100644 --- a/packages/opencode/src/cli/cmd/run/footer.view.tsx +++ b/packages/cli/src/mini/footer.view.tsx @@ -88,7 +88,7 @@ type RunFooterViewProps = { diffStyle?: RunDiffStyle tuiConfig: RunTuiConfig backgroundSubagents: boolean - history?: RunPrompt[] + history?: () => RunPrompt[] agent: string onSubmit: (input: RunPrompt) => boolean onPermissionReply: (input: PermissionReply) => void | Promise @@ -781,6 +781,7 @@ export function RunFooterView(props: RunFooterViewProps) { command: { name, arguments: "", + source: "skill", }, }) closePanel() diff --git a/packages/opencode/src/cli/cmd/run/footer.width.ts b/packages/cli/src/mini/footer.width.ts similarity index 100% rename from packages/opencode/src/cli/cmd/run/footer.width.ts rename to packages/cli/src/mini/footer.width.ts diff --git a/packages/cli/src/mini/index.ts b/packages/cli/src/mini/index.ts new file mode 100644 index 0000000000..62decb77a5 --- /dev/null +++ b/packages/cli/src/mini/index.ts @@ -0,0 +1,7 @@ +export { runMini, mergeInput as mergeInteractiveInput, type MiniCommandInput } from "./mini" +export { + runNonInteractive, + mergeInput as mergeNonInteractiveInput, + pickRunModel, + type RunCommandInput, +} from "./run" diff --git a/packages/cli/src/mini/mini.ts b/packages/cli/src/mini/mini.ts new file mode 100644 index 0000000000..9259bd5dbe --- /dev/null +++ b/packages/cli/src/mini/mini.ts @@ -0,0 +1,234 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" +import { truthy } from "@opencode-ai/core/flag/flag" +import { Global } from "@opencode-ai/core/global" +import { Effect } from "effect" +import path from "node:path" +import { Daemon } from "../daemon" +import { waitForCatalogReady } from "./catalog.shared" +import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin" +import type { RunInput, RunTuiConfig } from "./types" + +export type MiniCommandInput = { + directory?: string + attach?: string + password?: string + username?: string + continue?: boolean + session?: string + fork?: boolean + model?: string + agent?: string + prompt?: string + replay?: boolean + replayLimit?: number + demo?: boolean + serverCommand?: ReadonlyArray + tuiConfig?: RunTuiConfig | Promise +} + +type Session = Awaited> +type Transport = { readonly url: string; readonly headers?: HeadersInit } + +export async function runMini(input: MiniCommandInput) { + validate(input) + const initialInput = mergeInput(process.stdin.isTTY ? undefined : await Bun.stdin.text(), input.prompt) + const runtimeTask = import("./runtime") + const directory = input.attach ? input.directory : localDirectory(input.directory) + const transportTask = startTransport(input) + void transportTask.catch(() => {}) + + try { + if (input.attach) await transportTask + const sdk = OpenCode.make({ + baseUrl: "http://opencode.pending", + fetch: deferredFetch(transportTask), + }) + const attachedSession = + input.attach && input.session && !input.directory + ? await sdk.session.get({ sessionID: input.session }).catch(() => fail("Session not found")) + : undefined + const resolvedDirectory = + directory ?? attachedSession?.location.directory ?? (await remoteDirectory(await transportTask, sdk)) + const model = parseModel(input.model) + let agentTask: Promise | undefined + const resolveAgent = () => { + agentTask ??= validateAgent(sdk, resolvedDirectory, input.agent, input.attach) + return agentTask + } + const resolveSession = async () => { + const [agent, selected] = await Promise.all([ + resolveAgent(), + selectSession(sdk, resolvedDirectory, input, attachedSession), + ]) + const readyModel = + model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined) + if (readyModel) await waitForCatalogReady({ sdk, directory: resolvedDirectory, model: readyModel }) + const session = selected ?? (await createSession(sdk, resolvedDirectory, agent, model)) + return { id: session.id, title: session.title, resume: selected !== undefined } + } + const create = ( + _sdk: OpenCodeClient, + next: { agent: string | undefined; model: RunInput["model"]; variant: string | undefined }, + ) => createSession(sdk, resolvedDirectory, next.agent, next.model, next.variant) + const runtime = await runtimeTask + await runtime.runInteractiveDeferredMode({ + sdk, + directory: resolvedDirectory, + resolveAgent, + session: resolveSession, + createSession: create, + agent: input.agent, + model, + variant: undefined, + files: [], + initialInput, + thinking: true, + backgroundSubagents: + truthy("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS") || + (process.env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS === undefined && truthy("OPENCODE_EXPERIMENTAL")), + replay: input.replay ?? true, + replayLimit: input.replayLimit, + demo: input.demo, + tuiConfig: input.tuiConfig, + }) + } catch (error) { + if (error instanceof Error && error.message === INTERACTIVE_INPUT_ERROR) fail(error.message) + throw error + } +} + +/** @internal Exported for testing. */ +export function mergeInput(piped: string | undefined, prompt: string | undefined) { + if (!prompt) return piped || undefined + if (!piped) return prompt + return piped + "\n" + prompt +} + +function validate(input: MiniCommandInput) { + if (!process.stdout.isTTY) fail("opencode mini requires a TTY stdout") + if (input.replayLimit !== undefined && (!Number.isInteger(input.replayLimit) || input.replayLimit <= 0)) { + fail("--replay-limit must be a positive integer") + } + if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session") + resolveInteractiveStdin().cleanup?.() +} + +function localDirectory(directory?: string): string { + const root = process.env.PWD ?? process.cwd() + try { + process.chdir(directory ? (path.isAbsolute(directory) ? directory : path.join(root, directory)) : root) + return process.cwd() + } catch { + fail(`Failed to change directory to ${directory}`) + } +} + +function startTransport(input: MiniCommandInput): Promise { + if (input.attach) { + return Effect.runPromise( + Daemon.transport({ + mode: "attach", + url: input.attach, + password: input.password ?? process.env.OPENCODE_SERVER_PASSWORD, + username: input.username ?? process.env.OPENCODE_SERVER_USERNAME, + }), + ) + } + return Effect.runPromise( + Daemon.transport({ mode: "shared", command: input.serverCommand }).pipe( + Effect.provide(NodeFileSystem.layer), + Effect.provide(Global.layerWith({})), + ), + ) +} + +function deferredFetch(transportTask: Promise<{ url: string; headers?: HeadersInit }>): typeof globalThis.fetch { + const fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const transport = await transportTask + const request = new Request(input, init) + const source = new URL(request.url) + const headers = new Headers(request.headers) + for (const [key, value] of new Headers(transport.headers)) headers.set(key, value) + return globalThis.fetch(new Request(new URL(source.pathname + source.search, transport.url), request), { headers }) + } + return fetch as typeof globalThis.fetch +} + +async function remoteDirectory( + transport: { url: string; headers?: HeadersInit }, + sdk: OpenCodeClient, +): Promise { + const location = await sdk.location.get() + if (!location.directory) throw new Error(`Failed to resolve remote directory from ${transport.url}`) + return location.directory +} + +function parseModel(value?: string): RunInput["model"] { + if (!value) return + const [providerID, ...rest] = value.split("/") + const modelID = rest.join("/") + if (!providerID || !modelID) fail("--model must use the format provider/model") + return { providerID, modelID } +} + +async function validateAgent(sdk: OpenCodeClient, directory: string, name?: string, attach?: string) { + if (!name) return + const deadline = Date.now() + 5_000 + let agents: Awaited> | undefined + while (Date.now() < deadline) { + agents = await sdk.agent.list({ location: { directory } }).catch(() => undefined) + const agent = agents?.data.find((item) => item.id === name) + if (agent?.mode === "subagent") { + warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`) + return + } + if (agent) return name + await Bun.sleep(25) + } + if (!agents) { + warning(`failed to list agents${attach ? ` from ${attach}` : ""}. Falling back to default agent`) + return + } + warning(`agent "${name}" not found. Falling back to default agent`) +} + +async function selectSession(sdk: OpenCodeClient, directory: string, input: MiniCommandInput, preselected?: Session) { + const selected = + preselected ?? + (input.session + ? await sdk.session.get({ sessionID: input.session }).catch(() => undefined) + : input.continue + ? await sdk.session + .list({ directory, parentID: null, limit: 1, order: "desc" }) + .then((result) => result.data[0]) + : undefined) + if (input.session && !selected) fail("Session not found") + if (!selected) return + if (!input.fork) return selected + return sdk.session.fork({ sessionID: selected.id }) +} + +async function createSession( + sdk: OpenCodeClient, + directory: string, + agent: string | undefined, + model: RunInput["model"], + variant?: string, +): Promise { + if (model) await waitForCatalogReady({ sdk, directory, model }) + return sdk.session.create({ + agent, + model: model ? { providerID: model.providerID, id: model.modelID, variant } : undefined, + location: { directory }, + }) +} + +function warning(message: string) { + process.stderr.write(`\x1b[93m\x1b[1m!\x1b[0m ${message}\n`) +} + +function fail(message: string): never { + process.stderr.write(`\x1b[91m\x1b[1mError: \x1b[0m${message}\n`) + process.exit(1) +} diff --git a/packages/opencode/src/cli/cmd/run/noninteractive.ts b/packages/cli/src/mini/noninteractive.ts similarity index 71% rename from packages/opencode/src/cli/cmd/run/noninteractive.ts rename to packages/cli/src/mini/noninteractive.ts index 440436c704..97d2fd91b6 100644 --- a/packages/opencode/src/cli/cmd/run/noninteractive.ts +++ b/packages/cli/src/mini/noninteractive.ts @@ -1,15 +1,17 @@ import type { - OpencodeClient, + EventSubscribeOutput, + OpenCodeClient, +} from "@opencode-ai/client/promise" +import type { ReasoningPart, StepFinishPart, StepStartPart, TextPart, ToolPart, - V2Event, } from "@opencode-ai/sdk/v2" +import { SessionMessage } from "@opencode-ai/schema/session-message" import { EOL } from "node:os" -import { MessageID } from "@/session/schema" -import { UI } from "../../ui" +import { UI } from "./ui" type Model = { providerID: string @@ -23,7 +25,7 @@ type File = { } type Input = { - client: OpencodeClient + client: OpenCodeClient sessionID: string message: string files: File[] @@ -33,6 +35,8 @@ type Input = { thinking: boolean format: "default" | "json" dangerouslySkipPermissions: boolean + /** True when the client is attached to a shared server rather than an exclusive in-process one. */ + attached: boolean renderTool: (part: ToolPart) => Promise renderToolError: (part: ToolPart) => Promise } @@ -50,18 +54,21 @@ type ToolState = StartedPart & { provider?: unknown } +type V2Event = EventSubscribeOutput +type FormRequest = Extract["data"]["form"] + +// MCP elicitations are temporarily owned by the "global" sentinel instead of a real +// session. An exclusive local process may treat them as this run's blockers; an +// attached client must not cancel input that may belong to another session. +const GLOBAL_FORM_SESSION_ID = "global" + export async function runNonInteractivePrompt(input: Input) { const controller = new AbortController() - const events = await input.client.v2.event.subscribe({ - signal: controller.signal, - sseMaxRetryAttempts: 0, - throwOnError: true, - }) - const stream = events.stream[Symbol.asyncIterator]() as AsyncGenerator + const stream = input.client.event.subscribe({ signal: controller.signal })[Symbol.asyncIterator]() const connected = await stream.next() if (connected.done) throw new Error("Event stream disconnected before prompt admission") - const messageID = MessageID.ascending() + const messageID = SessionMessage.ID.create() const starts = new Map() const tools = new Map() let submitted = false @@ -69,6 +76,7 @@ export async function runNonInteractivePrompt(input: Input) { let emittedError = false let questionRejected = false let permissionRejected = false + let formCancelled = false let interrupted = false let admission: AbortController | undefined @@ -91,7 +99,7 @@ export async function runNonInteractivePrompt(input: Input) { UI.empty() } - const replyPermission = async (request: { id: string; action: string; resources: string[] }) => { + const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray }) => { if (!input.dangerouslySkipPermissions) { permissionRejected = true UI.println( @@ -100,7 +108,7 @@ export async function runNonInteractivePrompt(input: Input) { `permission requested: ${request.action} (${request.resources.join(", ")}); auto-rejecting`, ) } - await input.client.v2.session.permission + await input.client.permission .reply({ sessionID: input.sessionID, requestID: request.id, @@ -108,19 +116,30 @@ export async function runNonInteractivePrompt(input: Input) { }) .catch(() => {}) if (!input.dangerouslySkipPermissions) { - await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) + await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) } } const rejectQuestion = async (request: { id: string }) => { questionRejected = true - await input.client.v2.session.question.reject({ sessionID: input.sessionID, requestID: request.id }).catch(() => {}) + await input.client.question.reject({ sessionID: input.sessionID, requestID: request.id }).catch(() => {}) + } + + const cancelForm = async (request: Pick) => { + formCancelled = true + await input.client.form.cancel({ sessionID: request.sessionID, formID: request.id }).catch(() => {}) } const consume = async () => { while (!controller.signal.aborted) { - const next = await stream.next() - if (next.done) throw new Error("Event stream disconnected during prompt execution") + const next = await stream.next().catch((error) => { + if (!emittedError) throw error + return { done: true as const, value: undefined } + }) + if (next.done) { + if (emittedError) return + throw new Error("Event stream disconnected during prompt execution") + } const event = next.value if (event.type === "permission.v2.asked" && submitted && event.data.sessionID === input.sessionID) { @@ -131,26 +150,34 @@ export async function runNonInteractivePrompt(input: Input) { await rejectQuestion(event.data) continue } + if ( + event.type === "form.created" && + submitted && + (event.data.form.sessionID === input.sessionID || + (!input.attached && event.data.form.sessionID === GLOBAL_FORM_SESSION_ID)) + ) { + await cancelForm(event.data.form) + continue + } if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue - const time = "timestamp" in event.data ? toMillis(event.data.timestamp) : Date.now() + const time = toMillis("created" in event ? event.created : undefined) - if (event.type === "session.next.prompted") { - if (event.data.messageID === messageID) { + if (event.type === "session.prompt.promoted") { + if (event.data.inputID === messageID) { promoted = true continue } - if (promoted && event.data.delivery === "queue") return } if ( - event.type === "session.next.execution.settled" && + event.type === "session.execution.settled" && event.data.outcome === "interrupted" && - (interrupted || permissionRejected || questionRejected) + (interrupted || permissionRejected || questionRejected || formCancelled) ) { return } if (!promoted) continue - if (event.type === "session.next.step.started") { + if (event.type === "session.step.started") { const part: StepStartPart = { id: partID(event.id), sessionID: input.sessionID, @@ -166,11 +193,11 @@ export async function runNonInteractivePrompt(input: Input) { continue } - if (event.type === "session.next.text.started") { + if (event.type === "session.text.started") { starts.set(event.data.textID, { id: partID(event.id), timestamp: time }) continue } - if (event.type === "session.next.text.ended") { + if (event.type === "session.text.ended") { const started = starts.get(event.data.textID) const part: TextPart = { id: started?.id ?? partID(event.id), @@ -184,11 +211,11 @@ export async function runNonInteractivePrompt(input: Input) { continue } - if (event.type === "session.next.reasoning.started") { + if (event.type === "session.reasoning.started") { starts.set(event.data.reasoningID, { id: partID(event.id), timestamp: time }) continue } - if (event.type === "session.next.reasoning.ended" && input.thinking) { + if (event.type === "session.reasoning.ended" && input.thinking) { const started = starts.get(event.data.reasoningID) const part: ReasoningPart = { id: started?.id ?? partID(event.id), @@ -213,7 +240,7 @@ export async function runNonInteractivePrompt(input: Input) { continue } - if (event.type === "session.next.tool.input.started") { + if (event.type === "session.tool.input.started") { tools.set(event.data.callID, { id: partID(event.id), timestamp: time, @@ -223,12 +250,12 @@ export async function runNonInteractivePrompt(input: Input) { }) continue } - if (event.type === "session.next.tool.input.ended") { + if (event.type === "session.tool.input.ended") { const current = tools.get(event.data.callID) if (current) current.raw = event.data.text continue } - if (event.type === "session.next.tool.called") { + if (event.type === "session.tool.called") { const current = tools.get(event.data.callID) tools.set(event.data.callID, { id: current?.id ?? partID(event.id), @@ -241,7 +268,7 @@ export async function runNonInteractivePrompt(input: Input) { }) continue } - if (event.type === "session.next.tool.success") { + if (event.type === "session.tool.success") { const current = tools.get(event.data.callID) ?? fallbackTool(event) const part: ToolPart = { id: current.id, @@ -274,7 +301,7 @@ export async function runNonInteractivePrompt(input: Input) { if (!emit("tool_use", time, { part })) await input.renderTool(part) continue } - if (event.type === "session.next.tool.failed") { + if (event.type === "session.tool.failed") { const current = tools.get(event.data.callID) ?? fallbackTool(event) const error = event.data.error.message const part: ToolPart = { @@ -305,7 +332,7 @@ export async function runNonInteractivePrompt(input: Input) { continue } - if (event.type === "session.next.step.ended") { + if (event.type === "session.step.ended") { const part: StepFinishPart = { id: partID(event.id), sessionID: input.sessionID, @@ -319,19 +346,19 @@ export async function runNonInteractivePrompt(input: Input) { emit("step_finish", time, { part }) continue } - if (event.type === "session.next.step.failed") { - if (interrupted || permissionRejected || questionRejected) continue + if (event.type === "session.step.failed") { + if (interrupted || permissionRejected || questionRejected || formCancelled) continue emittedError = true process.exitCode = 1 if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message) continue } - if (event.type === "session.next.execution.settled") { - if (event.data.outcome === "failure" && !emittedError && !questionRejected) { + if (event.type === "session.execution.settled") { + if (event.data.outcome === "failure" && !emittedError && !questionRejected && !formCancelled) { emittedError = true process.exitCode = 1 const error = event.data.error ?? { type: "unknown", message: "Session execution failed" } - if (!emit("error", toMillis(event.data.timestamp), { error })) UI.error(error.message) + if (!emit("error", time, { error })) UI.error(error.message) } if (event.data.outcome === "interrupted" && interrupted) process.exitCode = 130 return @@ -344,34 +371,31 @@ export async function runNonInteractivePrompt(input: Input) { interrupted = true process.exitCode = 130 admission?.abort() - void input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) + void input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) } process.on("SIGINT", interrupt) let completed: Promise | undefined try { if (input.agent) { - await input.client.v2.session.switchAgent( - { sessionID: input.sessionID, agent: input.agent }, - { throwOnError: true }, - ) + await input.client.session.switchAgent({ sessionID: input.sessionID, agent: input.agent }) } const selected = input.model ? { providerID: input.model.providerID, id: input.model.modelID, variant: input.variant } : input.variant - ? await input.client.v2.session - .get({ sessionID: input.sessionID }, { throwOnError: true }) - .then((result) => result.data.data.model) + ? await input.client.session + .get({ sessionID: input.sessionID }) + .then((result) => result.model) .then(async (model) => { if (model) return { ...model, variant: input.variant } - const result = await input.client.v2.model.default(undefined, { throwOnError: true }) - const fallback = result.data.data + const result = await input.client.model.default() + const fallback = result.data return fallback ? { providerID: fallback.providerID, id: fallback.id, variant: input.variant } : undefined }) : undefined if (input.variant && !selected) throw new Error("Cannot select a variant before selecting a model") if (selected) { - await input.client.v2.session.switchModel({ sessionID: input.sessionID, model: selected }, { throwOnError: true }) + await input.client.session.switchModel({ sessionID: input.sessionID, model: selected }) } const prepared = await Promise.all(input.files.map(prepareFile)) @@ -379,7 +403,7 @@ export async function runNonInteractivePrompt(input: Input) { submitted = true completed = consume() admission = new AbortController() - const response = await input.client.v2.session + const response = await input.client.session .prompt( { sessionID: input.sessionID, @@ -390,29 +414,34 @@ export async function runNonInteractivePrompt(input: Input) { }, delivery: "steer", }, - { throwOnError: true, signal: admission.signal }, + { signal: admission.signal }, ) .catch(async (error) => { if (interrupted) { - await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) + await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) } controller.abort() await completed?.catch(() => {}) - if (interrupted) return undefined + if (interrupted || emittedError) return undefined throw error }) admission = undefined if (!response) return - if (!response.data.data) throw new Error("Prompt was not admitted") - if (interrupted) await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) + if (interrupted) await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) - const [permissions, questions] = await Promise.all([ - input.client.v2.session.permission.list({ sessionID: input.sessionID }).catch(() => undefined), - input.client.v2.session.question.list({ sessionID: input.sessionID }).catch(() => undefined), + const [permissions, questions, forms] = await Promise.all([ + input.client.permission.list({ sessionID: input.sessionID }).catch(() => undefined), + input.client.question.list({ sessionID: input.sessionID }).catch(() => undefined), + Promise.all( + (input.attached ? [input.sessionID] : [input.sessionID, GLOBAL_FORM_SESSION_ID]).map((sessionID) => + input.client.form.list({ sessionID }).catch(() => undefined), + ), + ), ]) await Promise.all([ - ...(permissions?.data?.data ?? []).map(replyPermission), - ...(questions?.data?.data ?? []).map(rejectQuestion), + ...(permissions ?? []).map(replyPermission), + ...(questions ?? []).map(rejectQuestion), + ...forms.flatMap((response) => response ?? []).map(cancelForm), ]) await completed } finally { @@ -428,11 +457,12 @@ function partID(eventID: string) { function fallbackTool(event: { id: string - data: { timestamp: number; assistantMessageID: string; callID: string } + created: number + data: { assistantMessageID: string; callID: string } }): ToolState { return { id: partID(event.id), - timestamp: toMillis(event.data.timestamp), + timestamp: toMillis(event.created), assistantMessageID: event.data.assistantMessageID, tool: "tool", input: {}, diff --git a/packages/opencode/src/cli/cmd/run/permission.shared.ts b/packages/cli/src/mini/permission.shared.ts similarity index 100% rename from packages/opencode/src/cli/cmd/run/permission.shared.ts rename to packages/cli/src/mini/permission.shared.ts diff --git a/packages/opencode/src/cli/cmd/run/prompt.editor.ts b/packages/cli/src/mini/prompt.editor.ts similarity index 100% rename from packages/opencode/src/cli/cmd/run/prompt.editor.ts rename to packages/cli/src/mini/prompt.editor.ts diff --git a/packages/opencode/src/cli/cmd/run/prompt.shared.ts b/packages/cli/src/mini/prompt.shared.ts similarity index 99% rename from packages/opencode/src/cli/cmd/run/prompt.shared.ts rename to packages/cli/src/mini/prompt.shared.ts index 63c33aa34f..56d1cb2968 100644 --- a/packages/opencode/src/cli/cmd/run/prompt.shared.ts +++ b/packages/cli/src/mini/prompt.shared.ts @@ -7,7 +7,7 @@ // the current browse position. When the user arrows up at cursor offset 0, // the current draft is saved and history begins. Arrowing past the end // restores the draft. -export { displayCharAt, displaySlice, mentionTriggerIndex } from "../prompt-display" +export { displayCharAt, displaySlice, mentionTriggerIndex } from "@opencode-ai/tui/prompt/display" import type { RunPrompt } from "./types" const HISTORY_LIMIT = 200 diff --git a/packages/opencode/src/cli/cmd/run/question.shared.ts b/packages/cli/src/mini/question.shared.ts similarity index 100% rename from packages/opencode/src/cli/cmd/run/question.shared.ts rename to packages/cli/src/mini/question.shared.ts diff --git a/packages/cli/src/mini/run.ts b/packages/cli/src/mini/run.ts new file mode 100644 index 0000000000..d8e3dcd228 --- /dev/null +++ b/packages/cli/src/mini/run.ts @@ -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 +} + +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 { + 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) +} diff --git a/packages/opencode/src/cli/cmd/run/runtime.boot.ts b/packages/cli/src/mini/runtime.boot.ts similarity index 74% rename from packages/opencode/src/cli/cmd/run/runtime.boot.ts rename to packages/cli/src/mini/runtime.boot.ts index 4753adaae2..b5fca98dc0 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.boot.ts +++ b/packages/cli/src/mini/runtime.boot.ts @@ -7,12 +7,10 @@ // none block each other. import { Context, Effect, Layer } from "effect" import { resolve } from "@opencode-ai/tui/config" -import { TuiConfig } from "@/config/tui" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" -import { makeRuntime } from "@/effect/run-service" +import { makeRuntime } from "@opencode-ai/core/effect/runtime" import { loadRunProviders } from "./catalog.shared" -import { reusePendingTask } from "./runtime.shared" import { resolveCurrentSession, sessionHistory } from "./session.shared" import type { RunDiffStyle, RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types" import { pickVariant } from "./variant.shared" @@ -26,10 +24,10 @@ export type ModelInfo = { export type SessionInfo = { first: boolean history: RunPrompt[] + model?: NonNullable variant: string | undefined } -type Config = Awaited> type BootService = { readonly resolveModelInfo: ( sdk: RunInput["sdk"], @@ -41,18 +39,10 @@ type BootService = { sessionID: string, model: RunInput["model"], ) => Effect.Effect - readonly resolveRunTuiConfig: () => Effect.Effect - readonly resolveDiffStyle: () => Effect.Effect } -const configTask: { current?: Promise } = {} - class Service extends Context.Service()("@opencode/RunBoot") {} -function loadConfig() { - return reusePendingTask(configTask, () => TuiConfig.get()) -} - function emptyModelInfo(): ModelInfo { return { providers: [], @@ -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 { - return runtime.runPromise((svc) => svc.resolveRunTuiConfig()).catch(() => defaultRunTuiConfig()) +export async function resolveRunTuiConfig( + config?: RunTuiConfig | Promise, +): Promise { + return Promise.resolve(config).then((value) => value ?? defaultRunTuiConfig()).catch(() => defaultRunTuiConfig()) } -export async function resolveDiffStyle(): Promise { - return runtime.runPromise((svc) => svc.resolveDiffStyle()).catch(() => "auto") +export async function resolveDiffStyle(config?: RunTuiConfig | Promise): Promise { + return resolveRunTuiConfig(config).then((value) => value.diff_style ?? "auto") } diff --git a/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts b/packages/cli/src/mini/runtime.lifecycle.ts similarity index 95% rename from packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts rename to packages/cli/src/mini/runtime.lifecycle.ts index a25b293883..4700f7c479 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts +++ b/packages/cli/src/mini/runtime.lifecycle.ts @@ -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 backgroundSubagents: boolean onPermissionReply: (input: PermissionReply) => void | Promise onQuestionReply: (input: QuestionReply) => void | Promise @@ -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): 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 { const source = resolveInteractiveStdin() + const footerTask = import("./footer") let unregisterKeymap: (() => void) | undefined try { @@ -194,10 +186,10 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise {}) const ignore = () => {} detachSigint() diff --git a/packages/opencode/src/cli/cmd/run/runtime.queue.ts b/packages/cli/src/mini/runtime.queue.ts similarity index 96% rename from packages/opencode/src/cli/cmd/run/runtime.queue.ts rename to packages/cli/src/mini/runtime.queue.ts index d29e03672a..5a2bdb98db 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.queue.ts +++ b/packages/cli/src/mini/runtime.queue.ts @@ -8,8 +8,9 @@ // and tracks per-turn wall-clock duration for the footer status line. // // Resolves when the footer closes and all in-flight work finishes. -import * as Locale from "@/util/locale" -import { MessageID, PartID } from "@/session/schema" +import { ascending } from "@opencode-ai/schema/identifier" +import { SessionMessage } from "@opencode-ai/schema/session-message" +import { Locale } from "@opencode-ai/tui/util/locale" import { isExitCommand, isNewCommand } from "./prompt.shared" import type { FooterApi, FooterEvent, FooterQueuedPrompt, RunPrompt } from "./types" @@ -167,7 +168,7 @@ export async function runPromptQueue(input: QueueInput): Promise { ? prompt : { ...prompt, - messageID: prompt.messageID ?? queued?.messageID ?? MessageID.ascending(), + messageID: prompt.messageID ?? queued?.messageID ?? SessionMessage.ID.create(), } state.active = sent @@ -285,8 +286,8 @@ export async function runPromptQueue(input: QueueInput): Promise { !isNewCommand(prompt.text) ) { const queued: FooterQueuedPrompt = { - messageID: MessageID.ascending(), - partID: PartID.ascending(), + messageID: SessionMessage.ID.create(), + partID: "prt_" + ascending(), prompt, } state.queued = [...state.queued, queued] diff --git a/packages/opencode/src/cli/cmd/run/runtime.shared.ts b/packages/cli/src/mini/runtime.shared.ts similarity index 100% rename from packages/opencode/src/cli/cmd/run/runtime.shared.ts rename to packages/cli/src/mini/runtime.shared.ts diff --git a/packages/opencode/src/cli/cmd/run/runtime.stdin.ts b/packages/cli/src/mini/runtime.stdin.ts similarity index 100% rename from packages/opencode/src/cli/cmd/run/runtime.stdin.ts rename to packages/cli/src/mini/runtime.stdin.ts diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/cli/src/mini/runtime.ts similarity index 66% rename from packages/opencode/src/cli/cmd/run/runtime.ts rename to packages/cli/src/mini/runtime.ts index 8f3704fd41..8f575a87fe 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/cli/src/mini/runtime.ts @@ -4,7 +4,7 @@ // and prompt queue together into a single session loop. Two entry points: // // runInteractiveMode -- used when an SDK client already exists (attach mode) -// runInteractiveLocalMode -- used for local in-process mode (no server) +// runInteractiveDeferredMode -- paints before resolving its session // // Both delegate to runInteractiveRuntime, which: // 1. resolves TUI config, model info, and session history, @@ -12,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 afterPaint?: (ctx: BootContext) => Promise | void - resolveSession?: ( - ctx: BootContext, - ) => Promise<{ sessionID: string; sessionTitle?: string; agent?: string | undefined }> + resolveSession?: (ctx: BootContext) => Promise createSession?: (ctx: BootContext, input: CreateSessionInput) => Promise files: RunInput["files"] initialInput?: string @@ -56,13 +60,14 @@ type RunRuntimeInput = { replay?: boolean replayLimit?: number demo?: RunInput["demo"] + tuiConfig?: RunTuiConfig | Promise } -type RunLocalInput = { +type RunDeferredInput = { + sdk: RunInput["sdk"] directory: string - fetch: typeof globalThis.fetch resolveAgent: () => Promise - session: (sdk: RunInput["sdk"]) => Promise<{ id: string; title?: string } | undefined> + session: (sdk: RunInput["sdk"]) => Promise<{ id: string; title?: string; resume?: boolean } | undefined> createSession?: CreateSession agent: RunInput["agent"] model: RunInput["model"] @@ -74,6 +79,7 @@ type RunLocalInput = { replay?: boolean replayLimit?: number demo?: RunInput["demo"] + tuiConfig?: RunTuiConfig | Promise } type StreamTransportModule = Pick< @@ -91,10 +97,13 @@ type StreamState = { handle: Awaited> } +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 - demo?: ReturnType + demo?: RunDemo selectSubagent?: (sessionID: string | undefined) => void session?: Promise stream?: Promise @@ -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 { 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 => { + const applyCatalog = (catalog: { + agents: Awaited> + references: Awaited> + commands: Awaited> + }) => { 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>, + 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 | 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 { - const sdk = createOpencodeClient({ - baseUrl: "http://opencode.internal", - fetch: input.fetch, - directory: input.directory, - }) +// Deferred mode paints before session resolution. The caller may back the +// generated client with a transport that is still acquiring a daemon. +export async function runInteractiveDeferredMode(input: RunDeferredInput, deps?: RunRuntimeDeps): Promise { + const sdk = input.sdk let session: Promise | undefined - return runInteractiveRuntime({ - files: input.files, - initialInput: input.initialInput, - thinking: input.thinking, - backgroundSubagents: input.backgroundSubagents, - replay: input.replay, - replayLimit: input.replayLimit, - demo: input.demo, - resolveSession: () => { - if (session) { + return runInteractiveRuntime( + { + files: input.files, + initialInput: input.initialInput, + thinking: input.thinking, + backgroundSubagents: input.backgroundSubagents, + replay: input.replay, + replayLimit: input.replayLimit, + demo: input.demo, + tuiConfig: input.tuiConfig, + resolveSession: () => { + if (session) { + return session + } + + session = Promise.all([input.resolveAgent(), input.session(sdk)]).then(([agent, next]) => { + if (!next?.id) { + throw new Error("Session not found") + } + + return { + sessionID: next.id, + sessionTitle: next.title, + agent, + resume: next.resume, + } + }) return session - } - - session = Promise.all([input.resolveAgent(), input.session(sdk)]).then(([agent, next]) => { - if (!next?.id) { - throw new Error("Session not found") - } - + }, + createSession: createSessionResolver(input.createSession), + boot: async () => { return { - sessionID: next.id, - sessionTitle: next.title, - agent, + sdk, + directory: input.directory, + sessionID: "", + sessionTitle: undefined, + resume: false, + agent: input.agent, + model: input.model, + variant: input.variant, } - }) - return session + }, }, - createSession: createSessionResolver(input.createSession), - boot: async () => { - return { - sdk, - directory: input.directory, - sessionID: "", - sessionTitle: undefined, - resume: false, - agent: input.agent, - model: input.model, - variant: input.variant, - } - }, - }) + deps, + ) } // Attach mode. Uses the caller-provided SDK client directly. export async function runInteractiveMode( - input: RunInput & { createSession?: CreateSession }, + input: RunInput & { createSession?: CreateSession; tuiConfig?: RunTuiConfig | Promise }, deps?: RunRuntimeDeps, ): Promise { return runInteractiveRuntime( @@ -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, diff --git a/packages/opencode/src/cli/cmd/run/scrollback.shared.ts b/packages/cli/src/mini/scrollback.shared.ts similarity index 100% rename from packages/opencode/src/cli/cmd/run/scrollback.shared.ts rename to packages/cli/src/mini/scrollback.shared.ts diff --git a/packages/opencode/src/cli/cmd/run/scrollback.surface.ts b/packages/cli/src/mini/scrollback.surface.ts similarity index 98% rename from packages/opencode/src/cli/cmd/run/scrollback.surface.ts rename to packages/cli/src/mini/scrollback.surface.ts index f8516a054c..c5e5c6dd7d 100644 --- a/packages/opencode/src/cli/cmd/run/scrollback.surface.ts +++ b/packages/cli/src/mini/scrollback.surface.ts @@ -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) diff --git a/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx b/packages/cli/src/mini/scrollback.writer.tsx similarity index 100% rename from packages/opencode/src/cli/cmd/run/scrollback.writer.tsx rename to packages/cli/src/mini/scrollback.writer.tsx diff --git a/packages/opencode/src/cli/cmd/run/session-data.ts b/packages/cli/src/mini/session-data.ts similarity index 96% rename from packages/opencode/src/cli/cmd/run/session-data.ts rename to packages/cli/src/mini/session-data.ts index 05daa8b423..263b8e86d4 100644 --- a/packages/opencode/src/cli/cmd/run/session-data.ts +++ b/packages/cli/src/mini/session-data.ts @@ -25,7 +25,7 @@ // event arrives, the queue entry is removed and the footer falls back // to the next pending request or to the prompt view. import type { Event, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2" -import * as Locale from "@/util/locale" +import { Locale } from "@opencode-ai/tui/util/locale" import { toolView } from "./tool" import type { FooterOutput, FooterPatch, FooterView, StreamCommit } from "./types" @@ -62,7 +62,7 @@ type SessionCommit = StreamCommit // - sent: part ID → byte offset of last flushed text (for incremental output) // - visible: part ID → rendered text for an active part after display transforms // - end: part IDs whose time.end has arrived (part is finished) -// - shell: shell call ID → chosen transcript source for direct shell calls +// - shell: shell ID → chosen transcript source for direct shell calls // - echo: message ID → bash outputs to strip from the next assistant chunk type ShellCall = { source: "shell" | "tool" @@ -607,12 +607,12 @@ function toolCommit( } } -function shellPartID(callID: string): string { - return `shell:${callID}` +function shellPartID(shellID: string): string { + return `shell:${shellID}` } -function claimShell(data: SessionData, callID: string, source: ShellCall["source"], command?: string): ShellCall { - const current = data.shell.get(callID) +function claimShell(data: SessionData, shellID: string, source: ShellCall["source"], command?: string): ShellCall { + const current = data.shell.get(shellID) if (current) { if (command && !current.command) { current.command = command @@ -625,7 +625,7 @@ function claimShell(data: SessionData, callID: string, source: ShellCall["source source, ...(command ? { command } : {}), } satisfies ShellCall - data.shell.set(callID, next) + data.shell.set(shellID, next) return next } @@ -728,37 +728,37 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput { const data = input.data const event = input.event - if (event.type === "session.next.shell.started") { + if (event.type === "session.shell.started") { if (event.properties.sessionID !== input.sessionID) { return out(data, commits) } - const shell = claimShell(data, event.properties.callID, "shell", event.properties.command) + const shell = claimShell(data, event.properties.shell.id, "shell", event.properties.shell.command) if (shell.source !== "shell") { return out(data, commits) } - const partID = shellPartID(event.properties.callID) + const partID = shellPartID(event.properties.shell.id) if (data.ids.has(partID) || data.tools.has(partID)) { return out(data, commits, patch({ status: "running shell" })) } data.tools.add(partID) - commits.push(startShell(event.properties.callID, shell.command ?? event.properties.command)) + commits.push(startShell(event.properties.shell.id, shell.command ?? event.properties.shell.command)) return out(data, commits, patch({ status: "running shell" })) } - if (event.type === "session.next.shell.ended") { + if (event.type === "session.shell.ended") { if (event.properties.sessionID !== input.sessionID) { return out(data, commits) } - const shell = claimShell(data, event.properties.callID, "shell") + const shell = claimShell(data, event.properties.shell.id, "shell") if (shell.source !== "shell") { return out(data, commits) } - const partID = shellPartID(event.properties.callID) + const partID = shellPartID(event.properties.shell.id) const seen = data.tools.has(partID) const command = shell.command ?? "" data.tools.delete(partID) @@ -767,11 +767,11 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput { } if (!seen && command) { - commits.push(startShell(event.properties.callID, command)) + commits.push(startShell(event.properties.shell.id, command)) } data.ids.add(partID) - commits.push(doneShell(event.properties.callID, command, event.properties.output)) + commits.push(doneShell(event.properties.shell.id, command, event.properties.output.output)) return out(data, commits) } diff --git a/packages/opencode/src/cli/cmd/run/session.shared.ts b/packages/cli/src/mini/session.shared.ts similarity index 87% rename from packages/opencode/src/cli/cmd/run/session.shared.ts rename to packages/cli/src/mini/session.shared.ts index 49dece5a89..e68df9e6c2 100644 --- a/packages/opencode/src/cli/cmd/run/session.shared.ts +++ b/packages/cli/src/mini/session.shared.ts @@ -4,11 +4,12 @@ // the prompt history ring. Also finds the most recently used variant for // the current model so the footer can pre-select it. import { promptCopy, promptSame } from "./prompt.shared" +import type { Message, Part } from "@opencode-ai/sdk/v2" import type { RunInput, RunPrompt } from "./types" const LIMIT = 200 -export type SessionMessages = NonNullable>["data"]> +export type SessionMessages = Array<{ info: Message; parts: Part[] }> type Turn = { prompt: RunPrompt @@ -20,6 +21,8 @@ type Turn = { export type RunSession = { first: boolean turns: Turn[] + model?: NonNullable + variant?: string } function fileName(url: string, filename?: string) { @@ -157,9 +160,11 @@ export async function resolveCurrentSession( sessionID: string, limit = LIMIT, ): Promise { - 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) { diff --git a/packages/opencode/src/cli/cmd/run/splash.ts b/packages/cli/src/mini/splash.ts similarity index 98% rename from packages/opencode/src/cli/cmd/run/splash.ts rename to packages/cli/src/mini/splash.ts index 9e9321cafd..e39adba376 100644 --- a/packages/opencode/src/cli/cmd/run/splash.ts +++ b/packages/cli/src/mini/splash.ts @@ -17,8 +17,8 @@ import { type ScrollbackSnapshot, type ScrollbackWriter, } from "@opentui/core" -import * as Locale from "@/util/locale" -import { go } from "@/cli/logo" +import { Locale } from "@opencode-ai/tui/util/locale" +import { go } from "@opencode-ai/tui/logo" import type { RunSplashTheme } from "./theme" export const SPLASH_TITLE_LIMIT = 50 diff --git a/packages/opencode/src/cli/cmd/run/stream-v2.subagent.ts b/packages/cli/src/mini/stream-v2.subagent.ts similarity index 83% rename from packages/opencode/src/cli/cmd/run/stream-v2.subagent.ts rename to packages/cli/src/mini/stream-v2.subagent.ts index 1988d18d89..ad34f6ecff 100644 --- a/packages/opencode/src/cli/cmd/run/stream-v2.subagent.ts +++ b/packages/cli/src/mini/stream-v2.subagent.ts @@ -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 finishedTools: Set messageIDs: Set + prompts: Map 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() + const hydrationEvents = new Map() + const hydrationOverflow = new Set() const hydrations = new Map>() 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 => { const existing = hydrations.get(child.sessionID) if (existing) return existing - const task = input.sdk.v2.session - .messages({ sessionID: child.sessionID, limit: CHILD_MESSAGE_LIMIT, order: "desc" }, { throwOnError: true }) + const pendingPrompts = new Map(child.prompts) + const pendingTools = new Map(child.tools) + let retry = false + const task = input.sdk.message + .list({ sessionID: child.sessionID, limit: CHILD_MESSAGE_LIMIT, order: "desc" }) .then((response) => { - rebuild(child, response.data.data.toReversed()) + const buffered = hydrationEvents.get(child.sessionID) ?? [] + hydrationEvents.delete(child.sessionID) + if (hydrationOverflow.delete(child.sessionID)) { + child.hydrated = false + retry = true + notifyDetail(child) + return + } + for (const [id, prompt] of pendingPrompts) { + if (!child.prompts.has(id)) child.prompts.set(id, prompt) + } + rebuild(child, structuredClone(response.data).toReversed() as SessionMessage[]) + for (const [id, tool] of pendingTools) { + if (!child.finishedTools.has(id) && !child.tools.has(id)) child.tools.set(id, tool) + } + for (const event of buffered) reduce(child, event) child.hydrated = true notifyDetail(child) }) - .catch(() => {}) + .catch(() => { + hydrationEvents.delete(child.sessionID) + hydrationOverflow.delete(child.sessionID) + }) .finally(() => { hydrations.delete(child.sessionID) + if (retry) queueMicrotask(() => void hydrateChild(child)) }) hydrations.set(child.sessionID, task) return task @@ -401,10 +425,9 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac if (checked.has(sessionID) || children.has(sessionID) || sessionID === input.sessionID) return checked.add(sessionID) if (!pendingEvents.has(sessionID)) pendingEvents.set(sessionID, []) - void input.sdk.v2.session - .get({ sessionID }, { throwOnError: true }) - .then((response) => { - const session = response.data.data + void input.sdk.session + .get({ sessionID }) + .then((session) => { const buffered = pendingEvents.get(sessionID) ?? [] pendingEvents.delete(sessionID) if (session.parentID !== input.sessionID) return @@ -424,21 +447,27 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac } const reduce = (child: ChildState, event: V2Event) => { - if (event.type === "session.next.prompted") { - if (userFrame(child, event.data.messageID, event.data.prompt.text)) { - touch(child, event.data.timestamp) + if (event.type === "session.prompt.admitted") { + child.prompts.set(event.data.inputID, event.data.prompt.text) + return + } + if (event.type === "session.prompt.promoted") { + const prompt = child.prompts.get(event.data.inputID) ?? "" + child.prompts.delete(event.data.inputID) + if (userFrame(child, event.data.inputID, prompt)) { + touch(child, event.created) notifyDetail(child) } return } - if (event.type === "session.next.step.started") { - touch(child, event.data.timestamp) + if (event.type === "session.step.started") { + touch(child, event.created) if (child.label === FALLBACK_LABEL && event.data.agent) child.label = Locale.titlecase(event.data.agent) if (child.status !== "running") child.status = "running" input.emit() return } - if (event.type === "session.next.text.delta") { + if (event.type === "session.text.delta") { const projected = child.projectedText.get(event.data.textID) const covered = projected?.indexOf(event.data.delta) ?? -1 if (projected && covered >= 0) { @@ -455,11 +484,11 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac messageID: event.data.assistantMessageID, partID: event.data.textID, }) - touch(child, event.data.timestamp) + touch(child, event.created) notifyDetail(child) return } - if (event.type === "session.next.text.ended") { + if (event.type === "session.text.ended") { child.text.set(event.data.textID, event.data.text) child.projectedText.delete(event.data.textID) setFrame(child, `text:${event.data.textID}`, { @@ -470,11 +499,11 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac messageID: event.data.assistantMessageID, partID: event.data.textID, }) - touch(child, event.data.timestamp) + touch(child, event.created) notifyDetail(child) return } - if (event.type === "session.next.reasoning.delta") { + if (event.type === "session.reasoning.delta") { const projected = child.projectedReasoning.get(event.data.reasoningID) const covered = projected?.indexOf(event.data.delta) ?? -1 if (projected && covered >= 0) { @@ -495,7 +524,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac notifyDetail(child) return } - if (event.type === "session.next.reasoning.ended") { + if (event.type === "session.reasoning.ended") { child.reasoning.set(event.data.reasoningID, event.data.text) child.projectedReasoning.delete(event.data.reasoningID) if (!input.thinking) return @@ -510,40 +539,42 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac notifyDetail(child) return } - if (event.type === "session.next.tool.input.started") { - child.tools.set(event.data.callID, { name: event.data.name, input: {}, started: event.data.timestamp }) + if (event.type === "session.tool.input.started") { + if (child.finishedTools.has(event.data.callID)) return + child.tools.set(event.data.callID, { name: event.data.name, input: {}, started: event.created }) return } - if (event.type === "session.next.tool.called") { + if (event.type === "session.tool.called") { + if (child.finishedTools.has(event.data.callID)) return const current = child.tools.get(event.data.callID) child.tools.set(event.data.callID, { name: event.data.tool, input: event.data.input, - started: current?.started ?? event.data.timestamp, + started: current?.started ?? event.created, }) childTool( child, - { + structuredClone({ type: "tool", id: event.data.callID, name: event.data.tool, provider: event.data.provider, state: { status: "running", input: event.data.input, structured: {}, content: [] }, - time: { created: current?.started ?? event.data.timestamp, ran: event.data.timestamp }, - }, + time: { created: current?.started ?? event.created, ran: event.created }, + }) as SessionMessageAssistantTool, event.data.assistantMessageID, ) - touch(child, event.data.timestamp) + touch(child, event.created) notifyDetail(child) return } - if (event.type === "session.next.tool.success" || event.type === "session.next.tool.failed") { + if (event.type === "session.tool.success" || event.type === "session.tool.failed") { if (child.finishedTools.has(event.data.callID)) return const current = child.tools.get(event.data.callID) - const failed = event.type === "session.next.tool.failed" + const failed = event.type === "session.tool.failed" childTool( child, - { + structuredClone({ type: "tool", id: event.data.callID, name: current?.name ?? "tool", @@ -566,18 +597,18 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac result: event.data.result, }, time: { - created: current?.started ?? event.data.timestamp, + created: current?.started ?? event.created, ran: current?.started, - completed: event.data.timestamp, + completed: event.created, }, - }, + }) as SessionMessageAssistantTool, event.data.assistantMessageID, ) - touch(child, event.data.timestamp) + touch(child, event.created) notifyDetail(child) return } - if (event.type === "session.next.step.failed") { + if (event.type === "session.step.failed") { setFrame(child, `error:step:${event.data.assistantMessageID}`, { kind: "error", source: "system", @@ -585,14 +616,14 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac phase: "start", messageID: event.data.assistantMessageID, }) - touch(child, event.data.timestamp) + touch(child, event.created) notifyDetail(child) return } - if (event.type === "session.next.execution.settled") { + if (event.type === "session.execution.settled") { child.status = event.data.outcome === "success" ? "completed" : event.data.outcome === "interrupted" ? "cancelled" : "error" - touch(child, event.data.timestamp) + touch(child, event.created) input.emit() } } @@ -613,15 +644,15 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac return { main(event) { - if (event.type === "session.next.tool.called") { + if (event.type === "session.tool.called") { if (event.data.tool === "subagent") pendingCalls.set(event.data.callID, event.data.input) return } - if (event.type === "session.next.tool.failed") { + if (event.type === "session.tool.failed") { pendingCalls.delete(event.data.callID) return } - if (event.type !== "session.next.tool.success") return + if (event.type !== "session.tool.success") return const pending = pendingCalls.get(event.data.callID) pendingCalls.delete(event.data.callID) const found = childSessionID(record(event.data.structured)) @@ -633,19 +664,25 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac child.status = "running" } if (!found.running && child.status === "running") child.status = "completed" - touch(child, event.data.timestamp) + touch(child, event.created) input.emit() if (!child.hydrated) void hydrateChild(child) }, foreign(sessionID, event) { const child = children.get(sessionID) if (child) { + if (hydrations.has(sessionID)) { + const buffered = hydrationEvents.get(sessionID) ?? [] + if (buffered.length < CHILD_EVENT_BUFFER_LIMIT) buffered.push(event) + else hydrationOverflow.add(sessionID) + hydrationEvents.set(sessionID, buffered) + } reduce(child, event) return } discover(sessionID) const buffered = pendingEvents.get(sessionID) - if (buffered && buffered.length < DISCOVERY_BUFFER_LIMIT) buffered.push(event) + if (buffered && buffered.length < CHILD_EVENT_BUFFER_LIMIT) buffered.push(event) }, async hydrate(next) { for (const message of next.messages) { @@ -656,9 +693,9 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac } // Family index: adopt children directly from the current session list so // historical subagents beyond the projected message window still get tabs. - const family = await input.sdk.v2.session - .list({ limit: FAMILY_LIST_LIMIT, order: "desc" }, { throwOnError: true }) - .then((response) => response.data.data.filter((session) => session.parentID === input.sessionID)) + const family = await input.sdk.session + .list({ parentID: input.sessionID, limit: FAMILY_LIST_LIMIT, order: "desc" }) + .then((response) => response.data) .catch(() => []) for (const session of family) { const child = ensureChild(session.id) diff --git a/packages/opencode/src/cli/cmd/run/stream-v2.transport.ts b/packages/cli/src/mini/stream-v2.transport.ts similarity index 57% rename from packages/opencode/src/cli/cmd/run/stream-v2.transport.ts rename to packages/cli/src/mini/stream-v2.transport.ts index 7700ddd024..7cec6f9e87 100644 --- a/packages/opencode/src/cli/cmd/run/stream-v2.transport.ts +++ b/packages/cli/src/mini/stream-v2.transport.ts @@ -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["data"] +type QuestionV2Request = Extract["data"] type PromptFilePart = Extract type ToolState = { @@ -99,6 +114,11 @@ type State = { projectedReasoning: Map tools: Map finishedTools: Set + skillMessages: Set + shellCommands: Map + shellStarted: Set + shellEnded: Set + 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 { + 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) { 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 { @@ -307,6 +436,47 @@ export async function createSessionTransport(input: StreamInput): Promise { const [messages, permissions, questions, active] = await Promise.all([ - input.sdk.v2.session.messages( - { sessionID: input.sessionID, limit: input.replayLimit ?? 200, order: "desc" }, - { throwOnError: true }, - ), - input.sdk.v2.session.permission.list({ sessionID: input.sessionID }, { throwOnError: true }), - input.sdk.v2.session.question.list({ sessionID: input.sessionID }, { throwOnError: true }), - input.sdk.v2.session.active({ throwOnError: true }), + input.sdk.message.list({ sessionID: input.sessionID, limit: input.replayLimit ?? 200, order: "desc" }), + input.sdk.permission.list({ sessionID: input.sessionID }), + input.sdk.question.list({ sessionID: input.sessionID }), + input.sdk.session.active(), ]) - const projected = messages.data.data.toReversed() + const projected = structuredClone(messages.data).toReversed() as SessionMessage[] for (const message of projected) renderMessage(message, next.render, next.reuseVisibleWait) - state.permissions = permissions.data.data.map(permission) - state.questions = questions.data.data.map(question) + state.permissions = permissions.map(permission) + state.questions = questions.map(question) syncBlockers() - await subagents.hydrate({ messages: projected, active: active.data.data }) - const running = input.sessionID in active.data.data + await subagents.hydrate({ messages: [...projected], active: active.data }) + const running = input.sessionID in active.data write([], { phase: running ? "running" : "idle", status: running ? "assistant responding" : "" }) if (!running && state.wait && (state.wait.promoted || state.wait.interrupted)) { const current = state.wait @@ -388,6 +555,11 @@ export async function createSessionTransport(input: StreamInput): Promise { + if (catalogEvents.has(event.type)) { + if (input.directory && event.location?.directory && event.location.directory !== input.directory) return + input.onCatalogRefresh?.() + return + } const source = sessionID(event) if (source !== input.sessionID) { if (source) subagents.foreign(source, event) @@ -395,17 +567,66 @@ export async function createSessionTransport(input: StreamInput): Promise previous.length) @@ -445,7 +666,7 @@ export async function createSessionTransport(input: StreamInput): Promise previous.length) @@ -486,41 +707,48 @@ export async function createSessionTransport(input: StreamInput): Promise 0 ? total.toLocaleString() : "" - write([], { phase: event.data.finish === "tool-calls" ? "running" : "idle", usage: event.data.cost ? `${usage} · ${money.format(event.data.cost)}` : usage }) + write([], { + phase: event.data.finish === "tool-calls" ? "running" : "idle", + usage: event.data.cost ? `${usage} · ${money.format(event.data.cost)}` : usage, + }) return } - if (event.type === "session.next.step.failed") { + if (event.type === "session.step.failed") { state.errors.add(event.data.assistantMessageID) if (state.wait) state.wait.failureRendered = true write([{ kind: "error", source: "system", text: errorMessage(event.data.error), phase: "start" }]) return } - if (event.type === "session.next.execution.settled") { + if (event.type === "session.execution.settled") { write([], { phase: "idle", status: "" }) const current = state.wait if (!current || (!current.promoted && !current.interrupted)) return @@ -606,12 +837,7 @@ export async function createSessionTransport(input: StreamInput): Promise connection.abort() controller.signal.addEventListener("abort", abortConnection, { once: true }) - const response = await input.sdk.v2.event.subscribe({ - signal: connection.signal, - sseMaxRetryAttempts: 0, - throwOnError: true, - }) - const stream = response.stream[Symbol.asyncIterator]() as AsyncGenerator + const stream = input.sdk.event.subscribe({ signal: connection.signal })[Symbol.asyncIterator]() try { const first = await stream.next() if (first.done || first.value.type !== "server.connected") throw new Error("Event stream disconnected") @@ -627,6 +853,7 @@ export async function createSessionTransport(input: StreamInput): Promise {}) await hydrate({ render: state.initial ? input.replay === true : true, reuseVisibleWait: !state.initial }) + input.onCatalogRefresh?.() state.initial = false booting = false for (const event of buffered.splice(0)) apply(event) @@ -656,108 +883,185 @@ export async function createSessionTransport(input: StreamInput): Promise { + 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((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 }, + ) => { + let resolve!: () => void + let reject!: (error: unknown) => void + const done = new Promise((ok, fail) => { + resolve = ok + reject = fail + }) + const active: Wait = { + messageID, + promoted: turn.promoted === true, + interrupted: false, + failureRendered: false, + resolve, + reject, + onVisibleOutput: next.onVisibleOutput, + } + state.wait = active + const interrupt = () => { + active.interrupted = true + void input.sdk.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) + } + next.signal?.addEventListener("abort", interrupt, { once: true }) + try { + await turn.send() + await done + } catch (error) { + if (state.wait === active) state.wait = undefined + if (next.signal?.aborted) return + throw error + } finally { + next.signal?.removeEventListener("abort", interrupt) + } + } + return { async runPromptTurn(next) { - if (next.prompt.mode === "shell") throw new Error("Shell is not yet available for current Session transcripts") - if (next.prompt.command) throw new Error("Commands are not yet available for current Session transcripts") - if (state.wait) throw new Error("prompt already running") + if (next.prompt.mode === "shell") { + await runShellTurn(next) + return + } + if (state.wait || state.shellWait) throw new Error("prompt already running") if (!state.connected) throw new Error("Event stream is reconnecting") + const messageID = next.prompt.messageID + if (!messageID) throw new Error("Prompt message ID is required") + + const command = next.prompt.command + if (command?.source === "skill") { + input.trace?.write("send.skill", { sessionID: input.sessionID, messageID, skill: command.name }) + await runTurnWait(next, messageID, { + send: () => + input.sdk.session.skill( + { sessionID: input.sessionID, id: messageID, skill: command.name }, + { signal: next.signal }, + ), + }) + return + } + if (command) { + const selected = await resolveSelectedModel(input, next) + if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model") + // Agent and model ride the command payload; the server switches only + // when the command itself does not pin them. + const files = [ + ...(next.includeFiles ? next.files : []).map((file) => ({ uri: file.url, name: file.filename })), + ...promptFiles(next), + ] + const agents = promptAgents(next) + input.trace?.write("send.command", { sessionID: input.sessionID, messageID, command: command.name }) + await runTurnWait(next, messageID, { + send: () => + input.sdk.session.command( + { + sessionID: input.sessionID, + id: messageID, + command: command.name, + arguments: command.arguments, + agent: next.agent, + model: selected, + files: files.length ? files : undefined, + agents: agents.length ? agents : undefined, + delivery: "steer", + }, + { signal: next.signal }, + ), + }) + return + } if (next.agent) { - await input.sdk.v2.session.switchAgent( + await input.sdk.session.switchAgent( { sessionID: input.sessionID, agent: next.agent }, - { throwOnError: true, signal: next.signal }, + { signal: next.signal }, ) } const selected = await resolveSelectedModel(input, next) if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model") if (selected) - await input.sdk.v2.session.switchModel( + await input.sdk.session.switchModel( { sessionID: input.sessionID, model: selected }, - { throwOnError: true, signal: next.signal }, + { signal: next.signal }, ) const prepared = await Promise.all((next.includeFiles ? next.files : []).map(prepareFile)) - const promptFiles = next.prompt.parts.flatMap((part) => - part.type === "file" - ? [ - { - uri: part.url, - name: part.filename, - source: promptFileSource(part), - }, - ] - : [], - ) const attachments = [ ...prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])), - ...promptFiles, + ...promptFiles(next), ] - const agents = next.prompt.parts.flatMap((part) => - part.type === "agent" - ? [ - { - name: part.name, - source: part.source - ? { start: part.source.start, end: part.source.end, text: part.source.value } - : undefined, + const agents = promptAgents(next) + input.trace?.write("send.prompt", { sessionID: input.sessionID, messageID }) + await runTurnWait(next, messageID, { + send: () => + input.sdk.session.prompt( + { + sessionID: input.sessionID, + id: messageID, + prompt: { + text: [next.prompt.text, ...prepared.flatMap((file) => (file.text ? [file.text] : []))].join("\n\n"), + files: attachments.length ? attachments : undefined, + agents: agents.length ? agents : undefined, }, - ] - : [], - ) - const messageID = next.prompt.messageID - if (!messageID) throw new Error("Prompt message ID is required") - let resolve!: () => void - let reject!: (error: unknown) => void - const done = new Promise((done, fail) => { - resolve = done - reject = fail - }) - const active: Wait = { - messageID, - promoted: false, - interrupted: false, - failureRendered: false, - resolve, - reject, - onVisibleOutput: next.onVisibleOutput, - } - state.wait = active - const interrupt = () => { - active.interrupted = true - void input.sdk.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) - } - next.signal?.addEventListener("abort", interrupt, { once: true }) - try { - input.trace?.write("send.prompt", { sessionID: input.sessionID, messageID }) - await input.sdk.v2.session.prompt( - { - sessionID: input.sessionID, - id: messageID, - prompt: { - text: [ - next.prompt.text, - ...prepared.flatMap((file) => (file.text ? [file.text] : [])), - ].join("\n\n"), - files: attachments.length ? attachments : undefined, - agents: agents.length ? agents : undefined, + delivery: "steer", }, - delivery: "steer", - }, - { throwOnError: true, signal: next.signal }, - ) - await done - } catch (error) { - if (state.wait === active) state.wait = undefined - if (next.signal?.aborted) return - throw error - } finally { - next.signal?.removeEventListener("abort", interrupt) - } + { signal: next.signal }, + ), + }) }, async interruptActiveTurn() { + // A running shell holds no drain, so session.interrupt cannot reach it; + // abort the blocking request instead. The server-side command keeps its + // own lifecycle and simply loses its waiter. + const shell = state.shellWait + if (shell) { + shell.abort() + return + } if (state.wait) state.wait.interrupted = true - await input.sdk.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) + await input.sdk.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) }, selectSubagent(sessionID) { subagents.select(sessionID) @@ -776,6 +1080,10 @@ export async function createSessionTransport(input: StreamInput): Promise +type PatchFile = { + type?: string + relativePath?: string + filePath?: string + movePath?: string + patch?: string + deletions?: number +} + +type ToolInput = ToolDict & { + path?: string + pattern?: string + filePath?: string + filepath?: string + url?: string + query?: string + subagent_type?: string + description?: string + name?: string + operation?: string + line?: number + character?: number + content?: string + command?: string + workdir?: string + todos?: Array<{ status?: string; content?: string }> + questions?: Array<{ question?: string }> + diff?: string +} + +type ToolMetadata = ToolDict & { + count?: number + matches?: number + diff?: string + provider?: unknown + files?: PatchFile[] + answers?: string[][] + exit?: number +} + export type ToolFrame = { raw: string name: string @@ -73,15 +96,15 @@ export type ToolPermissionInfo = { file?: string } -export type ToolProps = { - input: Partial> - metadata: Partial> +export type ToolProps = { + input: ToolInput + metadata: ToolMetadata frame: ToolFrame } -type ToolPermissionProps = { - input: Partial> - metadata: Partial> +type ToolPermissionProps = { + input: ToolInput + metadata: ToolMetadata patterns: string[] } @@ -91,40 +114,35 @@ type ToolPermissionCtx = { patterns: string[] } -type ToolDefs = { - invalid: typeof InvalidTool - bash: typeof BashTool - write: typeof WriteTool - edit: typeof EditTool - apply_patch: typeof ApplyPatchTool - batch: Tool.Info - task: typeof TaskTool - todowrite: typeof TodoWriteTool - question: typeof QuestionTool - read: typeof ReadTool - glob: typeof GlobTool - grep: typeof GrepTool - list: Tool.Info - lsp: typeof LspTool - webfetch: typeof WebFetchTool - websearch: typeof WebSearchTool - skill: typeof SkillTool - plan_exit: typeof PlanExitTool -} +type ToolName = + | "invalid" + | "bash" + | "write" + | "edit" + | "apply_patch" + | "batch" + | "task" + | "todowrite" + | "question" + | "read" + | "glob" + | "grep" + | "list" + | "lsp" + | "webfetch" + | "websearch" + | "skill" + | "plan_exit" -type ToolName = keyof ToolDefs - -type ToolRule = { +type ToolRule = { view: ToolView - run: (props: ToolProps) => ToolInline - scroll?: Partial) => string>> - permission?: (props: ToolPermissionProps) => ToolPermissionInfo - snap?: (props: ToolProps) => ToolSnapshot | undefined + run: (props: ToolProps) => ToolInline + scroll?: Partial string>> + permission?: (props: ToolPermissionProps) => ToolPermissionInfo + snap?: (props: ToolProps) => ToolSnapshot | undefined } -type ToolRegistry = { - [K in ToolName]: ToolRule -} +type ToolRegistry = Record type AnyToolRule = ToolRule @@ -136,22 +154,28 @@ function dict(v: unknown): ToolDict { return { ...v } } -function props(frame: ToolFrame): ToolProps { +function props(frame: ToolFrame): ToolProps { return { - input: Object.assign(Object.create(null), frame.input), - metadata: Object.assign(Object.create(null), frame.meta), + input: frame.input, + metadata: frame.meta, frame, } } -function permission(ctx: ToolPermissionCtx): ToolPermissionProps { +function permission(ctx: ToolPermissionCtx): ToolPermissionProps { return { - input: Object.assign(Object.create(null), ctx.input), - metadata: Object.assign(Object.create(null), ctx.meta), + input: ctx.input, + metadata: ctx.meta, patterns: ctx.patterns, } } +function webSearchProviderLabel(provider: unknown) { + if (provider === "parallel") return "Parallel Web Search" + if (provider === "exa") return "Exa Web Search" + return "Web Search" +} + function text(v: unknown): string { return typeof v === "string" ? v : "" } @@ -285,7 +309,7 @@ function count(n: number, label: string): string { return `${n} ${label}${n === 1 ? "" : "es"}` } -function runGlob(p: ToolProps): ToolInline { +function runGlob(p: ToolProps): ToolInline { const root = p.input.path ?? "" const title = `Glob "${p.input.pattern ?? ""}"` const suffix = root ? `in ${toolPath(root)}` : "" @@ -298,7 +322,7 @@ function runGlob(p: ToolProps): ToolInline { } } -function runGrep(p: ToolProps): ToolInline { +function runGrep(p: ToolProps): ToolInline { const root = p.input.path ?? "" const title = `Grep "${p.input.pattern ?? ""}"` const suffix = root ? `in ${toolPath(root)}` : "" @@ -319,7 +343,7 @@ function runList(p: ToolProps): ToolInline { } } -function runRead(p: ToolProps): ToolInline { +function runRead(p: ToolProps): ToolInline { const file = toolPath(p.input.filePath) const description = info(p.frame.input, ["filePath"]) || undefined return { @@ -329,7 +353,7 @@ function runRead(p: ToolProps): ToolInline { } } -function runWrite(p: ToolProps): ToolInline { +function runWrite(p: ToolProps): ToolInline { return { icon: "←", title: `Write ${toolPath(p.input.filePath)}`, @@ -338,7 +362,7 @@ function runWrite(p: ToolProps): ToolInline { } } -function runWebfetch(p: ToolProps): ToolInline { +function runWebfetch(p: ToolProps): ToolInline { const url = p.input.url ?? "" return { icon: "%", @@ -346,7 +370,7 @@ function runWebfetch(p: ToolProps): ToolInline { } } -function runEdit(p: ToolProps): ToolInline { +function runEdit(p: ToolProps): ToolInline { return { icon: "←", title: `Edit ${toolPath(p.input.filePath)}`, @@ -355,7 +379,7 @@ function runEdit(p: ToolProps): ToolInline { } } -function runWebSearch(p: ToolProps): ToolInline { +function runWebSearch(p: ToolProps): ToolInline { const title = webSearchProviderLabel(p.metadata.provider) return { icon: "◈", @@ -363,7 +387,7 @@ function runWebSearch(p: ToolProps): ToolInline { } } -function runTask(p: ToolProps): ToolInline { +function runTask(p: ToolProps): ToolInline { const kind = Locale.titlecase(p.input.subagent_type || "unknown") const desc = p.input.description const icon = p.frame.status === "error" ? "✗" : p.frame.status === "running" ? "•" : "✓" @@ -374,7 +398,7 @@ function runTask(p: ToolProps): ToolInline { } } -function runTodo(p: ToolProps): ToolInline { +function runTodo(p: ToolProps): ToolInline { return { icon: "#", title: "Todos", @@ -393,14 +417,14 @@ function runTodo(p: ToolProps): ToolInline { } } -function runSkill(p: ToolProps): ToolInline { +function runSkill(p: ToolProps): ToolInline { return { icon: "→", title: `Skill "${p.input.name ?? ""}"`, } } -function runPatch(p: ToolProps): ToolInline { +function runPatch(p: ToolProps): ToolInline { const files = p.metadata.files?.length ?? 0 if (files === 0) { return { @@ -415,7 +439,7 @@ function runPatch(p: ToolProps): ToolInline { } } -function runQuestion(p: ToolProps): ToolInline { +function runQuestion(p: ToolProps): ToolInline { const total = list(p.frame.input.questions).length return { icon: "→", @@ -423,7 +447,7 @@ function runQuestion(p: ToolProps): ToolInline { } } -function runInvalid(p: ToolProps): ToolInline { +function runInvalid(p: ToolProps): ToolInline { return { icon: "✗", title: text(p.frame.state.title) || "Invalid Tool", @@ -463,14 +487,14 @@ function lspTitle( return `LSP ${op} ${file}${pos}` } -function runLsp(p: ToolProps): ToolInline { +function runLsp(p: ToolProps): ToolInline { return { icon: "→", title: text(p.frame.state.title) || lspTitle(p.input), } } -function runPlanExit(p: ToolProps): ToolInline { +function runPlanExit(p: ToolProps): ToolInline { return { icon: "→", title: text(p.frame.state.title) || "Switching to build agent", @@ -479,8 +503,6 @@ function runPlanExit(p: ToolProps): ToolInline { } } -type PatchFile = Tool.InferMetadata["files"][number] - function patchTitle(file: PatchFile): string { const rel = file.relativePath const from = file.filePath @@ -497,7 +519,7 @@ function patchTitle(file: PatchFile): string { return `# Patched ${rel || toolPath(from)}` } -function snapWrite(p: ToolProps): ToolSnapshot | undefined { +function snapWrite(p: ToolProps): ToolSnapshot | undefined { const file = p.input.filePath || "" const content = p.input.content || "" if (!file && !content) { @@ -512,7 +534,7 @@ function snapWrite(p: ToolProps): ToolSnapshot | undefined { } } -function snapEdit(p: ToolProps): ToolSnapshot | undefined { +function snapEdit(p: ToolProps): ToolSnapshot | undefined { const file = p.input.filePath || "" const diff = p.metadata.diff || "" if (!file || !diff.trim()) { @@ -531,7 +553,7 @@ function snapEdit(p: ToolProps): ToolSnapshot | undefined { } } -function snapPatch(p: ToolProps): ToolSnapshot | undefined { +function snapPatch(p: ToolProps): ToolSnapshot | undefined { const files = list(p.frame.meta.files) if (files.length === 0) { return undefined @@ -568,7 +590,7 @@ function snapPatch(p: ToolProps): ToolSnapshot | undefine } } -function snapTask(p: ToolProps): ToolSnapshot { +function snapTask(p: ToolProps): ToolSnapshot { const kind = Locale.titlecase(p.input.subagent_type || "general") const desc = p.input.description const title = text(p.frame.state.title) @@ -582,7 +604,7 @@ function snapTask(p: ToolProps): ToolSnapshot { } } -function snapTodo(p: ToolProps): ToolSnapshot { +function snapTodo(p: ToolProps): ToolSnapshot { const items = list<{ status?: string; content?: string }>(p.frame.input.todos).flatMap((item) => { const content = typeof item?.content === "string" ? item.content : "" if (!content) { @@ -604,7 +626,7 @@ function snapTodo(p: ToolProps): ToolSnapshot { } } -function snapQuestion(p: ToolProps): ToolSnapshot { +function snapQuestion(p: ToolProps): ToolSnapshot { const answers = list(p.frame.meta.answers) const items = list<{ question?: string }>(p.frame.input.questions).map((item, i) => { const answer = list(answers[i]).filter((entry) => typeof entry === "string") @@ -621,7 +643,7 @@ function snapQuestion(p: ToolProps): ToolSnapshot { } } -function scrollBashStart(p: ToolProps): string { +function scrollBashStart(p: ToolProps): string { const cmd = p.input.command ?? "" const wd = p.input.workdir ?? "" const formatted = wd && wd !== "." ? toolPath(wd) : "" @@ -637,7 +659,7 @@ function scrollBashStart(p: ToolProps): string { return `# Running in ${dir}\n$ ${cmd}` } -function scrollBashProgress(p: ToolProps): string { +function scrollBashProgress(p: ToolProps): string { const out = stripAnsi(p.frame.raw) const cmd = (p.input.command ?? "").trim() const fmt = (text: string) => { @@ -670,7 +692,11 @@ function scrollBashProgress(p: ToolProps): string { return fmt(out) } -function scrollBashFinal(p: ToolProps): string { +function scrollBashFinal(p: ToolProps): string { + if (p.frame.status === "error") { + return fail(p.frame) + } + 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): string { return `bash completed (exit ${code})${time ? ` · ${time}` : ""}` } -function scrollReadStart(p: ToolProps): string { +function scrollReadStart(p: ToolProps): string { const file = toolPath(p.input.filePath) const extra = info(p.frame.input, ["filePath"]) const tail = extra ? ` ${extra}` : "" return `→ Read ${file}${tail}`.trim() } -function scrollWriteStart(_: ToolProps): string { +function scrollWriteStart(_: ToolProps): string { return "" } -function scrollEditStart(_: ToolProps): string { +function scrollEditStart(_: ToolProps): string { return "" } -function scrollPatchStart(_: ToolProps): string { +function scrollPatchStart(_: ToolProps): string { return "" } @@ -723,7 +749,7 @@ function patchLine(file: PatchFile): string { return `~ Patched ${rel || toolPath(from)}` } -function scrollPatchFinal(p: ToolProps): string { +function scrollPatchFinal(p: ToolProps): string { if (p.frame.status === "error") { return fail(p.frame) } @@ -752,7 +778,7 @@ function scrollPatchFinal(p: ToolProps): string { return patchLine(files[0]!) } -function scrollTaskStart(_: ToolProps): string { +function scrollTaskStart(_: ToolProps): string { return "" } @@ -774,7 +800,7 @@ function taskResult(output: string): string | undefined { return next || undefined } -function scrollTaskFinal(p: ToolProps): string { +function scrollTaskFinal(p: ToolProps): string { if (p.frame.status === "error") { return fail(p.frame) } @@ -788,11 +814,11 @@ function scrollTaskFinal(p: ToolProps): string { return `# ${kind} Task\n${row}` } -function scrollTodoStart(_: ToolProps): string { +function scrollTodoStart(_: ToolProps): string { return "" } -function scrollTodoFinal(p: ToolProps): string { +function scrollTodoFinal(p: ToolProps): string { const items = list<{ status?: string }>(p.input.todos) const time = span(p.frame.state) if (items.length === 0) { @@ -824,11 +850,11 @@ function scrollTodoFinal(p: ToolProps): string { return tail.join(" · ") } -function scrollQuestionStart(_: ToolProps): string { +function scrollQuestionStart(_: ToolProps): string { return "" } -function scrollQuestionFinal(p: ToolProps): string { +function scrollQuestionFinal(p: ToolProps): string { const q = p.input.questions ?? [] const a = p.metadata.answers ?? [] const time = span(p.frame.state) @@ -855,15 +881,15 @@ function scrollQuestionFinal(p: ToolProps): string { return rows.join("\n") } -function scrollLspStart(p: ToolProps): string { +function scrollLspStart(p: ToolProps): string { return `→ ${lspTitle(p.input)}` } -function scrollSkillStart(p: ToolProps): string { +function scrollSkillStart(p: ToolProps): string { return `→ Skill "${p.input.name ?? ""}"` } -function scrollGlobStart(p: ToolProps): string { +function scrollGlobStart(p: ToolProps): string { const pattern = p.input.pattern ?? "" const head = pattern ? `✱ Glob "${pattern}"` : "✱ Glob" const dir = p.input.path ?? "" @@ -874,11 +900,11 @@ function scrollGlobStart(p: ToolProps): string { return `${head} in ${toolPath(dir)}` } -function scrollGlobFinal(p: ToolProps): string { +function scrollGlobFinal(p: ToolProps): string { return toolError(p.frame) || fail(p.frame) } -function scrollGrepStart(p: ToolProps): string { +function scrollGrepStart(p: ToolProps): string { const pattern = p.input.pattern ?? "" const head = pattern ? `✱ Grep "${pattern}"` : "✱ Grep" const dir = p.input.path ?? "" @@ -898,7 +924,7 @@ function scrollListStart(p: ToolProps): string { return `→ List ${toolPath(dir)}` } -function scrollWebfetchStart(p: ToolProps): string { +function scrollWebfetchStart(p: ToolProps): string { const url = p.input.url ?? "" if (!url) { return "% WebFetch" @@ -907,7 +933,7 @@ function scrollWebfetchStart(p: ToolProps): string { return `% WebFetch ${url}` } -function scrollWebSearchStart(p: ToolProps): string { +function scrollWebSearchStart(p: ToolProps): string { const title = webSearchProviderLabel(p.metadata.provider) const query = p.input.query ?? "" if (!query) { @@ -917,7 +943,7 @@ function scrollWebSearchStart(p: ToolProps): string { return `◈ ${title} "${query}"` } -function permEdit(p: ToolPermissionProps): ToolPermissionInfo { +function permEdit(p: ToolPermissionProps): ToolPermissionInfo { const input = p.input as { filePath?: string; filepath?: string; diff?: string } const file = input.filePath || input.filepath || p.patterns[0] || "" return { @@ -929,7 +955,7 @@ function permEdit(p: ToolPermissionProps): ToolPermissionInfo { } } -function permRead(p: ToolPermissionProps): ToolPermissionInfo { +function permRead(p: ToolPermissionProps): ToolPermissionInfo { const file = p.input.filePath || p.patterns[0] || "" return { icon: "→", @@ -938,7 +964,7 @@ function permRead(p: ToolPermissionProps): ToolPermissionInfo { } } -function permGlob(p: ToolPermissionProps): ToolPermissionInfo { +function permGlob(p: ToolPermissionProps): ToolPermissionInfo { const pattern = p.input.pattern || p.patterns[0] || "" return { icon: "✱", @@ -947,7 +973,7 @@ function permGlob(p: ToolPermissionProps): ToolPermissionInfo { } } -function permGrep(p: ToolPermissionProps): ToolPermissionInfo { +function permGrep(p: ToolPermissionProps): ToolPermissionInfo { const pattern = p.input.pattern || p.patterns[0] || "" return { icon: "✱", @@ -965,7 +991,7 @@ function permList(p: ToolPermissionProps): ToolPermissionInfo { } } -function permBash(p: ToolPermissionProps): ToolPermissionInfo { +function permBash(p: ToolPermissionProps): ToolPermissionInfo { const cmd = p.input.command || "" return { icon: "#", @@ -974,7 +1000,7 @@ function permBash(p: ToolPermissionProps): ToolPermissionInfo { } } -function permTask(p: ToolPermissionProps): ToolPermissionInfo { +function permTask(p: ToolPermissionProps): ToolPermissionInfo { const type = p.input.subagent_type || "general" const desc = p.input.description return { @@ -984,7 +1010,7 @@ function permTask(p: ToolPermissionProps): ToolPermissionInfo { } } -function permWebfetch(p: ToolPermissionProps): ToolPermissionInfo { +function permWebfetch(p: ToolPermissionProps): ToolPermissionInfo { const url = p.input.url || "" return { icon: "%", @@ -993,7 +1019,7 @@ function permWebfetch(p: ToolPermissionProps): ToolPermissi } } -function permWebSearch(p: ToolPermissionProps): ToolPermissionInfo { +function permWebSearch(p: ToolPermissionProps): ToolPermissionInfo { const query = p.input.query || "" const title = webSearchProviderLabel(p.metadata.provider) return { @@ -1003,7 +1029,7 @@ function permWebSearch(p: ToolPermissionProps): ToolPermis } } -function permLsp(p: ToolPermissionProps): ToolPermissionInfo { +function permLsp(p: ToolPermissionProps): ToolPermissionInfo { const file = p.input.filePath || "" const line = typeof p.input.line === "number" ? p.input.line : undefined const char = typeof p.input.character === "number" ? p.input.character : undefined @@ -1269,7 +1295,7 @@ export function toolFrame(commit: StreamCommit, raw: string): ToolFrame { } } -function runBash(p: ToolProps): ToolInline { +function runBash(p: ToolProps): ToolInline { return { icon: "$", title: p.input.command || "", @@ -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 } diff --git a/packages/opencode/src/cli/cmd/run/trace.ts b/packages/cli/src/mini/trace.ts similarity index 100% rename from packages/opencode/src/cli/cmd/run/trace.ts rename to packages/cli/src/mini/trace.ts diff --git a/packages/opencode/src/cli/cmd/run/turn-summary.ts b/packages/cli/src/mini/turn-summary.ts similarity index 100% rename from packages/opencode/src/cli/cmd/run/turn-summary.ts rename to packages/cli/src/mini/turn-summary.ts diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/cli/src/mini/types.ts similarity index 89% rename from packages/opencode/src/cli/cmd/run/types.ts rename to packages/cli/src/mini/types.ts index 27372596f1..1077dc1c9f 100644 --- a/packages/opencode/src/cli/cmd/run/types.ts +++ b/packages/cli/src/mini/types.ts @@ -11,7 +11,8 @@ // → stream.ts bridges to footer API // → footer.ts queues commits and patches the footer view // → OpenTUI split-footer renderer writes to terminal -import type { OpencodeClient, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2" +import type { OpenCodeClient, ReferenceListOutput } from "@opencode-ai/client/promise" +import type { FilePart, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2" import type { TuiConfig } from "@opencode-ai/tui/config" export type RunFilePart = { @@ -21,10 +22,17 @@ export type RunFilePart = { mime: string } -type PromptModel = Parameters[0]["model"] -type PromptInput = Parameters[0] +type PromptModel = { providerID: string; modelID: string } -export type RunPromptPart = NonNullable[number] +export type RunPromptPart = + | { + type: "file" + url: string + filename?: string + mime?: string + source?: FilePart["source"] + } + | { type: "agent"; name: string; source?: { start: number; end: number; value: string } } export type RunCommand = { name: string @@ -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>["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 } | { type: "turn.send" @@ -339,11 +352,14 @@ export type FooterEvent = state: FooterSubagentState } -export type PermissionReply = Parameters[0] +export type PermissionReply = Omit[0], "sessionID"> -export type QuestionReply = Parameters[0] +export type QuestionReply = { + requestID: string + answers: string[][] +} -export type QuestionReject = Parameters[0] +export type QuestionReject = Omit[0], "sessionID"> export type RunTuiConfig = Pick diff --git a/packages/cli/src/mini/ui.ts b/packages/cli/src/mini/ui.ts new file mode 100644 index 0000000000..1caf2674fc --- /dev/null +++ b/packages/cli/src/mini/ui.ts @@ -0,0 +1,27 @@ +import { EOL } from "node:os" + +export const Style = { + TEXT_DIM: "\x1b[90m", + TEXT_NORMAL: "\x1b[0m", + TEXT_WARNING_BOLD: "\x1b[93m\x1b[1m", + TEXT_DANGER_BOLD: "\x1b[91m\x1b[1m", +} + +export function println(...message: string[]) { + process.stderr.write(message.join(" ") + EOL) +} + +let blank = false + +export function empty() { + if (blank) return + println(Style.TEXT_NORMAL) + blank = true +} + +export function error(message: string) { + if (message.startsWith("Error: ")) message = message.slice("Error: ".length) + println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message) +} + +export * as UI from "./ui" diff --git a/packages/opencode/src/cli/cmd/run/variant.shared.ts b/packages/cli/src/mini/variant.shared.ts similarity index 96% rename from packages/opencode/src/cli/cmd/run/variant.shared.ts rename to packages/cli/src/mini/variant.shared.ts index fa10af0055..196b4e040c 100644 --- a/packages/opencode/src/cli/cmd/run/variant.shared.ts +++ b/packages/cli/src/mini/variant.shared.ts @@ -12,9 +12,8 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Context, Effect, Layer } from "effect" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" -import { makeRuntime } from "@/effect/run-service" +import { makeRuntime } from "@opencode-ai/core/effect/runtime" import { Global } from "@opencode-ai/core/global" -import { isRecord } from "@/util/record" import { createSession, sessionVariant, type RunSession, type SessionMessages } from "./session.shared" import type { RunInput, RunProvider } from "./types" @@ -32,6 +31,10 @@ type VariantRuntime = { saveVariant(model: RunInput["model"], variant: string | undefined): Promise } +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + class Service extends Context.Service()("@opencode/RunVariant") {} function modelKey(provider: string, model: string): string { diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts new file mode 100644 index 0000000000..48471a573a --- /dev/null +++ b/packages/cli/src/server-process.ts @@ -0,0 +1,134 @@ +export * as ServerProcess from "./server-process" + +import { NodeServices } from "@effect/platform-node" +import { Service } from "@opencode-ai/client/effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Global } from "@opencode-ai/core/global" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { AppProcess } from "@opencode-ai/core/process" +import { Flock } from "@opencode-ai/core/util/flock" +import { start } from "@opencode-ai/server/process" +import { randomBytes, randomUUID } from "node:crypto" +import path from "node:path" +import { Effect, FileSystem, Logger, Option, Redacted, Schedule, Schema } from "effect" +import { HttpServer } from "effect/unstable/http" +import { Env } from "./env" +import { ServiceConfig } from "./services/service-config" +import { Updater } from "./services/updater" + +export type Mode = "default" | "service" | "stdio" + +export type Options = { + readonly mode: Mode + readonly hostname?: string + readonly port?: number +} + +export const run = Effect.fn("cli.server-process.run")((options: Options) => + processEffect(options).pipe( + Effect.provide(Updater.layer), + Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))), + Effect.provide(NodeServices.layer), + ), +) + +const processEffect = Effect.fnUntraced(function* (options: Options) { + if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home)) + return yield* Effect.scoped( + Effect.gen(function* () { + if (options.mode === "service") { + const service = yield* ServiceConfig.options() + yield* Flock.effect(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((resume) => { + const close = () => resume(Effect.void) + process.stdin.once("end", close) + process.stdin.once("close", close) + process.stdin.resume() + if (process.stdin.readableEnded || process.stdin.destroyed) close() + return Effect.sync(() => { + process.stdin.off("end", close) + process.stdin.off("close", close) + process.stdin.pause() + }) + }) +} diff --git a/packages/cli/src/services/service-config.ts b/packages/cli/src/services/service-config.ts index 356472e359..ecdd5bf19a 100644 --- a/packages/cli/src/services/service-config.ts +++ b/packages/cli/src/services/service-config.ts @@ -5,7 +5,7 @@ import { Effect, FileSystem, Schema } from "effect" import { randomBytes } from "crypto" import path from "path" -// The CLI's service configuration file, plus the ServiceOptions binding that +// The CLI's service configuration file, plus the Service.Options binding that // points the client package's service operations at this CLI: which // registration file (by channel), which version, and how to spawn opencode. diff --git a/packages/cli/src/services/standalone.ts b/packages/cli/src/services/standalone.ts index b591a5deb1..c0a8d8b71d 100644 --- a/packages/cli/src/services/standalone.ts +++ b/packages/cli/src/services/standalone.ts @@ -9,13 +9,21 @@ import path from "node:path" const Ready = Schema.Struct({ url: Schema.String }) const decodeReady = Schema.decodeUnknownPromise(Schema.fromJsonString(Ready)) -function command(password: string) { +type Options = { + readonly command?: ReadonlyArray +} + +function command(password: string, options: Options) { const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun" const entrypoint = compiled ? [] : process.argv[1] ? [process.argv[1]] : [] if (!compiled && entrypoint.length === 0) throw new Error("Failed to resolve CLI entrypoint") - return ChildProcess.make(process.execPath, [...entrypoint, "serve", "--stdio", "--port", "0"], { + const [executable, ...args] = options.command ?? [process.execPath, ...entrypoint, "serve"] + if (!executable) throw new Error("Failed to resolve standalone server command") + return ChildProcess.make(executable, [...args, "--stdio", "--port", "0"], { cwd: process.cwd(), - env: { OPENCODE_SERVER_PASSWORD: password }, + // Explicit entry wins over anything inherited, so a user-exported + // OPENCODE_PASSWORD cannot shadow the child's lease credential. + env: { OPENCODE_PASSWORD: password }, extendEnv: true, // The server treats EOF on this pipe as the end of its ownership lease. // The OS closes it even when the TUI is killed before Effect finalizers run. @@ -26,17 +34,21 @@ function command(password: string) { }) } -export const transport = Effect.fn("cli.standalone.transport")( - function* () { +const makeTransport = Effect.fn("cli.standalone.transport")( + function* (options: Options) { const password = randomBytes(32).toString("base64url") const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - const proc = yield* spawner.spawn(command(password)) + const proc = yield* spawner.spawn(command(password, options)) const output = yield* proc.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.take(1), Stream.mkString) if (!output) return yield* Effect.fail(new Error("Standalone server exited before reporting readiness")) const ready = yield* Effect.tryPromise(() => decodeReady(output)) - return { url: ready.url, headers: ServerAuth.headers({ password }), pid: proc.pid } + return { url: ready.url, headers: ServerAuth.headers({ password, username: "opencode" }), pid: proc.pid } }, Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)), ) +export function transport(options: Options = {}) { + return makeTransport(options) +} + export * as Standalone from "./standalone" diff --git a/packages/cli/src/tui.ts b/packages/cli/src/tui.ts index 577a7be493..96d92c50b2 100644 --- a/packages/cli/src/tui.ts +++ b/packages/cli/src/tui.ts @@ -5,11 +5,16 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Global } from "@opencode-ai/core/global" import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins" import { OpenCode } from "@opencode-ai/client/promise" -import type { Transport } from "@opencode-ai/client/effect" +import type { Service } from "@opencode-ai/client/effect" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import type { Args } from "@opencode-ai/tui/context/args" -export function runTui(transport: Transport, args: Args, discover?: () => Promise) { +export function runTui( + transport: Service.Transport, + args: Args, + discover?: () => Promise, + reload?: () => Promise, +) { const config = TuiConfig.resolve({}, { terminalSuspend: false }) let disposeSlots: (() => void) | undefined return Effect.gen(function* () { @@ -33,6 +38,7 @@ export function runTui(transport: Transport, args: Args, discover?: () => Promis } } : undefined, + reload, args, config, pluginHost: { diff --git a/packages/cli/test/fixture/standalone-owner.ts b/packages/cli/test/fixture/standalone-owner.ts index 6149f614de..1ad311890a 100644 --- a/packages/cli/test/fixture/standalone-owner.ts +++ b/packages/cli/test/fixture/standalone-owner.ts @@ -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 }), ), diff --git a/packages/cli/test/mini.test.ts b/packages/cli/test/mini.test.ts new file mode 100644 index 0000000000..4734b62476 --- /dev/null +++ b/packages/cli/test/mini.test.ts @@ -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") + } + }) +}) diff --git a/packages/cli/test/standalone.test.ts b/packages/cli/test/standalone.test.ts index 1a309477f3..3cfe25855f 100644 --- a/packages/cli/test/standalone.test.ts +++ b/packages/cli/test/standalone.test.ts @@ -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") diff --git a/packages/client/package.json b/packages/client/package.json index 1314239d41..85b550779e 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -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:", diff --git a/packages/client/script/build-package.ts b/packages/client/script/build-package.ts new file mode 100644 index 0000000000..323a63ddf9 --- /dev/null +++ b/packages/client/script/build-package.ts @@ -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` diff --git a/packages/client/script/build.ts b/packages/client/script/build.ts index 2ba1dbc9c2..6a83d52b99 100644 --- a/packages/client/script/build.ts +++ b/packages/client/script/build.ts @@ -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 }, diff --git a/packages/client/script/publish.ts b/packages/client/script/publish.ts new file mode 100644 index 0000000000..8e37674b64 --- /dev/null +++ b/packages/client/script/publish.ts @@ -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 +} +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 }) +} diff --git a/packages/client/src/effect/api.ts b/packages/client/src/effect/api.ts new file mode 100644 index 0000000000..e7cb2012f5 --- /dev/null +++ b/packages/client/src/effect/api.ts @@ -0,0 +1,8 @@ +import type { ModelApi, ProviderApi } from "./api/api.js" + +export type * from "./api/api.js" + +export interface CatalogApi { + readonly provider: ProviderApi + readonly model: ModelApi +} diff --git a/packages/plugin/src/v2/effect/generated/.httpapi-codegen.json b/packages/client/src/effect/api/.httpapi-codegen.json similarity index 100% rename from packages/plugin/src/v2/effect/generated/.httpapi-codegen.json rename to packages/client/src/effect/api/.httpapi-codegen.json diff --git a/packages/plugin/src/v2/effect/generated/api.ts b/packages/client/src/effect/api/api.ts similarity index 93% rename from packages/plugin/src/v2/effect/generated/api.ts rename to packages/client/src/effect/api/api.ts index 049ca7486c..9b67aee42b 100644 --- a/packages/plugin/src/v2/effect/generated/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -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 type EffectValue = A extends Effect.Effect ? Success : never @@ -153,96 +153,105 @@ export type Endpoint4_11Input = { export type Endpoint4_11Output = EffectValue> export type SessionSyntheticOperation = (input: Endpoint4_11Input) => Effect.Effect -type Endpoint4_12Request = Parameters[0] -export type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] } -export type Endpoint4_12Output = EffectValue> -export type SessionCompactOperation = (input: Endpoint4_12Input) => Effect.Effect - -type Endpoint4_13Request = Parameters[0] -export type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] } -export type Endpoint4_13Output = EffectValue> -export type SessionWaitOperation = (input: Endpoint4_13Input) => Effect.Effect - -type Endpoint4_14Request = Parameters[0] -export type Endpoint4_14Input = { - readonly sessionID: Endpoint4_14Request["params"]["sessionID"] - readonly messageID: Endpoint4_14Request["payload"]["messageID"] - readonly files?: Endpoint4_14Request["payload"]["files"] +type Endpoint4_12Request = Parameters[0] +export type Endpoint4_12Input = { + readonly sessionID: Endpoint4_12Request["params"]["sessionID"] + readonly id?: Endpoint4_12Request["payload"]["id"] + readonly command: Endpoint4_12Request["payload"]["command"] } -export type Endpoint4_14Output = EffectValue>["data"] -export type SessionRevertStageOperation = (input: Endpoint4_14Input) => Effect.Effect +export type Endpoint4_12Output = EffectValue> +export type SessionShellOperation = (input: Endpoint4_12Input) => Effect.Effect -type Endpoint4_15Request = Parameters[0] -export type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] } -export type Endpoint4_15Output = EffectValue> -export type SessionRevertClearOperation = (input: Endpoint4_15Input) => Effect.Effect +type Endpoint4_13Request = Parameters[0] +export type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] } +export type Endpoint4_13Output = EffectValue> +export type SessionCompactOperation = (input: Endpoint4_13Input) => Effect.Effect -type Endpoint4_16Request = Parameters[0] +type Endpoint4_14Request = Parameters[0] +export type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] } +export type Endpoint4_14Output = EffectValue> +export type SessionWaitOperation = (input: Endpoint4_14Input) => Effect.Effect + +type Endpoint4_15Request = Parameters[0] +export type Endpoint4_15Input = { + readonly sessionID: Endpoint4_15Request["params"]["sessionID"] + readonly messageID: Endpoint4_15Request["payload"]["messageID"] + readonly files?: Endpoint4_15Request["payload"]["files"] +} +export type Endpoint4_15Output = EffectValue>["data"] +export type SessionRevertStageOperation = (input: Endpoint4_15Input) => Effect.Effect + +type Endpoint4_16Request = Parameters[0] export type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] } -export type Endpoint4_16Output = EffectValue> -export type SessionRevertCommitOperation = (input: Endpoint4_16Input) => Effect.Effect +export type Endpoint4_16Output = EffectValue> +export type SessionRevertClearOperation = (input: Endpoint4_16Input) => Effect.Effect -type Endpoint4_17Request = Parameters[0] +type Endpoint4_17Request = Parameters[0] export type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] } -export type Endpoint4_17Output = EffectValue>["data"] -export type SessionContextOperation = (input: Endpoint4_17Input) => Effect.Effect +export type Endpoint4_17Output = EffectValue> +export type SessionRevertCommitOperation = (input: Endpoint4_17Input) => Effect.Effect -type Endpoint4_18Request = Parameters[0] +type Endpoint4_18Request = Parameters[0] export type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] } -export type Endpoint4_18Output = EffectValue< +export type Endpoint4_18Output = EffectValue>["data"] +export type SessionContextOperation = (input: Endpoint4_18Input) => Effect.Effect + +type Endpoint4_19Request = Parameters[0] +export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] } +export type Endpoint4_19Output = EffectValue< ReturnType >["data"] export type SessionListContextEntriesOperation = ( - input: Endpoint4_18Input, -) => Effect.Effect - -type Endpoint4_19Request = Parameters[0] -export type Endpoint4_19Input = { - readonly sessionID: Endpoint4_19Request["params"]["sessionID"] - readonly key: Endpoint4_19Request["params"]["key"] - readonly value: Endpoint4_19Request["payload"]["value"] -} -export type Endpoint4_19Output = EffectValue> -export type SessionPutContextEntryOperation = ( input: Endpoint4_19Input, ) => Effect.Effect -type Endpoint4_20Request = Parameters[0] +type Endpoint4_20Request = Parameters[0] export type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] readonly key: Endpoint4_20Request["params"]["key"] + readonly value: Endpoint4_20Request["payload"]["value"] } -export type Endpoint4_20Output = EffectValue> -export type SessionRemoveContextEntryOperation = ( +export type Endpoint4_20Output = EffectValue> +export type SessionPutContextEntryOperation = ( input: Endpoint4_20Input, ) => Effect.Effect -type Endpoint4_21Request = Parameters[0] +type Endpoint4_21Request = Parameters[0] export type Endpoint4_21Input = { readonly sessionID: Endpoint4_21Request["params"]["sessionID"] - readonly after?: Endpoint4_21Request["query"]["after"] - readonly follow?: Endpoint4_21Request["query"]["follow"] + readonly key: Endpoint4_21Request["params"]["key"] } -export type Endpoint4_21Output = StreamValue>> -export type SessionLogOperation = (input: Endpoint4_21Input) => Stream.Stream +export type Endpoint4_21Output = EffectValue> +export type SessionRemoveContextEntryOperation = ( + input: Endpoint4_21Input, +) => Effect.Effect -type Endpoint4_22Request = Parameters[0] -export type Endpoint4_22Input = { readonly sessionID: Endpoint4_22Request["params"]["sessionID"] } -export type Endpoint4_22Output = EffectValue> -export type SessionInterruptOperation = (input: Endpoint4_22Input) => Effect.Effect +type Endpoint4_22Request = Parameters[0] +export type Endpoint4_22Input = { + readonly sessionID: Endpoint4_22Request["params"]["sessionID"] + readonly after?: Endpoint4_22Request["query"]["after"] + readonly follow?: Endpoint4_22Request["query"]["follow"] +} +export type Endpoint4_22Output = StreamValue>> +export type SessionLogOperation = (input: Endpoint4_22Input) => Stream.Stream -type Endpoint4_23Request = Parameters[0] +type Endpoint4_23Request = Parameters[0] export type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] } -export type Endpoint4_23Output = EffectValue> -export type SessionBackgroundOperation = (input: Endpoint4_23Input) => Effect.Effect +export type Endpoint4_23Output = EffectValue> +export type SessionInterruptOperation = (input: Endpoint4_23Input) => Effect.Effect -type Endpoint4_24Request = Parameters[0] -export type Endpoint4_24Input = { - readonly sessionID: Endpoint4_24Request["params"]["sessionID"] - readonly messageID: Endpoint4_24Request["params"]["messageID"] +type Endpoint4_24Request = Parameters[0] +export type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] } +export type Endpoint4_24Output = EffectValue> +export type SessionBackgroundOperation = (input: Endpoint4_24Input) => Effect.Effect + +type Endpoint4_25Request = Parameters[0] +export type Endpoint4_25Input = { + readonly sessionID: Endpoint4_25Request["params"]["sessionID"] + readonly messageID: Endpoint4_25Request["params"]["messageID"] } -export type Endpoint4_24Output = EffectValue>["data"] -export type SessionMessageOperation = (input: Endpoint4_24Input) => Effect.Effect +export type Endpoint4_25Output = EffectValue>["data"] +export type SessionMessageOperation = (input: Endpoint4_25Input) => Effect.Effect export interface SessionApi { readonly list: SessionListOperation @@ -257,6 +266,7 @@ export interface SessionApi { readonly command: SessionCommandOperation readonly skill: SessionSkillOperation readonly synthetic: SessionSyntheticOperation + readonly shell: SessionShellOperation readonly compact: SessionCompactOperation readonly wait: SessionWaitOperation readonly revertStage: SessionRevertStageOperation @@ -440,20 +450,24 @@ export interface CredentialApi { readonly remove: CredentialRemoveOperation } -type Endpoint12_0Request = Parameters[0] -export type Endpoint12_0Input = { readonly location?: Endpoint12_0Request["query"]["location"] } -export type Endpoint12_0Output = EffectValue> -export type ProjectCurrentOperation = (input?: Endpoint12_0Input) => Effect.Effect +export type Endpoint12_0Output = EffectValue> +export type ProjectListOperation = () => Effect.Effect -type Endpoint12_1Request = Parameters[0] -export type Endpoint12_1Input = { - readonly projectID: Endpoint12_1Request["params"]["projectID"] - readonly location?: Endpoint12_1Request["query"]["location"] +type Endpoint12_1Request = Parameters[0] +export type Endpoint12_1Input = { readonly location?: Endpoint12_1Request["query"]["location"] } +export type Endpoint12_1Output = EffectValue> +export type ProjectCurrentOperation = (input?: Endpoint12_1Input) => Effect.Effect + +type Endpoint12_2Request = Parameters[0] +export type Endpoint12_2Input = { + readonly projectID: Endpoint12_2Request["params"]["projectID"] + readonly location?: Endpoint12_2Request["query"]["location"] } -export type Endpoint12_1Output = EffectValue> -export type ProjectDirectoriesOperation = (input: Endpoint12_1Input) => Effect.Effect +export type Endpoint12_2Output = EffectValue> +export type ProjectDirectoriesOperation = (input: Endpoint12_2Input) => Effect.Effect export interface ProjectApi { + readonly list: ProjectListOperation readonly current: ProjectCurrentOperation readonly directories: ProjectDirectoriesOperation } @@ -852,6 +866,13 @@ export interface VcsApi { readonly diff: VcsDiffOperation } +export type Endpoint25_0Output = EffectValue> +export type DebugLocationOperation = () => Effect.Effect + +export interface DebugApi { + readonly location: DebugLocationOperation +} + export interface AppApi { readonly health: HealthApi readonly location: LocationApi @@ -878,4 +899,5 @@ export interface AppApi { readonly reference: ReferenceApi readonly projectCopy: ProjectCopyApi readonly vcs: VcsApi + readonly debug: DebugApi } diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index f25557c0de..1001c1e047 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -208,23 +208,35 @@ const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11I payload: { text: input["text"], description: input["description"], metadata: input["metadata"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_12Request = Parameters[0] -type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] } +type Endpoint4_12Request = Parameters[0] +type Endpoint4_12Input = { + readonly sessionID: Endpoint4_12Request["params"]["sessionID"] + readonly id?: Endpoint4_12Request["payload"]["id"] + readonly command: Endpoint4_12Request["payload"]["command"] +} const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) => - raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + raw["session.shell"]({ + params: { sessionID: input["sessionID"] }, + payload: { id: input["id"], command: input["command"] }, + }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_13Request = Parameters[0] +type Endpoint4_13Request = Parameters[0] type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] } const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) => + raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_14Request = Parameters[0] +type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] } +const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) => raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_14Request = Parameters[0] -type Endpoint4_14Input = { - readonly sessionID: Endpoint4_14Request["params"]["sessionID"] - readonly messageID: Endpoint4_14Request["payload"]["messageID"] - readonly files?: Endpoint4_14Request["payload"]["files"] +type Endpoint4_15Request = Parameters[0] +type Endpoint4_15Input = { + readonly sessionID: Endpoint4_15Request["params"]["sessionID"] + readonly messageID: Endpoint4_15Request["payload"]["messageID"] + readonly files?: Endpoint4_15Request["payload"]["files"] } -const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) => +const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) => raw["session.revert.stage"]({ params: { sessionID: input["sessionID"] }, payload: { messageID: input["messageID"], files: input["files"] }, @@ -233,61 +245,61 @@ const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14I Effect.map((value) => value.data), ) -type Endpoint4_15Request = Parameters[0] -type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] } -const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) => - raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) - -type Endpoint4_16Request = Parameters[0] +type Endpoint4_16Request = Parameters[0] type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] } const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) => - raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_17Request = Parameters[0] +type Endpoint4_17Request = Parameters[0] type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] } const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) => + raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_18Request = Parameters[0] +type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] } +const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) => raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint4_18Request = Parameters[0] -type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] } -const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) => +type Endpoint4_19Request = Parameters[0] +type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] } +const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) => raw["session.context.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint4_19Request = Parameters[0] -type Endpoint4_19Input = { - readonly sessionID: Endpoint4_19Request["params"]["sessionID"] - readonly key: Endpoint4_19Request["params"]["key"] - readonly value: Endpoint4_19Request["payload"]["value"] +type Endpoint4_20Request = Parameters[0] +type Endpoint4_20Input = { + readonly sessionID: Endpoint4_20Request["params"]["sessionID"] + readonly key: Endpoint4_20Request["params"]["key"] + readonly value: Endpoint4_20Request["payload"]["value"] } -const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) => +const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) => raw["session.context.entry.put"]({ params: { sessionID: input["sessionID"], key: input["key"] }, payload: { value: input["value"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_20Request = Parameters[0] -type Endpoint4_20Input = { - readonly sessionID: Endpoint4_20Request["params"]["sessionID"] - readonly key: Endpoint4_20Request["params"]["key"] +type Endpoint4_21Request = Parameters[0] +type Endpoint4_21Input = { + readonly sessionID: Endpoint4_21Request["params"]["sessionID"] + readonly key: Endpoint4_21Request["params"]["key"] } -const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) => +const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) => raw["session.context.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint4_21Request = Parameters[0] -type Endpoint4_21Input = { - readonly sessionID: Endpoint4_21Request["params"]["sessionID"] - readonly after?: Endpoint4_21Request["query"]["after"] - readonly follow?: Endpoint4_21Request["query"]["follow"] +type Endpoint4_22Request = Parameters[0] +type Endpoint4_22Input = { + readonly sessionID: Endpoint4_22Request["params"]["sessionID"] + readonly after?: Endpoint4_22Request["query"]["after"] + readonly follow?: Endpoint4_22Request["query"]["follow"] } -const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) => +const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) => Stream.unwrap( raw["session.log"]({ params: { sessionID: input["sessionID"] }, @@ -298,22 +310,22 @@ const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21I ), ) -type Endpoint4_22Request = Parameters[0] -type Endpoint4_22Input = { readonly sessionID: Endpoint4_22Request["params"]["sessionID"] } -const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) => - raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) - -type Endpoint4_23Request = Parameters[0] +type Endpoint4_23Request = Parameters[0] type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] } const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23Input) => + raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_24Request = Parameters[0] +type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] } +const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) => raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_24Request = Parameters[0] -type Endpoint4_24Input = { - readonly sessionID: Endpoint4_24Request["params"]["sessionID"] - readonly messageID: Endpoint4_24Request["params"]["messageID"] +type Endpoint4_25Request = Parameters[0] +type Endpoint4_25Input = { + readonly sessionID: Endpoint4_25Request["params"]["sessionID"] + readonly messageID: Endpoint4_25Request["params"]["messageID"] } -const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) => +const Endpoint4_25 = (raw: RawClient["server.session"]) => (input: Endpoint4_25Input) => raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), @@ -332,19 +344,20 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({ command: Endpoint4_9(raw), skill: Endpoint4_10(raw), synthetic: Endpoint4_11(raw), - compact: Endpoint4_12(raw), - wait: Endpoint4_13(raw), - revertStage: Endpoint4_14(raw), - revertClear: Endpoint4_15(raw), - revertCommit: Endpoint4_16(raw), - context: Endpoint4_17(raw), - listContextEntries: Endpoint4_18(raw), - putContextEntry: Endpoint4_19(raw), - removeContextEntry: Endpoint4_20(raw), - log: Endpoint4_21(raw), - interrupt: Endpoint4_22(raw), - background: Endpoint4_23(raw), - message: Endpoint4_24(raw), + shell: Endpoint4_12(raw), + compact: Endpoint4_13(raw), + wait: Endpoint4_14(raw), + revertStage: Endpoint4_15(raw), + revertClear: Endpoint4_16(raw), + revertCommit: Endpoint4_17(raw), + context: Endpoint4_18(raw), + listContextEntries: Endpoint4_19(raw), + putContextEntry: Endpoint4_20(raw), + removeContextEntry: Endpoint4_21(raw), + log: Endpoint4_22(raw), + interrupt: Endpoint4_23(raw), + background: Endpoint4_24(raw), + message: Endpoint4_25(raw), }) type Endpoint5_0Request = Parameters[0] @@ -531,25 +544,29 @@ const Endpoint11_1 = (raw: RawClient["server.credential"]) => (input: Endpoint11 const adaptGroup11 = (raw: RawClient["server.credential"]) => ({ update: Endpoint11_0(raw), remove: Endpoint11_1(raw) }) -type Endpoint12_0Request = Parameters[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[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[0] -type Endpoint12_1Input = { - readonly projectID: Endpoint12_1Request["params"]["projectID"] - readonly location?: Endpoint12_1Request["query"]["location"] +type Endpoint12_2Request = Parameters[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[0] @@ -1030,6 +1047,11 @@ const Endpoint24_1 = (raw: RawClient["server.vcs"]) => (input: Endpoint24_1Input const adaptGroup24 = (raw: RawClient["server.vcs"]) => ({ status: Endpoint24_0(raw), diff: Endpoint24_1(raw) }) +const Endpoint25_0 = (raw: RawClient["server.debug"]) => () => + raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)) + +const adaptGroup25 = (raw: RawClient["server.debug"]) => ({ location: Endpoint25_0(raw) }) + const adaptClient = (raw: RawClient) => ({ health: adaptGroup0(raw["server.health"]), location: adaptGroup1(raw["server.location"]), @@ -1056,6 +1078,7 @@ const adaptClient = (raw: RawClient) => ({ reference: adaptGroup22(raw["server.reference"]), projectCopy: adaptGroup23(raw["server.projectCopy"]), vcs: adaptGroup24(raw["server.vcs"]), + debug: adaptGroup25(raw["server.debug"]), }) export const make = (options?: { readonly baseUrl?: URL | string }) => diff --git a/packages/client/src/effect/index.ts b/packages/client/src/effect/index.ts index 1a69a358cc..8cde84695c 100644 --- a/packages/client/src/effect/index.ts +++ b/packages/client/src/effect/index.ts @@ -1,8 +1,23 @@ // TODO: Keep additional network capabilities inside Schema and Protocol as the client grows; /effect must never import // Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations. +import type { Effect } from "effect" + export * from "./generated/index" +export type { + AgentApi, + AppApi, + CatalogApi, + CommandApi, + EventApi, + IntegrationApi, + ModelApi, + PluginApi, + ProviderApi, + ReferenceApi, + SessionApi, + SkillApi, +} from "./api.js" export { Service } from "./service.js" -export type { Transport, ServiceOptions } from "./service.js" export { Agent } from "@opencode-ai/schema/agent" export { Command } from "@opencode-ai/schema/command" export { Credential } from "@opencode-ai/schema/credential" @@ -28,3 +43,4 @@ export { SessionMessage } from "@opencode-ai/schema/session-message" export { Skill } from "@opencode-ai/schema/skill" export { Prompt } from "@opencode-ai/schema/prompt" export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event" +export type OpenCodeClient = Effect.Success> diff --git a/packages/client/src/effect/service.ts b/packages/client/src/effect/service.ts index 93cbdd03fc..9c5e833ee1 100644 --- a/packages/client/src/effect/service.ts +++ b/packages/client/src/effect/service.ts @@ -16,7 +16,7 @@ export type Transport = { readonly headers?: RequestInit["headers"] } -export type ServiceOptions = { +export type Options = { // Absolute path to the service registration file. Defaults to // opencode/service.json in the XDG state directory. readonly file?: string @@ -30,44 +30,58 @@ export type ServiceOptions = { // Read-only lookup: registration file plus health check and version gate. // Never spawns; escalation to start() is the caller's policy. -export const discover = Effect.fn("service.discover")(function* (options: ServiceOptions = {}) { - const registration = yield* read(options.file) - if (registration === undefined) return undefined - if (options.version !== undefined && registration.version !== options.version) return undefined - const found = yield* probe(registration) - return found?.transport +export const discover = Effect.fn("service.discover")(function* (options: Options = {}) { + return (yield* discoverLocal(options))?.transport +}) + +const discoverLocal = Effect.fnUntraced(function* (options: Options) { + const info = yield* read(options.file) + if (info === undefined) return undefined + if (options.version !== undefined && info.version !== options.version) return undefined + return yield* probe(info, options.version) }) // Idempotent ensure-running: reuses a healthy compatible server, replaces a // version-mismatched one, and otherwise spawns the service command detached. -export const start = Effect.fn("service.start")(function* (options: ServiceOptions = {}) { +export const start = Effect.fn("service.start")(function* (options: Options = {}) { const compatible = yield* discover(options) if (compatible !== undefined) return compatible const mismatched = yield* find(options) - if (mismatched !== undefined) yield* kill(mismatched.registration, options).pipe(Effect.ignore) + if (mismatched !== undefined) yield* kill(mismatched.info, options).pipe(Effect.ignore) const [command, ...args] = options.command ?? ["opencode", "serve", "--service"] if (command === undefined) return yield* Effect.fail(new Error("Missing service command")) - yield* Effect.try({ + const child = yield* Effect.try({ try: () => { - spawn(command, args, { detached: true, stdio: "ignore" }).unref() + const child = spawn(command, args, { detached: true, stdio: "ignore" }) + child.unref() + return child }, catch: (cause) => new Error("Failed to start server", { cause }), }) - return yield* discover(options).pipe( + return yield* discoverLocal(options).pipe( Effect.flatMap((found) => found === undefined ? Effect.fail(new Error("Server is not ready")) : Effect.succeed(found), ), Effect.retry(poll), + Effect.tap((found) => + found.info.pid === child.pid + ? Effect.void + : Effect.sync(() => { + child.kill("SIGTERM") + }), + ), + Effect.map((found) => found.transport), + Effect.tapError(() => Effect.try({ try: () => child.kill("SIGTERM"), catch: () => undefined }).pipe(Effect.ignore)), Effect.mapError(() => new Error("Failed to start server")), ) }) -export const stop = Effect.fn("service.stop")(function* (options: ServiceOptions = {}) { +export const stop = Effect.fn("service.stop")(function* (options: Options = {}) { const fs = yield* FileSystem.FileSystem const existing = yield* find(options) - if (existing !== undefined) yield* kill(existing.registration, options) + if (existing !== undefined) yield* kill(existing.info, options) yield* fs.remove(options.file ?? fallback()).pipe(Effect.ignore) }) @@ -80,18 +94,22 @@ function auth(password: string): RequestInit["headers"] { return { authorization: "Basic " + btoa("opencode:" + password) } } -const Registration = Schema.Struct({ +export const Info = Schema.Struct({ id: Schema.optional(Schema.String), version: Schema.optional(Schema.String), url: Schema.String, pid: Schema.Int.check(Schema.isGreaterThan(0)), password: Schema.optional(Schema.String), }) -type Registration = typeof Registration.Type +export type Info = typeof Info.Type -const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration)) +const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info)) +const decodeHealth = Schema.decodeUnknownOption( + Schema.Struct({ healthy: Schema.Literal(true), version: Schema.String, pid: Schema.Int }), +) +const decodeLegacyHealth = Schema.decodeUnknownOption(Schema.Struct({ healthy: Schema.Literal(true) })) -// A missing or corrupt file means no valid registration; callers treat both +// A missing or corrupt file means no valid info; callers treat both // the same (the registering server self-evicts, clients rediscover). const read = Effect.fnUntraced(function* (file?: string) { const fs = yield* FileSystem.FileSystem @@ -101,31 +119,42 @@ const read = Effect.fnUntraced(function* (file?: string) { }) type LocalService = { - readonly registration: Registration + readonly info: Info readonly transport: Transport } -const probe = Effect.fnUntraced(function* (registration: Registration) { - const headers = registration.password === undefined ? undefined : auth(registration.password) - const healthy = yield* Effect.tryPromise(() => - fetch(new URL("/api/health", registration.url), { +const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLegacy = false) { + const headers = info.password === undefined ? undefined : auth(info.password) + const response = yield* Effect.tryPromise(() => + fetch(new URL("/api/health", info.url), { headers, signal: AbortSignal.timeout(2_000), }), - ).pipe( - Effect.map((response) => response.ok), - Effect.orElseSucceed(() => false), + ).pipe(Effect.option, Effect.map(Option.getOrUndefined)) + if (response === undefined || !response.ok) return undefined + const body = yield* Effect.tryPromise(() => response.json()).pipe(Effect.option, Effect.map(Option.getOrUndefined)) + const health = decodeHealth(body) + if (Option.isSome(health)) { + if (health.value.pid !== info.pid) return undefined + if (info.version !== undefined && health.value.version !== info.version) return undefined + if (version !== undefined && health.value.version !== version) return undefined + return { info, transport: { url: info.url, headers } } satisfies LocalService + } + if ( + !allowLegacy || + Option.isNone(decodeLegacyHealth(body)) || + (typeof body === "object" && body !== null && ("version" in body || "pid" in body)) ) - if (!healthy) return undefined - return { registration, transport: { url: registration.url, headers } } satisfies LocalService + return undefined + return { info, transport: { url: info.url, headers } } satisfies LocalService }) // Health-checked lookup without the version gate: lifecycle operations must be // able to see (and replace or stop) a server from a different version. -const find = Effect.fnUntraced(function* (options: ServiceOptions) { - const registration = yield* read(options.file) - if (registration === undefined) return undefined - return yield* probe(registration) +const find = Effect.fnUntraced(function* (options: Options) { + const info = yield* read(options.file) + if (info === undefined) return undefined + return yield* probe(info, undefined, true) }) // 50ms cadence bounded at ~5s, shared by stop escalation and start readiness. @@ -142,22 +171,22 @@ const stopped = Effect.fnUntraced(function* (pid: number) { return yield* Effect.fail(new Error(`Server process ${pid} is still running`)) }) -function same(left: Registration, right: Registration) { +function same(left: Info, right: Info) { return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid } -const kill = Effect.fnUntraced(function* (info: Registration, options: ServiceOptions) { +const kill = Effect.fnUntraced(function* (info: Info, options: Options) { // A stale registration may point at a PID that has since been reused by // another process. Only signal the PID after authenticating the server. const current = yield* find(options) - if (current === undefined || !same(current.registration, info)) return + if (current === undefined || !same(current.info, info)) return yield* signal(info.pid, "SIGTERM") const done = yield* stopped(info.pid).pipe(Effect.retry(poll), Effect.option) if (Option.isSome(done)) return const latest = yield* find(options) - if (latest === undefined || !same(latest.registration, info)) return + if (latest === undefined || !same(latest.info, info)) return yield* signal(info.pid, "SIGKILL") yield* stopped(info.pid).pipe(Effect.retry(poll)) }) diff --git a/packages/client/src/promise/api.ts b/packages/client/src/promise/api.ts new file mode 100644 index 0000000000..e7d1c27d25 --- /dev/null +++ b/packages/client/src/promise/api.ts @@ -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 extends ( + ...args: infer Args +) => Effect.Effect + ? (...args: Args) => Promise + : Operation extends (...args: infer Args) => Stream.Stream + ? (...args: Args) => AsyncIterable + : Operation + +type PromisifyApi = { + readonly [Name in keyof Api]: PromisifyOperation +} + +export type AgentApi = PromisifyApi> +export type CommandApi = PromisifyApi> +export type EventApi = PromisifyApi> +export type IntegrationApi = PromisifyApi> +export type ModelApi = PromisifyApi> +export type PluginApi = PromisifyApi> +export type ProviderApi = PromisifyApi> +export type ReferenceApi = PromisifyApi> +export type SessionApi = PromisifyApi> +export type SkillApi = PromisifyApi> + +export interface CatalogApi { + readonly provider: ProviderApi + readonly model: ModelApi +} diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index ee752f23ef..d3e18279e0 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -29,6 +29,8 @@ import type { SessionSkillOutput, SessionSyntheticInput, SessionSyntheticOutput, + SessionShellInput, + SessionShellOutput, SessionCompactInput, SessionCompactOutput, SessionWaitInput, @@ -87,6 +89,7 @@ import type { CredentialUpdateOutput, CredentialRemoveInput, CredentialRemoveOutput, + ProjectListOutput, ProjectCurrentInput, ProjectCurrentOutput, ProjectDirectoriesInput, @@ -171,6 +174,7 @@ import type { VcsStatusOutput, VcsDiffInput, VcsDiffOutput, + DebugLocationOutput, } from "./types" import { ClientError } from "./client-error" @@ -525,6 +529,18 @@ export function make(options: ClientOptions) { }, requestOptions, ), + shell: (input: SessionShellInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/shell`, + body: { id: input["id"], command: input["command"] }, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), compact: (input: SessionCompactInput, requestOptions?: RequestOptions) => request( { @@ -884,6 +900,11 @@ export function make(options: ClientOptions) { ), }, project: { + list: (requestOptions?: RequestOptions) => + request( + { method: "GET", path: `/api/project`, successStatus: 200, declaredStatuses: [401, 400], empty: false }, + requestOptions, + ), current: (input?: ProjectCurrentInput, requestOptions?: RequestOptions) => request( { @@ -1434,6 +1455,19 @@ export function make(options: ClientOptions) { requestOptions, ), }, + debug: { + location: (requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/debug/location`, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, } } diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index d858ded933..fbbe19ae05 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -157,7 +157,7 @@ export type ProjectCopyError = { export const isProjectCopyError = (value: unknown): value is ProjectCopyError => typeof value === "object" && value !== null && "name" in value && value["name"] === "ProjectCopyError" -export type HealthGetOutput = { readonly healthy: true } +export type HealthGetOutput = { readonly healthy: true; readonly version: string; readonly pid: number } export type LocationGetInput = { readonly location?: { @@ -865,6 +865,14 @@ export type SessionSyntheticInput = { export type SessionSyntheticOutput = void +export type SessionShellInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly id?: { readonly id?: string | undefined; readonly command: string }["id"] + readonly command: { readonly id?: string | undefined; readonly command: string }["command"] +} + +export type SessionShellOutput = void + export type SessionCompactInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } export type SessionCompactOutput = void @@ -920,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 @@ -968,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 @@ -1099,74 +1126,75 @@ export type SessionLogOutput = | ( | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.agent.switched" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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 timestamp: number - readonly sessionID: string - readonly messageID: string - readonly agent: string - } + readonly data: { readonly sessionID: string; readonly agent: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.model.switched" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number readonly sessionID: string - readonly messageID: string readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.moved" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "session.moved" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly location: { readonly directory: string; readonly workspaceID?: string } - readonly subdirectory?: string + readonly subpath?: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.renamed" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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 timestamp: number; readonly sessionID: string; readonly title: string } + readonly data: { readonly sessionID: string; readonly title: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.forked" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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 timestamp: number - readonly sessionID: string - readonly parentID: string - readonly messageID?: string - } + readonly data: { readonly sessionID: string; readonly parentID: string; readonly from?: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.prompted" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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 } + } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + 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: { - readonly timestamp: number readonly sessionID: string - readonly messageID: string + readonly inputID: string readonly prompt: { readonly text: string readonly files?: ReadonlyArray<{ @@ -1186,54 +1214,22 @@ export type SessionLogOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.prompt.admitted" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "session.context.updated" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly messageID: string - readonly prompt: { - readonly text: string - readonly files?: ReadonlyArray<{ - readonly uri: string - readonly mime: string - readonly name?: string - readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly agents?: ReadonlyArray<{ - readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } - }> - } - readonly delivery: "steer" | "queue" - } + readonly data: { readonly sessionID: string; readonly text: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.context.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number readonly sessionID: string - readonly messageID: string - readonly text: string - } - } - | { - readonly id: string - readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.synthetic" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } - readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly messageID: string readonly text: string readonly description?: string readonly metadata?: { readonly [x: string]: unknown } @@ -1241,53 +1237,73 @@ export type SessionLogOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.skill.activated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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 } + } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + 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 timestamp: number readonly sessionID: string - readonly messageID: string - readonly name: string - readonly text: 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: "session.next.shell.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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 timestamp: number readonly sessionID: string - readonly messageID: string - readonly callID: string - readonly command: 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: "session.next.shell.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number - readonly sessionID: string - readonly callID: string - readonly output: string - } - } - | { - readonly id: string - readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.step.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } - readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly agent: string @@ -1297,12 +1313,12 @@ export type SessionLogOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.step.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly finish: string @@ -1319,12 +1335,12 @@ export type SessionLogOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.step.failed" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly error: { readonly type: "unknown"; readonly message: string } @@ -1332,25 +1348,21 @@ export type SessionLogOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.text.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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 timestamp: number - readonly sessionID: string - readonly assistantMessageID: string - readonly textID: string - } + readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly textID: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.text.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly textID: string @@ -1359,12 +1371,41 @@ export type SessionLogOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.tool.input.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { + readonly sessionID: string + readonly assistantMessageID: string + readonly reasoningID: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + } + } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + 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: { + readonly sessionID: string + readonly assistantMessageID: string + readonly reasoningID: string + readonly text: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + } + } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + 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: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly callID: string @@ -1373,12 +1414,12 @@ export type SessionLogOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.tool.input.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly callID: string @@ -1387,12 +1428,12 @@ export type SessionLogOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.tool.called" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly callID: string @@ -1406,12 +1447,12 @@ export type SessionLogOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.tool.progress" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly callID: string @@ -1424,12 +1465,12 @@ export type SessionLogOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.tool.success" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly callID: string @@ -1448,12 +1489,12 @@ export type SessionLogOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.tool.failed" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly callID: string @@ -1467,41 +1508,12 @@ export type SessionLogOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.reasoning.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number - readonly sessionID: string - readonly assistantMessageID: string - readonly reasoningID: string - readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } - } - } - | { - readonly id: string - readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.reasoning.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } - readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly assistantMessageID: string - readonly reasoningID: string - readonly text: string - readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } - } - } - | { - readonly id: string - readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.retried" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } - readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly timestamp: number readonly sessionID: string readonly attempt: number readonly error: { @@ -1516,27 +1528,22 @@ export type SessionLogOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.compaction.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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 timestamp: number - readonly sessionID: string - readonly messageID: string - readonly reason: "auto" | "manual" - } + readonly data: { readonly sessionID: string; readonly reason: "auto" | "manual" } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.compaction.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number readonly sessionID: string - readonly messageID: string readonly reason: "auto" | "manual" readonly text: string readonly recent: string @@ -1544,12 +1551,12 @@ export type SessionLogOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.revert.staged" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number readonly sessionID: string readonly revert: { readonly messageID: string @@ -1568,19 +1575,21 @@ export type SessionLogOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.revert.cleared" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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 timestamp: number; readonly sessionID: string } + readonly data: { readonly sessionID: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.revert.committed" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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 timestamp: number; readonly sessionID: string; readonly messageID: string } + readonly data: { readonly sessionID: string; readonly messageID: string } } ) | { readonly type: "log.synced"; readonly aggregateID: string; readonly seq?: number } @@ -1613,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 @@ -1661,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 @@ -1794,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 @@ -1842,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 @@ -2383,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" } @@ -2411,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 +}> + export type ProjectCurrentInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined @@ -3763,49 +3821,50 @@ export type SkillListOutput = { export type EventSubscribeOutput = | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "models-dev.refreshed" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: {} } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "integration.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: {} } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "integration.connection.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly integrationID: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "catalog.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: {} } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "agent.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: {} } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.created" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -3864,9 +3923,10 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -3925,9 +3985,10 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.deleted" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -3986,9 +4047,10 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "message.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4090,17 +4152,19 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "message.removed" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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 } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "message.part.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4336,82 +4400,84 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "message.part.removed" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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; readonly partID: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.agent.switched" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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 timestamp: number - readonly sessionID: string - readonly messageID: string - readonly agent: string - } + readonly data: { readonly sessionID: string; readonly agent: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.model.switched" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number readonly sessionID: string - readonly messageID: string readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.moved" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "session.moved" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly location: { readonly directory: string; readonly workspaceID?: string } - readonly subdirectory?: string + readonly subpath?: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.renamed" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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 timestamp: number; readonly sessionID: string; readonly title: string } + readonly data: { readonly sessionID: string; readonly title: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.forked" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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 timestamp: number - readonly sessionID: string - readonly parentID: string - readonly messageID?: string - } + readonly data: { readonly sessionID: string; readonly parentID: string; readonly from?: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.prompted" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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 } + } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + 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: { - readonly timestamp: number readonly sessionID: string - readonly messageID: string + readonly inputID: string readonly prompt: { readonly text: string readonly files?: ReadonlyArray<{ @@ -4431,39 +4497,11 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.prompt.admitted" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "session.execution.settled" readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly messageID: string - readonly prompt: { - readonly text: string - readonly files?: ReadonlyArray<{ - readonly uri: string - readonly mime: string - readonly name?: string - readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly agents?: ReadonlyArray<{ - readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } - }> - } - readonly delivery: "steer" | "queue" - } - } - | { - readonly id: string - readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.execution.settled" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } - readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly timestamp: number readonly sessionID: string readonly outcome: "success" | "failure" | "interrupted" readonly error?: { readonly type: "unknown"; readonly message: string } @@ -4471,27 +4509,22 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.context.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "session.context.updated" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly messageID: string - readonly text: string - } + readonly data: { readonly sessionID: string; readonly text: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.synthetic" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number readonly sessionID: string - readonly messageID: string readonly text: string readonly description?: string readonly metadata?: { readonly [x: string]: unknown } @@ -4499,53 +4532,73 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.skill.activated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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 } + } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + 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 timestamp: number readonly sessionID: string - readonly messageID: string - readonly name: string - readonly text: 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: "session.next.shell.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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 timestamp: number readonly sessionID: string - readonly messageID: string - readonly callID: string - readonly command: 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: "session.next.shell.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number - readonly sessionID: string - readonly callID: string - readonly output: string - } - } - | { - readonly id: string - readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.step.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } - readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly agent: string @@ -4555,12 +4608,12 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.step.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly finish: string @@ -4577,12 +4630,12 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.step.failed" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly error: { readonly type: "unknown"; readonly message: string } @@ -4590,25 +4643,20 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.text.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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 timestamp: number - readonly sessionID: string - readonly assistantMessageID: string - readonly textID: string - } + readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly textID: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.text.delta" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "session.text.delta" readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly textID: string @@ -4617,12 +4665,12 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.text.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly textID: string @@ -4631,12 +4679,12 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.reasoning.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly reasoningID: string @@ -4645,12 +4693,11 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.reasoning.delta" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "session.reasoning.delta" readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly reasoningID: string @@ -4659,12 +4706,12 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.reasoning.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly reasoningID: string @@ -4674,12 +4721,12 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.tool.input.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly callID: string @@ -4688,12 +4735,11 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.tool.input.delta" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "session.tool.input.delta" readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly callID: string @@ -4702,12 +4748,12 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.tool.input.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly callID: string @@ -4716,12 +4762,12 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.tool.called" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly callID: string @@ -4735,12 +4781,12 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.tool.progress" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly callID: string @@ -4753,12 +4799,12 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.tool.success" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly callID: string @@ -4777,12 +4823,12 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.tool.failed" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly callID: string @@ -4796,12 +4842,12 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.retried" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number readonly sessionID: string readonly attempt: number readonly error: { @@ -4816,40 +4862,30 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.compaction.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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 timestamp: number - readonly sessionID: string - readonly messageID: string - readonly reason: "auto" | "manual" - } + readonly data: { readonly sessionID: string; readonly reason: "auto" | "manual" } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.compaction.delta" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly type: "session.compaction.delta" readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly messageID: string - readonly text: string - } + readonly data: { readonly sessionID: string; readonly text: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.compaction.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number readonly sessionID: string - readonly messageID: string readonly reason: "auto" | "manual" readonly text: string readonly recent: string @@ -4857,12 +4893,12 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.revert.staged" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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: { - readonly timestamp: number readonly sessionID: string readonly revert: { readonly messageID: string @@ -4881,41 +4917,43 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.revert.cleared" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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 timestamp: number; readonly sessionID: string } + readonly data: { readonly sessionID: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.next.revert.committed" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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 timestamp: number; readonly sessionID: string; readonly messageID: string } + readonly data: { readonly sessionID: string; readonly messageID: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "file.edited" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + 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 + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "reference.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: {} } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "permission.v2.asked" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly id: string @@ -4929,9 +4967,9 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "permission.v2.replied" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4941,49 +4979,57 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "plugin.added" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } 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 readonly metadata?: { readonly [x: string]: unknown } readonly type: "project.directories.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly projectID: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "command.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: {} } | { readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "config.updated" + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: {} + } + | { + readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "skill.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: {} } | { readonly id: string - readonly metadata?: { readonly [x: string]: unknown } - readonly type: "file.watcher.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } - readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly file: string; readonly event: "add" | "change" | "unlink" } - } - | { - readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "pty.created" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly info: { @@ -5000,9 +5046,9 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "pty.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly info: { @@ -5019,25 +5065,25 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "pty.exited" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly id: string; readonly exitCode: number } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "pty.deleted" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } 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: "shell.created" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly info: { @@ -5056,9 +5102,9 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "shell.exited" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly id: string @@ -5068,17 +5114,17 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "shell.deleted" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } 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: "question.v2.asked" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly id: string @@ -5095,9 +5141,9 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "question.v2.replied" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -5107,17 +5153,17 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "question.v2.rejected" - 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 requestID: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "form.created" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly form: @@ -5230,9 +5276,9 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "form.replied" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly id: string @@ -5242,17 +5288,17 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "form.cancelled" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly id: string; readonly sessionID: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "todo.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -5261,9 +5307,9 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.status" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -5288,25 +5334,25 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.idle" - 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 id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "tui.prompt.append" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly text: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "tui.command.execute" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly command: @@ -5332,9 +5378,9 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "tui.toast.show" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly title?: string @@ -5345,49 +5391,49 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "tui.session.select" - 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 id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "installation.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly version: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "installation.update-available" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly version: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "vcs.branch.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly branch?: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "mcp.status.changed" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly server: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "permission.asked" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly id: string @@ -5401,9 +5447,9 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "permission.replied" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -5413,9 +5459,9 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "question.asked" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly id: string @@ -5432,9 +5478,9 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "question.replied" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -5444,17 +5490,17 @@ export type EventSubscribeOutput = } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "question.rejected" - 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 requestID: string } } | { readonly id: string + readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.error" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID?: string | undefined @@ -5495,7 +5541,6 @@ export type EventSubscribeOutput = | { readonly id: string readonly metadata?: { readonly [x: string]: unknown } | undefined - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined readonly location?: { readonly directory: string; readonly workspaceID?: string } | undefined readonly type: "server.connected" readonly data: {} @@ -5977,3 +6022,5 @@ export type VcsDiffOutput = { readonly status?: "added" | "deleted" | "modified" }> } + +export type DebugLocationOutput = ReadonlyArray<{ readonly directory: string; readonly workspaceID?: string }> diff --git a/packages/client/src/promise/index.ts b/packages/client/src/promise/index.ts index 61bdcdf888..fd889c64e5 100644 --- a/packages/client/src/promise/index.ts +++ b/packages/client/src/promise/index.ts @@ -1,4 +1,16 @@ export * from "./generated/index" -export type { Transport } from "../effect/service.js" +export type { + AgentApi, + CatalogApi, + CommandApi, + EventApi, + IntegrationApi, + ModelApi, + PluginApi, + ProviderApi, + ReferenceApi, + SessionApi, + SkillApi, +} from "./api.js" export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types" export type OpenCodeClient = ReturnType diff --git a/packages/client/test/api.types.ts b/packages/client/test/api.types.ts new file mode 100644 index 0000000000..5b17b23c09 --- /dev/null +++ b/packages/client/test/api.types.ts @@ -0,0 +1,10 @@ +import { Effect } from "effect" +import { OpenCode as EffectOpenCode, type AppApi as EffectApi } from "../src/effect" + +type EffectClient = Effect.Success> + +declare const effectClient: EffectClient + +const effectApi: EffectApi = effectClient + +void effectApi diff --git a/packages/client/test/effect.test.ts b/packages/client/test/effect.test.ts index 445a9cf3de..2077c27ca5 100644 --- a/packages/client/test/effect.test.ts +++ b/packages/client/test/effect.test.ts @@ -1,7 +1,17 @@ import { expect, test } from "bun:test" import { DateTime, Effect, Stream } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" -import { AbsolutePath, Agent, Event, Location, Model, OpenCode, Prompt, Session, SessionMessage } from "../src/effect/index" +import { + AbsolutePath, + Agent, + Event, + Location, + Model, + OpenCode, + Prompt, + Session, + SessionMessage, +} from "../src/effect/index" const synced = { type: "log.synced" as const, aggregateID: "ses_test", seq: Event.Seq.make(1) } @@ -23,7 +33,7 @@ test("event.subscribe exposes and decodes the native Effect event stream", async HttpClientResponse.fromWeb( request, new Response( - `data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` + + `data: ${JSON.stringify({ id: "evt_connected", created: 0, type: "server.connected", data: {} })}\n\n` + `data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, { headers: { "content-type": "text/event-stream" } }, ), @@ -35,10 +45,10 @@ test("event.subscribe exposes and decodes the native Effect event stream", async return yield* client.event.subscribe().pipe(Stream.runCollect) }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) - expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.next.model.switched"]) + expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.model.selected"]) const durable = events[1] - if (durable?.type !== "session.next.model.switched") throw new Error("Expected model event") - expect(DateTime.toEpochMillis(durable.data.timestamp)).toBe(1_717_171_717_000) + if (durable?.type !== "session.model.selected") throw new Error("Expected model event") + expect(DateTime.toEpochMillis(durable.created)).toBe(1_717_171_717_000) expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 }) }) @@ -149,8 +159,8 @@ test("session methods retain decoded Effect inputs and outputs", async () => { expect(result.context).toEqual([]) expect(logQueries[0]).toEqual({ after: "0" }) const logged = Array.from(result.log) - expect(logged.map((item) => item.type)).toEqual(["session.next.model.switched", "log.synced"]) - expect(logged[0]?.type === "session.next.model.switched" && DateTime.toEpochMillis(logged[0].data.timestamp)).toBe( + expect(logged.map((item) => item.type)).toEqual(["session.model.selected", "log.synced"]) + expect(logged[0]?.type === "session.model.selected" && DateTime.toEpochMillis(logged[0].created)).toBe( 1_717_171_717_000, ) expect(logged.at(-1)).toEqual(synced) @@ -217,12 +227,11 @@ const modelSwitchedMessage = { const modelSwitchedEvent = { id: "evt_model", - type: "session.next.model.switched", + created: 1_717_171_717_000, + type: "session.model.selected", durable: { aggregateID: "ses_test", seq: 1, version: 1 }, data: { - timestamp: 1_717_171_717_000, sessionID: "ses_test", - messageID: "msg_model", model: { id: "claude", providerID: "anthropic" }, }, } diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 030b761fa6..2dbd58a858 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -30,7 +30,9 @@ test("exposes every standard HTTP API group", () => { "reference", "projectCopy", "vcs", + "debug", ]) + expect(Object.keys(client.debug)).toEqual(["location"]) expect(Object.keys(client.message)).toEqual(["list"]) expect(Object.keys(client.integration)).toEqual([ "list", @@ -151,7 +153,7 @@ test("event.subscribe exposes the Promise event stream wire projection", async ( baseUrl: "http://localhost:3000", fetch: async () => new Response( - `: heartbeat\n\ndata: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` + + `: heartbeat\n\ndata: ${JSON.stringify({ id: "evt_connected", created: 0, type: "server.connected", data: {} })}\n\n` + `data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, { headers: { "content-type": "text/event-stream" } }, ), @@ -159,8 +161,8 @@ test("event.subscribe exposes the Promise event stream wire projection", async ( const events = [] for await (const event of client.event.subscribe()) events.push(event) - expect(events).toEqual([{ id: "evt_connected", type: "server.connected", data: {} }, modelSwitchedEvent]) - expect(events[1]?.type === "session.next.model.switched" && events[1].data.timestamp).toBe(1_717_171_717_000) + expect(events).toEqual([{ id: "evt_connected", created: 0, type: "server.connected", data: {} }, modelSwitchedEvent]) + expect(events[1]?.type === "session.model.selected" && events[1].created).toBe(1_717_171_717_000) }) test("event.subscribe terminates on malformed Promise SSE data", async () => { @@ -328,12 +330,11 @@ const synced = { type: "log.synced", aggregateID: "ses_test", seq: 1 } const modelSwitchedEvent = { id: "evt_model", - type: "session.next.model.switched", + created: 1_717_171_717_000, + type: "session.model.selected", durable: { aggregateID: "ses_test", seq: 1, version: 1 }, data: { - timestamp: 1_717_171_717_000, sessionID: "ses_test", - messageID: "msg_model", model: { id: "claude", providerID: "anthropic" }, }, } diff --git a/packages/client/tsconfig.build.json b/packages/client/tsconfig.build.json new file mode 100644 index 0000000000..e235ae78cf --- /dev/null +++ b/packages/client/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "noEmit": false, + "declaration": true + }, + "include": ["src"] +} diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index 47fc90bc55..a7ef7a1fa3 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -3,6 +3,8 @@ "extends": "@tsconfig/bun/tsconfig.json", "compilerOptions": { "lib": ["ESNext", "DOM", "DOM.Iterable"], + "allowImportingTsExtensions": false, + "allowJs": false, "noUncheckedIndexedAccess": false }, "include": ["src"] diff --git a/packages/codemode/AGENTS.md b/packages/codemode/AGENTS.md new file mode 100644 index 0000000000..df812c6d86 --- /dev/null +++ b/packages/codemode/AGENTS.md @@ -0,0 +1,23 @@ +# @opencode-ai/codemode + +- This local package owns confined execution over explicit schema-described tools. Applications own authorization, persistence, external authority, and tool-specific delivery semantics. +- Do not add a speculative generic permission or approval policy. A host omits tools it does not expose and enforces domain authorization inside each provided tool. +- Keep Code Mode unaware of host session, channel, and conversation models. The hosting application supplies trusted execution scope around it. +- Tool schemas are the model-facing Interface. Keep arguments minimal and natural to the operation; never add unrelated IDs as ambient capability tokens. + +## OpenAPI + +- Generate an operation only when its transport semantics are supported; otherwise return a precise `skipped` reason. +- Never guess parameter serialization or malformed security semantics. Unsupported serialization is skipped and malformed security fails closed. +- Render unresolved schema constructs as `unknown`, never as invented TypeScript names. +- Keep network reads bounded and map expected encoding, transport, and decoding failures to model-safe `ToolError` values. +- Test supported behavior directly; do not reproduce adapter algorithms in tests. + +## Future Design Notes + +- If a captured user-visible output channel returns (an earlier `output.text`/`output.file`/`output.image` API was removed from v1), keep `output` as its name, distinct from the program return value: `return` stays the structured result for the model, while `output.*` describes artifacts the host may render into a conversation or UI after execution. Keep this host-neutral and let applications decide how captured output is delivered. In v1, hosts collect media host-side (outside the sandbox) instead. +- Improve the sandbox failure taxonomy. Distinguish parse/compile mistakes, unsupported syntax, user-thrown errors, invalid returned data, tool refusal, tool internal failure, timeout, and genuine runtime defects so agents can recover accurately instead of treating everything as a generic execution failure. +- Preserve the public/private error split. Tool authors should be able to return a safe model-visible message while retaining a private cause for host diagnostics. Unknown host failures must remain sanitized by default. +- Think deliberately about richer binary boundaries before allowing `Blob`, `File`, `ArrayBuffer`, streams, or typed arrays beyond today's JSON-like values. If CodeMode supports binary tool args/results, use explicit tagged data shapes and clear size limits rather than relying on ambient runtime serialization. +- Keep host capabilities explicit. Globals such as `fetch`, `crypto`, filesystem handles, extra modules, or network clients should be opt-in runtime capabilities with obvious policy defaults, not ambient authority. Default to unavailable unless a host deliberately provides the capability. +- If `fetch` is added, model it as a host-provided outbound capability with policy controls: allowed origins, methods, headers, response size, timeout, and whether response bodies may be returned, emitted, or only summarized through a tool. diff --git a/packages/codemode/README.md b/packages/codemode/README.md new file mode 100644 index 0000000000..0be6d769c7 --- /dev/null +++ b/packages/codemode/README.md @@ -0,0 +1,361 @@ +# @opencode-ai/codemode + +Effect-native confined code execution over explicit, schema-described tools. + +CodeMode lets a model write a small JavaScript program that can call only the tools supplied by the host. The program can sequence calls, transform plain data, branch, loop, and run independent calls in parallel without receiving ambient filesystem, process, network, module, or application authority. + +The package is currently private to this workspace. Its API is designed around one-shot and reusable execution: + +```ts +// One execution +yield * CodeMode.execute({ tools, code }) + +// A reusable runtime +const runtime = CodeMode.make({ tools, limits }) +yield * runtime.execute(code) +``` + +## Install + +Within this workspace: + +```json +{ + "dependencies": { + "@opencode-ai/codemode": "workspace:*" + } +} +``` + +Hosts interact with CodeMode through `effect` (tool `run` implementations, `Effect`-typed results), so they should depend on `effect` themselves. + +## Quick Start + +Define tools with Effect Schema, then place them in the object tree exposed to programs as `tools`: + +```ts +import { CodeMode, Tool } from "@opencode-ai/codemode" +import { Effect, Schema } from "effect" + +const lookupOrder = Tool.make({ + description: "Look up an order by ID", + input: Schema.Struct({ id: Schema.String }), + output: Schema.Struct({ id: Schema.String, status: Schema.String }), + run: ({ id }) => Effect.succeed({ id, status: "open" }), +}) + +const runtime = CodeMode.make({ + tools: { + orders: { + lookup: lookupOrder, + }, + }, +}) + +const result = + yield * + runtime.execute(` + const order = await tools.orders.lookup({ id: "order_42" }) + return { id: order.id, needsAttention: order.status !== "complete" } +`) +``` + +`result` is always a `CodeMode.Result`. Program, validation, limit, and tool failures are returned as diagnostics rather than failing the Effect. Host interruption remains interruption. + +Successful result values are JSON-safe data. A program that returns `undefined`, including by reaching the end without `return`, produces `null`; nested `undefined` values are normalized to `null` as well. + +## API + +### `Tool.make` + +```ts +const tool = Tool.make({ + description, + input, // Effect Schema (validating) or JSON Schema (render-only) + output, // optional; same choice + run, +}) +``` + +`input` and `output` each accept a validating Effect Schema or a render-only JSON Schema document (the natural shape for adapter-provided tools whose schemas arrive as JSON Schema, e.g. MCP definitions). Effect Schema input is decoded before `run` is invoked, and `run` returns the encoded representation of an Effect Schema `output`, which CodeMode decodes and copies before exposing it to the program. JSON Schemas only shape the model-visible signature; values pass through unvalidated (they still cross the plain-data boundary). + +`output` is optional. Without it the tool's signature advertises `Promise` and the host result is exposed as-is. + +The description and schemas are part of the model-visible tool contract. Keep descriptions concrete and put authorization in `run` or in the service it calls. + +Public tool types are grouped under the same namespace: `Tool.Definition`, `Tool.Options`, `Tool.SchemaType`, and `Tool.JsonSchema`. + +### `CodeMode.execute` + +Use `CodeMode.execute` for a single execution: + +```ts +const result = + yield * + CodeMode.execute({ + tools: { orders: { lookup: lookupOrder } }, + code: `return await tools.orders.lookup({ id: "order_42" })`, + limits: { maxToolCalls: 10 }, + onToolCallStart: (call) => Effect.logDebug("CodeMode tool started", call), + onToolCallEnd: (call) => Effect.logDebug("CodeMode tool settled", call), + }) +``` + +The Effect environment is inferred from the supplied tools. CodeMode does not erase service requirements introduced by tool implementations. + +### `CodeMode.make` + +Use `CodeMode.make` when the tool set and execution policy are reused: + +```ts +const runtime = CodeMode.make({ + tools: { orders: { lookup: lookupOrder } }, + limits: { timeoutMs: 30_000 }, +}) + +runtime.catalog() // structured tool descriptions +runtime.instructions() // model-facing syntax and tool guide +runtime.execute(source) // CodeMode.Result +``` + +`CodeMode.Input`, `CodeMode.Result`, `CodeMode.Success`, `CodeMode.Failure`, `CodeMode.Diagnostic`, and `CodeMode.DiagnosticKind` are both Effect schemas and their inferred TypeScript types. Hosts can combine `CodeMode.Input` and `CodeMode.Result` with `runtime.instructions()` and `runtime.execute()` when constructing a framework-specific agent tool. + +All other CodeMode types use the same namespace: `CodeMode.Options`, `CodeMode.ExecuteOptions`, `CodeMode.Runtime`, `CodeMode.ExecutionLimits`, `CodeMode.DiscoveryOptions`, `CodeMode.DataValue`, `CodeMode.ToolDescription`, and the `CodeMode.ToolCall*` observation types. + +### Results + +```ts +type Result = Success | Failure + +interface Success { + readonly ok: true + readonly value: CodeMode.DataValue + readonly logs?: ReadonlyArray + readonly truncated?: boolean + readonly toolCalls: ReadonlyArray +} + +interface Failure { + readonly ok: false + readonly error: CodeMode.Diagnostic + readonly logs?: ReadonlyArray + readonly truncated?: boolean + readonly toolCalls: ReadonlyArray +} +``` + +`toolCalls` contains the names of calls admitted by the runtime in call order. It is retained on failure so hosts can audit partial execution without exposing inputs or host failures. `truncated` is present when the value or logs were cut to fit `maxOutputBytes` (see Execution Limits). + +### Tool-call hooks + +`onToolCallStart` receives `{ index, name, input }` after input decoding and before tool execution. The input is decoded host-side data and may include values produced by schema transformations; applications should avoid logging sensitive tool arguments indiscriminately. + +`onToolCallEnd` receives `{ index, name, input, durationMs, outcome, message? }` when an admitted call settles. `outcome` is `"success"` or `"failure"`; `message` is the model-safe failure message and is present only on failure. Interrupted calls (for example when the execution timeout fires) do not produce an end event. Both hooks are Effect-returning and must not fail. + +### OpenAPI tools + +`OpenAPI.fromSpec` turns an OpenAPI 3.x document into a tool subtree - one tool per operation. Dotted `operationId` values form namespaces such as `v2.session.get`. Missing IDs receive a flat method/path fallback such as `getUsersById`; names are sanitized and deduplicated. The host places the subtree under a key in its `tools` tree; that key is the model-visible namespace. + +```ts +import { CodeMode, OpenAPI } from "@opencode-ai/codemode" +import { Effect } from "effect" +import { FetchHttpClient } from "effect/unstable/http" + +const api = OpenAPI.fromSpec({ + spec: await Bun.file("openapi.json").json(), // parsed document (no YAML) + auth: { + resolve: ({ name, scopes, operation }) => + name === "BearerAuth" ? Effect.succeed({ type: "bearer", token }) : Effect.succeed(undefined), + }, +}) + +const runtime = CodeMode.make({ tools: { opencode: api.tools } }) +const result = await Effect.runPromise(runtime.execute(code).pipe(Effect.provide(FetchHttpClient.layer))) +``` + +`fromSpec` is synchronous and returns `{ tools, skipped }`. The initial adapter supports query `form`/`deepObject`, path/header `simple`, JSON request bodies, JSON responses, and text responses; unsupported parameter encodings, non-JSON request bodies, binary responses, and streaming operations land in `skipped` instead of producing broken tools. Operation and path servers take precedence over document servers unless `baseUrl` explicitly overrides all of them. Tool inputs flatten path, query, header, and closed object-body fields into one model-facing object while retaining their HTTP locations internally. Cross-location name collisions receive a location prefix such as `path_id` and `query_id`; composed, nullable, dictionary, conditionally-required, and non-object JSON bodies remain under `body`. Auth is never model-visible. Responses are limited to 50 MiB, and non-2xx responses become safe tool failures carrying the status and a size-capped body summary. Deferred capabilities are tracked in `src/openapi/TODO.md`. + +Supported bearer, basic, header, and query authentication follows OpenAPI `security` semantics and is resolved host-side via `auth.resolve` - credential storage, OAuth flows, and token refresh never enter the compiler. Cookie authentication alternatives are discarded; an operation is skipped when it has no supported alternative. See the option docstrings in `src/openapi/types.ts` for the full semantics. Generated tools require `HttpClient.HttpClient` (from `effect/unstable/http`) in the Effect environment - provide `FetchHttpClient.layer` or a custom/test client layer at execution. The supplied client owns redirect policy; credentialed hosts should reject redirects or strip credentials when the origin changes. + +## Discovery + +The agent-tool instructions use a budgeted catalog. Every tool namespace is always listed with its tool count regardless of budget, and as many complete tool signatures (each with a one-line description) as fit an estimated-token budget are inlined. Selection is round-robin across namespaces for fairness: in each round (namespaces alphabetical), every namespace still holding un-inlined tools attempts to place its next-cheapest signature line against the shared budget, and a namespace whose next line does not fit drops out while the others keep going - so every namespace gets some representation before any namespace gets everything. The instructions state exactly how comprehensive the list is, both overall (`COMPLETE list` vs `PARTIAL - N of M shown`) and per namespace (`(3 tools)`, `(3 tools, 1 shown)`, `(3 tools, none shown)`). + +The default budget is 2,000 estimated tokens (characters / 4, the same heuristic OpenCode uses). Override it when constructing a runtime: + +```ts +const runtime = CodeMode.make({ + tools, + discovery: { maxInlineCatalogTokens: 6_000 }, +}) +``` + +The budget must be a non-negative safe integer. + +The runtime search tool is always registered - including when the catalog is fully inlined - so a speculative `tools.$codemode.search` call never fails as an unknown tool. It is only advertised in the instructions when the inlined list is partial: + +```ts +const matches = await tools.$codemode.search({ + query: "order status", + namespace: "orders", // optional: scope to one top-level namespace + limit: 10, +}) +``` + +`search` performs deterministic, additive field-weighted matching. The query is tokenized (camelCase boundaries split; every non-alphanumeric character is a separator; empties and `*` are dropped), and each term scores every tool: exact path or path-segment match (20), path substring (8), description substring (4), and searchable-text substring (2). Each term also carries naive singular variants (trailing `s`/`es` stripped), and a field check passes when the term or any variant matches - so a plural query term (`issues`) still finds a tool whose text only says `issue`, without changing the weights. The searchable text also includes the input schema's property names and their description strings, so a query naming a parameter finds its tool, and substring matching means partial words match. Scores sum across terms; matches are sorted by score (ties broken alphabetically by path) and capped at `limit` results (default 10). + +Each result contains the path, description, and generated TypeScript signature, so no second lookup is needed. The result signature is the pretty, JSDoc-annotated multiline form: each described input/output field carries its schema `description` as a `/** ... */` comment, and constraints TypeScript cannot express ride along as tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`). The inline catalog in the instructions keeps the compact single-line form. + +```ts +tools.github.list_issues(input: { + /** Repository owner */ + owner: string + /** Cursor from the previous response's pageInfo */ + after?: string + /** + * Results per page + * @default 30 + */ + perPage?: number +}): Promise +``` + +Result paths are rendered as JavaScript expressions rooted at `tools` (`tools.orders.lookup`, or `tools.context7["resolve-library-id"]` for non-identifier segments), so each `path` is directly usable as the call site. An empty query browses the catalog alphabetically by path; combined with `namespace` (`{ query: "", namespace: "orders" }`) it lists everything in that namespace. A query that names one tool path exactly (canonical path, `tools.`-prefixed path, or rendered JavaScript expression) is treated as a lookup and returns that tool alone. + +The instructions are structured markdown, ordered so the workflow sits at the top and the catalog at the bottom: a `## Workflow` section with numbered steps (find a tool via search when the catalog is partial, or pick from the inlined list when it is complete; call the exact path as-is; `JSON.parse` string results; return only the needed fields), a `## Rules` section holding only guidance the workflow does not already cover (only listed/search-result tools exist inside `tools`; filter and aggregate collections in code; treat `Promise` results as shapeless until verified; run independent calls through `Promise.all`; enumerate `tools` with `Object.keys`/`for...in`; browse a namespace via search when it is advertised), a short `## Syntax` section that assumes standard JavaScript and names only what is unusual (TypeScript annotations stripped; the data-boundary serialization of Date/Map/Set/RegExp) or missing (classes, generators, `for await...of`, `.then`/`.catch`/`.finally`), and the budgeted `## Available tools` catalog. Example call forms use explicit `.`/`` placeholders - never a real or fabricated tool name. + +A host cannot define its own `$codemode` top-level namespace. + +## Supported Programs + +CodeMode executes a deliberately bounded JavaScript subset. It supports: + +- Plain data literals, property access, assignment, and destructuring. +- `if`, conditional expressions, `switch`, `for`, `for...of` (arrays, strings, Maps, Sets), `for...in` (own keys of plain objects, index strings of arrays, and namespace/tool names of `tools` references - anything else is an error suggesting `for...of` or `Object.keys`, rather than real JS's surprising behavior of indices for strings and zero iterations for Maps/Sets), `while`, and `do...while`. +- Arrow functions and function declarations with closures, defaults, rest parameters, and destructuring. +- Optional chaining, nullish coalescing, templates, spread (arrays, strings, Maps, Sets), and `try`/`catch`. +- Common array, string, number, `Object`, `Math`, and `JSON` operations. Mutating array methods include `push`/`pop`/`shift`/`unshift`/`splice` (removes in place and returns the removed elements)/`fill`/`copyWithin`; array `keys`/`values`/`entries` return **arrays** (matching the Map/Set convention) and work with `for...of` and spread. String methods include `localeCompare` (locale/options arguments ignored), `normalize`, and the `trimLeft`/`trimRight` aliases. `Object.keys` also accepts arrays (index strings, as in JS) and tool references: `Object.keys(tools)` lists the top-level namespaces and `Object.keys(tools.ns)` the names at that node (a callable tool enumerates as `[]`; an unknown path is an `UnknownTool` diagnostic). `Object.values`/`Object.entries` on a tool reference fail with a pointer at `Object.keys(tools)` and `tools.$codemode.search`. +- `Date` - `Date.now()`/`Date.parse()`/`Date.UTC()`, `new Date(...)`, the getter methods, and date arithmetic/comparison via the time value. Dates stringify as ISO (`toString` included, for determinism across host timezones). +- Regular expressions - `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). Invalid patterns, invalid flags, and missing-`g` calls fail with catchable errors that say what was wrong and how to fix it (escaping hints, the exact `/pattern/g` to write). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout. Function replacers are not supported. +- `Map` and `Set` - construction from entries/arrays/strings, `get`/`set`/`add`/`has`/`delete`/`clear`/`size`/`forEach`, and `keys`/`values`/`entries` returning **arrays** (not iterators). +- First-class promises - an un-awaited `tools.ns.tool(...)` is a promise value whose call starts immediately on a supervised fiber; `await` resolves it (awaiting a non-promise value is a no-op, and `return tools.ns.tool(...)` resolves like an async-function return). `Promise.all`, `Promise.allSettled`, and `Promise.race` accept any array mixing promises and plain values (built inline, beforehand, or via spread); `Promise.resolve`/`Promise.reject` construct settled promises. `Promise.allSettled` rejection reasons are the same plain `{ name?, message }` data a `catch` binding sees, and `Promise.race` interrupts its losing in-flight calls. At most 8 tool calls run concurrently. When a program completes, still-running un-awaited calls are awaited before the execution ends; a failure from a call that was never awaited surfaces as an unhandled-rejection diagnostic. +- `throw value` and `throw new Error(message)` for explicit program failure. `Error` (and `TypeError`/`RangeError`/`SyntaxError`/`ReferenceError`/`EvalError`/`URIError`) are real constructors, callable with or without `new`; error values are plain `{ name, message }` data that additionally satisfy `instanceof Error` (a specific type matches itself and `Error`, as in JS). Every caught failure - thrown errors, interpreter runtime errors, and tool failures - is `instanceof Error` in a `catch` block; a thrown non-error value (`throw "text"`) is not, matching JS. Caught failures carry the `name` the equivalent real-JS failure would have - `JSON.parse` and invalid regex patterns produce a `SyntaxError` (satisfying `instanceof SyntaxError`), an unknown identifier a `ReferenceError`, assigning to a constant a `TypeError`, a bad `normalize` form a `RangeError`; failures with no specific analogue (including tool failures) are named `"Error"`. `instanceof` also recognizes `Date`, `RegExp`, `Map`, `Set`, `Array`, `Object`, and `Promise`; any other right-hand side is a catchable error. + +Inside a program, Date/RegExp/Map/Set values stay live everywhere: the internal data checkpoints (`Object.*` helpers, spread, coercion inputs) preserve the instances, so `Object.values({ d: date })[0].getTime()` and a spread copy of an object holding a Map keep working. Only at the host boundary (final result, tool arguments, `JSON.stringify`) do the four value types serialize exactly as `JSON.stringify` would: a Date becomes its ISO string (`null` when invalid) and RegExp/Map/Set become `{}`. Promise values never cross a data boundary: an un-awaited promise in a result or tool argument produces a diagnostic that says to await it, instead of serializing to `{}`. + +It does not expose `eval`, dynamic imports, modules, classes, generators, timers, host globals, prototype mutation, custom promise constructors (`new Promise`), promise chaining (`.then`/`.catch`/`.finally` - `await` with `try`/`catch` is the supported style), or arbitrary method calls. Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source location when available. + +CodeMode is an orchestration language, not a general JavaScript runtime. + +## Execution Limits + +The limits are exactly three knobs: + +| Limit | Default | Bounds | +| ---------------- | -------------------: | -------------------------------------------------------------------- | +| `timeoutMs` | none - no timeout | Wall-clock execution time. | +| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. | +| `maxOutputBytes` | none - no truncation | Model-facing output: the serialized result value plus captured logs. | + +No limit has a default, on purpose: execution budgets are host policy, not library policy - a host that wants a bound sets one; a host that can interrupt the execution fiber (as OpenCode does on user cancel) may set no timeout, and a host with its own tool-output truncation (as OpenCode has) may leave `maxOutputBytes` unset. A host with neither should set `maxOutputBytes`, or oversized results silently flood model context. + +Pass only the overrides you need: + +```ts +const runtime = CodeMode.make({ + tools, + limits: { + maxToolCalls: 20, + timeoutMs: 60_000, + }, +}) +``` + +Limits are safe integers. `timeoutMs` must be at least `1`; the others may be `0`. Invalid configuration throws a `RangeError` when `CodeMode.make` or `CodeMode.execute` is called. An explicitly `undefined` value is the same as leaving the limit unset. + +Exceeding a configured `maxOutputBytes` never fails the execution. An oversized result value is replaced by its truncated serialized text plus an explanatory marker, logs are kept from the start until the remaining budget is exhausted (with a final marker line noting the cut), and the result carries `truncated: true`. + +When configured, the timeout interrupts in-flight tool Effects, including eagerly started calls the program has not awaited (their fibers are supervised by the execution). The interpreter yields cooperatively between steps, so the timeout also interrupts pure busy loops (`while (true) {}`) - no separate work budget exists. Tool implementations remain responsible for making their external operations interruptible or independently bounded. + +Two interpreter internals are fixed constants rather than knobs: at most 8 tool calls run concurrently, and values crossing a data boundary may nest at most 32 levels deep (deeper values fail as `InvalidDataValue`, which reads better than a native stack-overflow error). Neither is part of the public contract. + +## Diagnostics + +Failures are data: + +| Kind | Meaning | +| ----------------------- | -------------------------------------------------------------------------------------------------------- | +| `ParseError` | Source is empty or cannot be parsed. | +| `UnsupportedSyntax` | Parsed JavaScript is outside the supported subset. | +| `UnknownTool` | A program referenced a tool the host did not provide. | +| `InvalidToolInput` | Tool input failed schema decoding or safe-data copying. | +| `InvalidToolOutput` | Tool output failed schema decoding or safe-data copying. | +| `InvalidDataValue` | Program data violated the plain-data contract (depth, circularity, blocked properties, non-data values). | +| `ToolCallLimitExceeded` | Calls exceeded `maxToolCalls`. | +| `TimeoutExceeded` | Execution exceeded `timeoutMs`. | +| `ToolFailure` | A tool refused or failed. | +| `ExecutionFailure` | The program threw or another execution error occurred. | + +Unknown host failures, defects, invalid outputs, and copying failures are sanitized. To return a safe operational refusal, fail with `toolError`: + +```ts +import { toolError } from "@opencode-ai/codemode" + +run: ({ id }) => (authorized(id) ? loadOrder(id) : Effect.fail(toolError("Order is unavailable"))) +``` + +Only the supplied message is model-visible. The optional cause is never returned in `CodeMode.Result`; hosts should perform any required internal logging before crossing this boundary. + +## Authority Boundary + +CodeMode confines programs to the supplied tool tree, but it does not decide what those tools may do. + +The host owns: + +- Authentication and authorization. +- Tool selection and immutable scope. +- Credentials and network clients. +- Persistence, idempotency, approval, and durable side effects. +- Logging and redaction policy. + +CodeMode owns: + +- Parsing and interpreting the supported subset without `eval`. +- Schema boundaries around tool calls. +- Plain-data copying and blocked prototype members. +- Resource limits, call accounting, and normalized diagnostics. +- Model-facing tool discovery and instructions. + +A program cannot gain authority through prose or generated code. It can only exercise authority already present in the supplied tools. Do not expose a broad tool and expect the prompt to restrict it. + +## Laws + +The public contract is guided by these equivalences: + +- `CodeMode.execute({ ...options, code })` is equivalent to `CodeMode.make(options).execute(code)`. +- A tool implementation is not invoked unless its input has decoded successfully. +- A tool result is not visible to the program unless its output has decoded and crossed the plain-data boundary successfully. +- Unknown host failures do not become model-visible diagnostics; `ToolError` is the explicit safe-message channel. +- Host interruption remains interruption rather than a `CodeMode.Failure`. + +## Non-Goals + +- Generic permission prompts or approval workflows. +- Durable pause/resume, replay, or storage adapters. +- Exactly-once external side effects. +- Application authorization or product policy. +- A filesystem or process sandbox for arbitrary JavaScript. +- Compatibility with the full JavaScript language or npm ecosystem. + +Applications that need approval or durable consequences should model those above CodeMode and expose only the currently authorized tools. + +## Testing + +From the package directory: + +```sh +bun test +bun run typecheck +``` + +The direct suite covers public projections, discovery, schema boundaries, diagnostic sanitization, resource limits, tool-call observation, and interruption. diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md new file mode 100644 index 0000000000..a94f1d4eec --- /dev/null +++ b/packages/codemode/codemode.md @@ -0,0 +1,1225 @@ +# CodeMode - Status, Decisions, and Remaining Work + +This document is the working plan for `@opencode-ai/codemode` and its OpenCode integration. +It captures every locked decision, everything already implemented, and a detailed TODO of what +remains - enough context that someone (human or agent) can pick up any item cold. + +Tracking issue: https://github.com/anomalyco/opencode/issues/34787 +Working branch: `codemode-v2` (base: `dev`) + +--- + +## 1. What this is + +CodeMode gives a model one `execute` tool that runs JavaScript/TypeScript programs against a +tree of schema-described tools (`tools..(input)`), instead of exposing dozens +of MCP tools individually. The point is **control flow**: sequencing, filtering, and composing +tool calls in one program instead of round-tripping through the agent loop, plus not flooding +the context window when users connect many MCP servers. + +Architecture split (locked): + +- **`packages/codemode` (`@opencode-ai/codemode`)** - the generic, host-agnostic runtime: + a hand-rolled, Effect-native, tree-walking interpreter over acorn ASTs (TypeScript stripped + via `typescript`'s `transpileModule`), the tool runtime/data boundary, discovery/search, and + `Tool.make`. It knows nothing about OpenCode, MCP, permissions, or rendering. +- **`packages/opencode`** - the OpenCode integration: an MCP adapter that converts MCP tool + definitions into `Tool.make(...)` definitions, permission gating, host-side attachment + collection, the agent-facing `execute` tool, and TUI progress rendering. + +This package was seeded from the experiments workspace implementation +(`experiments/agents/packages/codemode`, package `@agents/codemode`) and then modified here. +The older vendored interpreter in `packages/opencode/src/session/rune/` was superseded by this +package and was **deleted** in Wave 3 (done, see below). + +--- + +## 2. Locked decisions + +From issue #34787 and design discussion. Do not relitigate these casually. + +### Core direction + +- Generic CodeMode lives in its own package: `@opencode-ai/codemode` (repo scope convention; + the issue's `@opencode/codemode` name was normalized to the `@opencode-ai/*` convention). +- **Keep the hand-rolled interpreter.** No QuickJS/V8/sandbox-engine dependency. We own and + test the whole surface; the model only needs orchestration syntax, not a full runtime. +- Naming: `CodeMode`, `Tool`, `ToolError`, `UnknownTool` (diagnostic kind), `$codemode` + reserved discovery namespace. (Historical names - "rune", "capability" - are dead.) +- Existing OpenCode core tools (bash/edit/patch/...) stay registered normally for v1. + CodeMode covers MCP tools, user-registered tools, and deferred tools only. +- Test runner is `bun test`; typecheck is `tsgo --noEmit` (repo conventions). Not vitest. +- **Never reference external prior-art implementations** (other companies' code-execution + products/blog posts) in code, comments, commit messages, or docs in this repo. + +### MCP / tools + +- The MCP adapter lives in OpenCode, not here. It converts MCP definitions into ordinary + `Tool.make(...)` definitions and hands CodeMode a plain tool tree. +- Permissions stay in the OpenCode adapter (each tool's `run` wraps the permission ask). + CodeMode stays dumb - no permission model in this package. +- Namespace collisions: last write wins (plain JS object override). No `tools.mcp.*` prefix, + no `_2` suffixing, no cleverness. OpenCode groups flat `server_tool` MCP names into + `tools..` namespaces before handing them over. + +### Discovery / search + +- **Search only - no separate `describe`.** `tools.$codemode.search({ query?, namespace?, +limit? })` over the final tool tree, owned by this package. +- Search result item shape: `{ path, description, signature }` in an `{ items, total }` + wrapper. The `signature` string embeds the full input/output TypeScript types - in search + results it is the pretty, JSDoc-annotated multiline form (Fix 7), so per-field schema + `description`s and constraints (`@default`, `@format`, `@deprecated`, `@minItems`, + `@maxItems`) ride along as field comments. The original spec's separate `input`/`output` + raw-schema fields are deliberately NOT added: shapes are already fully expressed in the + TypeScript signature and schema annotations now arrive as JSDoc - intent satisfied, letter + deviated. Result `path`s render a JavaScript expression rooted at `tools` (for example + `tools.github.list_issues` or `tools.context7["resolve-library-id"]`) so each is directly + usable as the call site; the internal `ToolDescription.path` stays unprefixed. +- Default limit: **10** (done). Exact-path lookup goes through search too: a query equal to a + canonical tool path, `tools.`-prefixed path, or rendered JavaScript expression returns that + tool alone (done). +- Signatures render **native payloads**: `Promise`, NOT `Promise>`. + There is no result envelope; attachments never appear in return types (they are collected + host-side, see below). +- Tools without an output schema render `unknown` as their return type. + +### Schemas / Tool.make + +- `Tool.make` carries rich metadata so search can render real signatures. +- Support **Effect Schema** (first-class, validating) and **JSON Schema** (initially + render-only - used for TypeScript rendering; the adapter may validate on its own). Leave + room for Standard Schema later. +- Tool implementations are **Effect-based** for v1 (`run` returns `Effect`). Promise + normalization for plugin authors can come later. + +### Attachments / output + +- **No `output.text/file/image` API in v1.** (Deleted in Wave 2.) +- Tool calls return native structured payloads into the sandbox. Files/images emitted by + child tools **never enter the sandbox** - the OpenCode adapter strips and accumulates them + host-side as calls happen, then returns them on the outer `execute` tool result as ordinary + tool-result attachments (OpenCode already has `Tool.ExecuteResult.attachments` -> vision + plumbing in `message-v2.ts`). +- No base64 in CodeMode values, ever. The model routes nothing; it can't accidentally dump + image bytes into context or drop attachments. + +### Runtime behavior + +- Limits are EXACTLY the three public knobs: `{ timeoutMs, maxToolCalls, maxOutputBytes }` - + matching the original locked spec exactly. NO limit has a default (user direction, Fix 6 + for the first two; extended to `maxOutputBytes` in the truncation-layering fix below): + absent = no timeout / unlimited calls / no output truncation - budgets are host policy. + A host without its own output bounding should set `maxOutputBytes` explicitly, or + oversized results silently flood model context. OpenCode's adapter policy (user + direction): NO limits at all - no timeout, unlimited tool calls (each child call is + permission-gated; user cancel interrupts the execution fiber and its children), and no + CodeMode truncation (output bounding is OpenCode's native tool-output truncation). + The internal limit system that Wave 2 kept behind + an `@internal` `InternalExecutionLimits` type (maxOperations, maxDataBytes, maxValueDepth, + maxCollectionLength, maxSourceBytes, maxAuditBytes, maxConcurrency) was deleted outright in + Fix 5 (see Post-wave fixes). Two internals survive as fixed constants, not knobs: + `TOOL_CALL_CONCURRENCY = 8` (the fork semaphore) and `MAX_VALUE_DEPTH = 32` (the `copyIn` + boundary depth check, kept only because it beats a native stack-overflow RangeError as an + error message; still reports `InvalidDataValue`). +- Truncation layering RESOLVED (user direction): CodeMode truncation is off in OpenCode. + `execute` is a normal `Tool.define` tool, so OpenCode's native tool-output truncation + (50KB / 2000 lines in `tool.ts` + `truncate.ts`, full output dumped to a file) applies to + it with no special-casing - verified by tracing `wrap()` in `tool.ts:130-144` (the + `metadata.truncated` exemption never fires for `execute`). One truncation layer, the + host's. `maxOutputBytes` remains available for hosts without their own bounding. +- Pure-JS built-ins only. **No ambient authority**: no fs, child processes, network/fetch, + process/env, or timers in v1. The agent has the bash tool for that. +- Forgiving JS semantics are locked (see section 3, Wave 1a/1b-i) - missing props read `undefined`, + `typeof` never throws, NaN/Infinity flow in-sandbox, etc. +- `console.*` is captured into `logs` on the result; the host appends them to model-facing + output. Not a tool call; costs no tool budget. +- Simple tool-call **start/end hooks** for nested progress: `onToolCallStart({ index, name, +input })` and `onToolCallEnd({ index, name, input, durationMs, outcome, message? })`. + Interrupted calls fire no end event. No `CurrentToolCall` context service (removed in + Wave 2). + +--- + +## 3. Current status (what is already done on `codemode-v2`) + +Everything below is committed and pushed on `codemode-v2` (six commits, in pairs of +generic-package + OpenCode-integration: waves 0-5, Fixes 4-9, then the DSL-expansion pass / +real-JS error names / truncation layering). Verification: from `packages/codemode`, +`bun test` (211 pass / 0 fail across `codemode/parity/stdlib/promise/enumeration/signature`) +and `bun run typecheck`; from `packages/opencode`, `bun run typecheck` and +`bun test test/tool/` (all green - the adapter suites are `test/tool/code-mode.test.ts`, +43 tests, and `test/tool/code-mode-integration.test.ts`, 16 tests, moved from +`test/session/` by the registry promotion; registry coverage in +`test/tool/registry.test.ts`). + +### Wave 0 - scaffold (done) + +- `packages/codemode` created from the experiments implementation: `src/{index,codemode,tool, +tool-error,tool-runtime}.ts`, README, AGENTS.md, tests. +- `package.json`: name `@opencode-ai/codemode`, deps `acorn@8.15.0`, `typescript: catalog:`, + `effect: catalog:` (both repos pin effect `4.0.0-beta.83`; opencode's effect patch only + touches `unstable/httpapi`, which this package doesn't use). +- Tests converted vitest -> `bun:test`. Only src change from verbatim: the `CurrentToolCall` + Context.Service key string renamed to `@opencode-ai/codemode/CurrentToolCall`. + +### Wave 1a - forgiving JS semantics (done) + +Ported from the old opencode rune work; `test/parity.test.ts` (24 tests) is the acceptance +spec. The seeded interpreter was deliberately strict; these behaviors replaced that: + +- **H1**: NaN/Infinity flow as in-sandbox values (`copyIn` admits them; `NaN`/`Infinity` are + bindable globals; `charCodeAt` returns real NaN). Normalized to `null` only at the data + boundary (`copyOut` - single chokepoint for final results AND tool-call arguments), matching + `JSON.stringify`. Guards like `Number.isNaN(x)` / `parseInt(x) || 0` work. +- **H2/H3**: unknown property reads on strings/numbers/arrays -> `undefined` (incl. under + `?.`), instead of throwing. This was the real-transcript failure: models write + `result?.login ?? result` against JSON-string tool results. +- **H4**: `typeof undeclaredIdentifier` -> `"undefined"` (short-circuits before resolution). +- **H5**: `Boolean`/`String`/`Number` accepted as array callbacks (`filter(Boolean)`). +- **H6**: `{...null}` / `{...undefined}` object spread is a no-op. Array spread of + null/undefined still throws (real JS throws too). + +### Wave 1b-i - stdlib value types: Date, RegExp, Map, Set (done) + +`src/values.ts` holds `SandboxDate/SandboxRegExp/SandboxMap/SandboxSet` (own module so both +`codemode.ts` and `tool-runtime.ts` import without a cycle). Design: + +- Opaque-by-default: all four join `isRuntimeReference`, with explicit carve-outs (member + access allowlists, Date in binary/unary ops, Map/Set in spread/for...of, console formatting, + `containsOpaqueReference` for operator guards; the `runtimeValueBytes` byte-accounting + carve-out died with that machinery in Fix 5). +- **JSON semantics at every boundary and checkpoint**: Date -> ISO string (invalid -> null), + RegExp/Map/Set -> `{}`. `copyIn` also converts host `Date`/`RegExp`/`Map`/`Set` instances the + same way (a host tool may legitimately return them). (Narrowed by the DSL-expansion pass: + intra-sandbox checkpoints now preserve the instances; JSON forms apply at the host + boundary only.) +- Date: `Date.now/parse/UTC`, `new Date(epoch|string|components)`, getters + UTC variants, + `end - start`, `a < b`, `+date`; `toString` is ISO for cross-host determinism. +- RegExp: literals + `new RegExp`, `test`/`exec` (stateful `lastIndex` for `g`), string + `match/matchAll/replace/replaceAll/split/search`. Match results are plain arrays carrying + `index`/named `groups` as own properties (enabled by a general array own-property read fix); + `input` omitted deliberately. Function replacers unsupported (clear error). Patterns run on + the host engine - catastrophic backtracking is bounded only by `timeoutMs` (accepted, in + README). +- Map/Set: full method sets; `keys/values/entries` return **arrays** (not iterators); + `for...of` + spread work; `Object.fromEntries(map)`, `Array.from(map|set)`; SameValueZero + keys (NaN findable). (The incremental byte totals and `maxCollectionLength`/`maxDataBytes` + enforcement this wave added were deleted in Fix 5.) +- Rode along, same spirit: `typeof` never throws for any value (`typeof fn` -> `"function"`), + `!` works on any value, `for...of` over strings, `{...sandboxValue}` no-op, template + interpolation renders `/regex/` and ISO dates directly. + +### Wave 2 - API layer (done) + +The package's public contract, reshaped for the Wave 3 adapter. 101 tests / 0 fail after this +wave; both packages typecheck clean. + +- **`Tool.make` schema flexibility** (`src/tool.ts`): `input`/`output` each accept an Effect + Schema (validating, decoded both directions as before) OR a raw JSON Schema document + (render-only - no validation, values pass through; rendering handles `$defs`/`definitions` + - `$ref`). `output` is **optional** -> signature renders `Promise` and the host + result is exposed as-is. Discrimination via `Schema.isSchema`. New helpers exported from + `tool-schema.ts`: `inputTypeScript`/`outputTypeScript`/`decodeInput`/`decodeOutput`/ + `jsonSchemaToTypeScript`; `tool-runtime.ts` consumes them (no direct `Schema.*` use there + anymore). Types `Tool.JsonSchema`/`Tool.SchemaType` exported from the index. Note: an empty + `Schema.Struct({})` renders as `{ } | Array` (effect's JSON Schema emission) - + cosmetic, fixed in Wave 4. +- **`output.*` API deleted**: `OutputItem`(+Schema), result `output` fields, the `output` + global/namespace dispatch, `invokeOutput`/`outputItem`/helpers, interpreter output fields, + instructions line, README section, seeded tests. AGENTS.md keeps a rephrased + future-design note (channel name stays `output` if it ever returns). +- **Hooks**: `CurrentToolCall` removed entirely (class, provideService, `Services` Exclude + special-casing, index export). `onToolCall` -> `onToolCallStart({ index, name, input })` + + `onToolCallEnd({ index, name, input, durationMs, outcome: "success"|"failure", message? })`. + End fires symmetrically via `Effect.tap`/`tapError` around the settling portion (host run + + output decode + boundary copy; search too - its post-record body is wrapped in `Effect.try` + 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 `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. +- **`maxOutputBytes` truncation** (CodeMode-owned, never fails): applied via `boundOutput` in + a final `Effect.map` over every result path (success/timeout/normalized failure). Oversized + 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 + `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 + trimmed query equal to one tool path (optionally `tools.`-prefixed) returns that tool alone + (`total: 1`), bypassing ranking. Tokenization/ranking/shape unchanged. + +### Wave 3 - OpenCode MCP adapter (done) + +`packages/opencode/src/session/code-mode.ts` rewritten as a thin adapter over this package; +the vendored rune interpreter is gone. Same `define(mcpTools, mcpDefs, servers)` signature, so +`tools.ts` gating (flag on + MCP tools exist -> single `execute` tool, early-return suppresses +per-MCP registration; MCP resource tools unaffected) is unchanged. + +- **Tool tree**: `groupByServer` (longest-sanitized-prefix, ported) groups flat `server_tool` + keys into `CatalogEntry`s carrying the raw MCP `inputSchema`/`outputSchema` as render-only + JSON Schema; `toolTree` turns each into `Tool.make({ description, input, output?, run })` + under `tools..`. The agent-facing description is + `CodeMode.make({ tools }).instructions()` over a preview tree (placeholder runs, never + invoked) - so signature rendering, the inline-vs-search switch, and `$codemode.search` + availability all come from this package and stay consistent with execution. +- **`run` path**: per-child permission ask first (`ctx.ask({ permission: entry.key, patterns: +["*"], always: ["*"] })`, exactly the old gating; approving `execute` approves no child). + Denials and host failures are mapped to `toolError(message)` so they surface as safe, + catchable in-program failures (MCP `isError` text propagates as `e.message`; without this + they'd be sanitized to "Tool execution failed"). Dispatch reuses the ai-sdk wrapper from + `catalog.convertTool` (`entry.tool.execute!`), which owns callTool timeouts/progress-reset. +- **Result shaping** (`toSandboxResult`): prefer `structuredContent`; else joined text + content; media (image/audio/resource blob/resource_link) NEVER enters the sandbox - blocks + are stripped into a per-execution `Attachment[]` accumulator, and a media-only result + becomes a marker payload (`"[1 image attached to the result]"`, noun/count adjusted). An + MCP-shaped result with nothing extractable becomes `null`; non-MCP values pass through. + No handles, no `Result` envelope, no base64 in the sandbox, no data-size tuning (the + `maxDataBytes` budget that existed at the time was deleted in Fix 5). +- **Execute result**: `{ output: formatValue(value) + trailing "Logs:" section (success AND +error - logs are plain pre-formatted lines now), attachments: accumulated }` through the + existing `Tool.ExecuteResult.attachments` -> `message-v2.ts` vision plumbing; attachments + ride on both success and error results. Diagnostic `suggestions` not already contained in + the message are appended to error output. Native outer truncation stays on (adapter never + sets `metadata.truncated`); CodeMode's own `maxOutputBytes` (32 KB default at the time) + cut first - since the truncation-layering fix, native truncation is the only layer. + Limits: `{ timeoutMs: 30_000 }` at the time (matched the default MCP request timeout); + killed in Fix 6 - the adapter now passes no limits at all. +- **Progress**: `onToolCallStart`/`onToolCallEnd` -> `ctx.metadata({ toolCalls })` with + `{ tool, status: running|completed|error, input? }` per call index - the exact shape the + TUI `Execute` component (`packages/tui/src/routes/session/index.tsx`) already renders. + `$codemode.search` calls stream through the same channel. +- **Deletions/deps**: `src/session/rune/` (all five files) and + `test/session/rune-parity.test.ts` (superseded by this package's `test/parity.test.ts`) + deleted; `acorn` removed from opencode deps, `typescript` moved back to devDependencies, + `"@opencode-ai/codemode": "workspace:*"` added; `bun install` run (lockfile updated). +- **Tests**: both opencode suites rewritten against the adapter design - + `code-mode.test.ts` (34: grouping, description/signature rendering incl. the large-catalog + search fallback, execution, permission flow + denial, metadata streaming, attachment + accumulation + media-only marker, logs on success/error, truncation marker, + `toSandboxResult`/`formatValue`/`withLogs` units) and `code-mode-integration.test.ts` + (16: real in-memory MCP server; native structured results, attachment accumulation, isError + propagation, logs, permissions, live metadata). Old envelope/attachment-handle/`$rune` + describe/`renderType`/`rankTools` tests died with the old design (58+17+24 -> 34+16). + +### Wave 4 - instructions/prompting + polish (done) + +Instructions are now the budgeted-catalog + prompting-guidance form; verified e2e against a +real MCP config. Package still 101 tests / 0 fail; opencode adapter suites still 34 + 16; both +packages typecheck clean. + +- **Budgeted catalog** (`discoveryPlan` in `tool-runtime.ts`): the all-or-nothing + 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 + `describe()` `PREVIEW_BUDGET` algorithm, adapted to `ToolDescription`: every namespace is + ALWAYS listed with its tool count; full signature lines + (` - // `) are inlined + cheapest-first (line byte length, path tiebreak) within each namespace, namespaces processed + alphabetically; once one line does not fit, inlining stops for every remaining namespace + (counts only), exactly like the ported algorithm (this stop-everything behavior was later + replaced by round-robin fairness in Fix 8). The header states comprehensiveness + precisely: "Available tools (COMPLETE list - ...)" vs "Available tools (PARTIAL - N of M + shown; find the rest with tools.$codemode.search)"; namespace labels are `(N tools)` / + `(N tools, K shown)` / `(N tools, none shown)`. An empty tree renders "No tools are + currently available." +- **Search always registered** (documented decision): `DiscoveryPlan.searchIndex` is required + and built unconditionally (new exported `ToolRuntime.searchIndex(tools)`; `SearchEntry` type + exported); `CodeMode.execute` (one-shot) passes it too, preserving the + `execute`==`make().execute` law. A speculative `tools.$codemode.search` call on a small + catalog now succeeds instead of `UnknownTool`, and unknown-tool suggestions always point at + search. Search is _advertised_ in the instructions only when the inlined list is PARTIAL, + keeping small-catalog instructions tight. +- **Prompting content** in `instructions()`, mapping 1:1 to the section 5 transcript failures: + parse-string-results-as-JSON, return-small, console-for-intermediates, and + 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-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`). +- **Tests**: 4 package discovery tests rewritten for the budgeted behavior (COMPLETE small + catalog + search-still-registered; PARTIAL at budget 0; cheapest-first selection + + per-namespace labels + budget-exhaustion stopping later namespaces; mode-validation + assertion dropped); 3 opencode description assertions updated (COMPLETE/PARTIAL headers, + namespace labels, `(input: {})` rendering, cheapest-first op_0 shown / op_149 not). +- **E2E (verified, headless)**: from the repo root with `OPENCODE_EXPERIMENTAL_CODE_MODE=1`, + the scratch `.opencode/opencode.jsonc` (context7, github, playwright, sentry, memory, + sequential-thinking; left uncommitted/as-is), and `bun packages/opencode/src/index.ts run +--dangerously-skip-permissions -m opencode/claude-sonnet-4-5 "..."`. Confirmed: a single + `execute` tool registered alongside core tools (per-MCP registration suppressed; MCP + resource tools unaffected); the live description read back as "Available tools (PARTIAL - + 56 of 88 shown; find the rest with tools.$codemode.search):" with correct per-namespace + labels (context7/github/memory fully shown; playwright/sentry/sequential-thinking "none + shown" - the alphabetical-exhaustion starvation Fix 8 later replaced with round-robin + fairness); programs executed with in-program `$codemode.search` + calls and returned the correct answer. NOT verified e2e (headless only; covered by + unit/integration tests instead): TUI child-call rendering, attachments becoming visible + images, output truncation. + +### Wave 5 - Promise generalization (done) + +First-class promise values in the interpreter; the direct-tool-call-only `Promise.all` +restriction (and its bespoke AST checks) is gone. Package suite is 136 tests / 0 fail (35 new +in `test/promise.test.ts`); adapter suites and both typechecks unchanged/green; the opencode +adapter needed **no changes**. + +- **Decision: eager fork** (`const p = tools.a.b(x)` starts the call immediately on a + supervised child fiber; `await p` observes its settlement). Chosen over lazy because: + (1) it's spec-faithful - JS promise work starts at call time, so + `const a = t1(); const b = t2(); return [await a, await b]` gets real parallelism instead of + silently sequential awaits; (2) run-once is free - a fiber settles exactly once and + `Fiber.await` is idempotent, so `await p` twice or `Promise.all([p, p])` can never re-invoke + the tool (lazy needs a deferred/latch to match); (3) effect's structured concurrency does the + hard part - `Effect.forkChild` children are auto-supervised (interrupted when the parent + fiber exits) and `Effect.timeoutOrElse` is `raceFirst`, which runs the program on its own + raced fiber, so forked calls cannot escape the timeout (tested: in-flight forks are + interrupted, awaited or abandoned, direct or inside `Promise.all`). +- **Mechanics**: `SandboxPromise` in `values.ts` (fiber-backed for tool calls; fiberless + `immediate` effect for `Promise.resolve`/`reject`). Forks run + `semaphore.withPermit(invoke)` with `startImmediately: true` - a per-execution + `Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY)` (fixed 8, see Fix 5) caps live calls (the + "Effect.all or equivalent" cap lives where the work is, so combinator joins can be + sequential without losing parallelism), and the tool-call-count charge (`recordCall`) plus + `onToolCallStart` fire at the call site before any await. `await` of a non-promise is a passthrough no-op; a returned + top-level promise resolves like an async-function return (`return tools.a.b(x)` works + without await). +- **Promise combinators are normal functions over values**: `Promise.all`/`allSettled`/`race` + accept any array (or spreadable collection) mixing promises and plain data - inline, built + beforehand, spread, nested in variables. `allSettled` yields + `{ status: "fulfilled", value } | { status: "rejected", reason }` with reasons produced by + the same `caughtErrorValue` helper the `catch` binding uses (factored out of + `evaluateTryStatement`). `race` resolves/rejects with the first settlement and interrupts + losing in-flight calls; awaiting an interrupted loser afterwards is a catchable program + failure ("interrupted because another value settled a Promise.race first"), while any other + interrupt-only settlement keeps propagating as interruption (preserving the + host-interruption law). `Promise.resolve` flattens promises; `Promise.reject` rejects with + the reason via `ProgramThrow`. +- **Opaqueness/boundaries**: promises are runtime references - `typeof` -> `"object"` (real JS), + operators reject them, `copyIn` raises an await-hinting `InvalidDataValue` ("contains an + un-awaited Promise; await tool calls (...) before using their results") for results, tool + arguments, and `JSON.stringify` instead of `{}`. Property access on a promise is a + deliberate error (not the forgiving `undefined`): `.then/.catch/.finally` -> + `UnsupportedSyntax` pointing at `await` + try/catch; anything else -> "await it first". + `new Promise(...)` -> UnsupportedSyntax ("tool calls already return promises"); + `Promise.` lists the five available statics. `console.log(p)` prints + `[Promise (await it to get its value)]`. +- **Program-end drain**: on successful completion the interpreter awaits still-running + un-awaited fibers (like a runtime waiting on in-flight I/O at exit), so fire-and-forget + calls complete deterministically; a failure nobody could have handled surfaces as an + "Unhandled rejection from an un-awaited tool call: ..." diagnostic (kind preserved, + suggestion says to await) - keeping pre-wave failure visibility for un-awaited + statement-position calls. Settlement observation (await/all/allSettled/race) marks a + promise handled; failed executions skip the drain and children are interrupted by + supervision. +- **Deletions/updates**: `evaluatePromiseAll`, `evaluateParallelMap`, `isToolCallExpression`, + `isToolPath`, `forkForParallelCallback`, and `PromiseAllReference` deleted + (`PromiseMethodReference` over `all/allSettled/race/resolve/reject` replaces it); + `supportedSyntaxMessage`, the two instructions lines in `tool-runtime.ts`, and README + "Supported Programs" rewritten for the new surface. +- **Known divergences (deliberate)**: `p === q` on promises throws the operators-need-data + diagnostic instead of comparing identity; `{...promise}` errors instead of JS's silent `{}`; + a per-iteration `await` inside `items.map(async (i) => await tools.x(i))` runs sequentially + (interpreter callbacks compose synchronously) - the parallel idiom is mapping to un-awaited + calls and awaiting `Promise.all`, which the instructions show. + +### Post-wave fixes + +- **Key enumeration: `Object.keys(tools)` + `for...in` (done).** Motivating transcript: a + model tried to enumerate tool namespaces with `Object.keys(tools)` (failed with the generic + "Object.keys input must contain plain objects only." - `tools` is a `ToolReference`, not + plain data) and then `for (const key in tools)` ("Syntax 'ForInStatement' is not + supported"), and had to fall back to guessing namespace names from the instructions - + defeating discovery. Fixes, all in this package: + - `ToolRuntime.make` now returns a `keys(path)` capability (`namespaceKeys` in + `tool-runtime.ts`) threaded into the `Interpreter` alongside `invoke` - the interpreter + still never holds the host tool tree. `Object.keys(tools)` yields the top-level namespace + names (never `$codemode`, which is virtual - but `Object.keys(tools.$codemode)` yields + `["search"]`), `Object.keys(tools.ns)` the names at that node; a callable tool leaf + enumerates as `[]` (like `Object.keys` of a JS function); an unknown path throws an + `UnknownTool` diagnostic suggesting `Object.keys(tools)` and `$codemode.search` (matching + call-time unknown-tool behavior rather than silently returning `[]`). + - `Object.values`/`Object.entries` (and every other `Object.*` helper) on a tool reference + now fail with "...not plain data. Use Object.keys(tools) for names, or + tools.$codemode.search({ query }) for signatures." instead of the generic message. + - `Object.keys(array)` returns index strings (`["0", "1", ...]`) like real JS (was a + Backlog item). + - `for...in` (ForInStatement) iterates own enumerable string keys of plain objects, index + strings of arrays, and namespace/tool names of tool references - sharing the interpreter's + `enumerableKeys` helper with the `Object.keys` tool path. const/let declarations and bare + identifiers bind the key; break/continue work. Anything else (strings, Map/Set, numbers, + null, ...) is a clear error suggesting `for...of` or `Object.keys` - deliberately smaller + than real JS (which yields indices for strings and zero iterations for Maps/Sets/null). + - `supportedSyntaxMessage`, the instructions loops line, and README "Supported Programs" + mention the new surface; tests in `test/enumeration.test.ts` (14, incl. the exact + transcript program) plus one adapter-level assertion that `Object.keys(tools)` returns + MCP server names. + +- **Search ranking, namespace scoping, prefixed result paths (done).** + Motivation: the Wave 4 e2e run showed a model retrying calls because search-result paths + lacked the `tools.` prefix (a Backlog item), and the word-set ranker missed + parameter-name and partial-word queries. Fixes: + - **Ranking ported from the pre-rebuild implementation** (the `searchTextFor`/`tokenize`/ + `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-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 + separators (empties and `*` dropped). Additive per-term scoring: exact path or + path-segment match 20, path substring 8, description substring 4, searchable-text + substring 2; summed across terms, filtered to score > 0, sorted score desc then path asc + (Fix 8 later made each field check accept the term OR a naive singular variant). + An empty query now browses ALPHABETICALLY by path (was declaration order). Kept: + `{ path, description, signature }` result items, default limit 10, exact-path instant + lookup, input validation errors. + - **Namespace scoping**: `tools.$codemode.search({ query?, namespace?, limit? })` - + `namespace` (validated as a string when provided) filters `SearchEntry`s to one top-level + namespace before ranking; `{ query: "", namespace: "github" }` lists that namespace + alphabetically. `searchSignature` updated. + - **Callable result paths**: search-result `path`s are rendered as JavaScript expressions + rooted at `tools` (`tools.github.list_issues`, or bracket notation for non-identifier + segments), directly usable as the call site. Internal `ToolDescription.path` stays + unprefixed; only the search RESULT items are rendered this way. Exact-path queries accept + canonical paths and rendered expressions. + - **Instructions** (`discoveryPlan`): an explicit calling-convention line and a browse + hint on the search advertisement (both since absorbed into the `## Rules` section by + the instructions restructure below). + - **Tests**: package search/discovery tests updated (prefixed paths, alphabetical browse) + plus new coverage for namespace scoping, parameter-name matching, partial-word substring + matching, alphabetical empty-query order, and prefixed exact-path lookup; one adapter + assertion updated to the prefixed path (suites stay 35 + 16, green). + +- **Instructions restructure: markdown sections, placeholder-only call forms (done).** + The flat prose instructions (which mixed a real catalog tool with fabricated result + fields in the worked example) are replaced by structured markdown in `discoveryPlan`, + ordered so the workflow sits at the top (the least likely part of a long description to + be truncated or skimmed away) and the catalog at the bottom (the per-section content + described here was later condensed by Fix 8 - Workflow/Rules deduped, Syntax inverted): + - **Intro** (2 lines): "Write a CodeMode program... Return code only." + "Execute + JavaScript in a confined runtime with access to the tools listed below under + `tools.*`." (the second line drops the tools clause when the tree is empty). + - **`## Workflow`**: numbered steps - find a tool via `tools.$codemode.search` -> read + the `{ path, description, signature }` matches -> call by path -> `typeof res === +"string" ? JSON.parse(res) : res` -> return only the needed fields. When the catalog is + COMPLETE the search/read steps collapse into "Pick a tool from the list under + `## Available tools`" and the steps renumber (4 instead of 5). + - **`## Rules`**: call-by-exact-path; TEXT-is-JSON -> JSON.parse; return small (never raw + payloads); filter/aggregate large collections in code instead of per-item round-trips; + console.log/warn/error/dir/table for intermediates; `Promise.all` parallelism (no + .then/.catch - await + try/catch); `Object.keys(tools)`/`for...in` enumeration; + browse-one-namespace via search (PARTIAL only); and host-side media handling (files/ + images never enter the program; a media-only call yields a small text marker - wording + verified against the adapter's `toSandboxResult`/`mediaMarker`). + - **`## Syntax`**: the dense syntax lines unchanged, minus the Promise.all and console + lines (moved into Rules) and the `for (const ns in tools)` fragment (redundant with + the enumeration rule). + - **`## Available tools`**: the budgeted catalog unchanged, with the COMPLETE/PARTIAL + header merged into the section heading (no trailing colon); the search-signature + advertisement follows when PARTIAL (its description-reading and browse clauses moved + to Workflow/Rules). + - Every call form in Workflow/Rules uses explicit `.`/`` + placeholders - the example builder that derived a worked example from the first inlined + catalog tool (`exampleArguments` + the example-selection machinery) is DELETED, so no + real catalog tool is cherry-picked into examples and no fabricated names or fields + appear anywhere in the instructions. Zero tools keep "No tools are currently + available." under minimal sections (intro + Syntax + Available tools). + - **Tests**: the package worked-example test replaced by section-structure/placeholder + assertions (section order; JSON.parse + return-small rules present; no + `total_count`/`list_issues`/real-tool example lines; browse hint only when PARTIAL; + zero-tool minimal sections) - 156 pass / 0 fail; adapter suites gain the same + assertions on the built description (still 35 + 16, green). + +**Fix 4 - token-budgeted catalog (was bytes)** (user direction: signatures need a token +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. +- `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 + Fix 8). Namespace stub lines were and remain unbudgeted - every + namespace always appears with its tool count, even at budget 0 (asserted in package and + adapter tests). +- Ripple: chars/4 rounding erases small line-length differences, so equal-cost lines fall + to the lexicographic path tiebreak; the adapter's PARTIAL test now asserts the + lexicographic tail (`op_99`) is excluded instead of `op_149`. Fixed-prose measurements + (2026-07): preamble ~44 + Workflow ~146 + Rules ~362 + Syntax ~453 ~ 1,100 tokens fixed; + worst-case net description ~ fixed + 4,000 ~ 5,100 estimated tokens. + +**Fix 5 - internal limits removed** (user direction: only the three PUBLIC limits survive as +configurable knobs; the internal limit system dies): + +- `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. + `ResolvedExecutionLimits` shrank to those three fields; the `@internal` + `InternalExecutionLimits` type is deleted. +- **Deleted outright**: `maxOperations` and the whole operation-budget machinery + (`recordWork`/`recordOperation`/`budget.operations`, plus the `workUnits`/ + `cheapArrayMethods` cost helpers); `maxSourceBytes` (the pre-parse source-size check); + `maxDataBytes` (every byte-accounting path: `runtimeValueBytes`, `boundedProgramValue`, + the container-size caches (`containerSizes`/`objectCounts`), Map/Set incremental `bytes` + fields in `values.ts`, string-growth `limitString` checks, tool-argument/result byte + checks in `tool-runtime.ts`, and the final-result size check); `maxAuditBytes` (log and + audit-trail byte accounting - `toolCalls` records and the start/end hooks are unchanged); + `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 + `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 + `InvalidDataValue`). The `DataLimits` plumbing through `tool-runtime.ts` is gone - + `copyIn(value, label)` needs no limits argument, and `ToolRuntime.make` takes just + `(tools, maxToolCalls, hooks?, searchIndex?)`. +- **Verified fact**: timeout interruption does NOT depend on the operation budget - the + Effect fiber runtime auto-yields between interpreter steps, so `timeoutMs` interrupts + even a pure `while (true) {}` loop (empirically verified: a 200ms timeout fired at + ~225ms with maxOperations set to MAX_SAFE_INTEGER before the deletion). A regression + test in `codemode.test.ts` asserts exactly this (`while(true){}` + `timeoutMs: 200` -> + `TimeoutExceeded`, elapsed well under a few seconds). +- **Kept (correctness, not budgets)**: circular detection (`copyIn` walks + + `rejectCircularInsertion` on mutations), plain-objects-only, blocked properties + (`__proto__`/`constructor`/`prototype`), data-only checks, and all three public-limit + behaviors unchanged. +- Behavior deltas beyond the intended kills: in-sandbox structures deeper than 32 levels + now fail at the data boundary (`copyIn`) instead of at construction; array index + assignment allows any non-negative integer index (holes permitted, message now "must be + a non-negative integer"); interpreter-produced deep/hostile structures that overflow the + native stack during a walk still normalize to the existing "Execution exceeded the + maximum nesting depth." data diagnostic - failures remain data everywhere. +- Tests: deleted the knob-only tests (stdlib Map/Set collection-length growth x2, + 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 `CodeMode.ExecutionLimits` + (promise/enumeration/stdlib run helpers). Package suite: 154 pass / 0 fail; adapter + suites: 34 + 16. + +**Fix 6 - no default timeout / tool-call cap** (user direction): `timeoutMs` and +`maxToolCalls` lost their defaults (were 10_000 / 100) - absent now means no timeout / +unlimited calls. Budgets are host policy, not library policy; `maxOutputBytes` kept its +32,000 default at the time (removed later - see the truncation-layering entry: absent now +means no truncation). `ResolvedExecutionLimits` carries `number | undefined` for both, the +timeout wrapper is only applied when configured, and `ToolRuntime.make` treats undefined +`maxToolCalls` as uncapped. Validation is unchanged when values ARE provided (safe integers, +timeoutMs >= 1, others >= 0). The OpenCode adapter is unaffected in behavior it sets +(explicit 30s timeout) but now runs with unlimited tool calls. Immediately after, per user +direction, the adapter's 30s timeout was killed too: `CODE_LIMITS` is deleted and OpenCode +passes NO limits - no timeout, no tool-call cap. Rationale: user cancel interrupts the +execution fiber and structured concurrency takes the program and in-flight child calls down +with it; every child call is permission-gated; output truncation (32KB default) is the only +active bound. New regression test: 150 tool calls succeed with no limits configured (would +have tripped the old default 100). Package suite: 155 pass / 0 fail. + +**Fix 7 - JSDoc-annotated search signatures**: `tools.$codemode.search` result signatures are +now the pretty, indented multiline form with per-field JSDoc - ported from the pre-rebuild +rune renderer in this repo's git history (`renderType(def, { pretty })`/`docTags`/`jsdoc`/ +`renderObject`), adapted to the current renderer's conventions (`Array`, `unknown` +fallback, existing `$defs`/`$ref` handling and empty-object `{}` collapse; the old +`Result`/`returnType` machinery was deliberately not ported - payloads stay native). +Semantics: each described input/output field carries its schema `description` as a +`/** ... */` comment at the right indent (nested objects recurse deeper); constraints TS can't +express surface as JSDoc tags - `@deprecated`, `@default ` (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-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 +helpers (`toTypeScript`/`jsonSchemaToTypeScript`/`inputTypeScript`/`outputTypeScript` never +throw - pathological schemas render `unknown`); each helper takes an optional trailing +`pretty = false` parameter, so existing callers are unchanged and compact output stays +byte-identical (inline `catalogLine`s and the token budget depend on it). `SearchEntry` +gained an eagerly-computed `signature` field (built once per tool at index-build time in +`toSearchEntry` - rendering is cheap and the search hot path stays allocation-free); both +ranked results and exact-path lookups serve it. Works for both tool kinds: Effect Schema +annotations (`Schema.String.annotate({ description })`) flow through the emitted JSON +Schema, and raw JSON Schema (MCP) property metadata is read directly - both covered in +`test/signature.test.ts` (12 tests) plus one strengthened adapter assertion (MCP property +description appears as JSDoc in a live search result; the tool description/catalog contains +no `/**`). README search section updated with an example. Package suite: 167 pass / 0 fail; +adapter suites: 34 + 16. + +**Fix 8 - condensed instructions + round-robin catalog fairness + plural-aware search** +(user direction: the fixed instruction prose was too verbose; two discovery fixes ride +along). All in `tool-runtime.ts`; no interpreter changes. + +- **Syntax section inverted**: the three dense allowlist lines (~453 estimated tokens) + are replaced by four short lines (~188) built on "models already know JavaScript; name + only what is unusual or missing": (1) standard modern JS works - functions/closures, + destructuring, template literals, loops, try/catch, spread, optional chaining, the + usual Array/String/Object/Math/JSON methods, plus Date/RegExp/Map/Set and + Promise.all/allSettled/race/resolve/reject; (2) TypeScript type annotations are + stripped before execution, decorators are not supported; (3) NOT supported (each fails + with a message naming the alternative): classes, generators, for await...of, + .then/.catch/.finally (use await with try/catch), `x instanceof Error` (caught errors + are plain `{ name, message }` objects), splice; (4) the data-boundary note (Dates -> + ISO strings; Map/Set/RegExp -> `{}`). Every claim was verified against the interpreter + before writing: probed empirically - classes/generators/for-await/.then/.catch/ + .finally/`instanceof Error`/splice/decorators/BigInt/labeled statements/tagged + templates/object getters all fail with clear diagnostics; TS annotations/`as`/ + interfaces/type aliases are stripped and TS **enums actually work** (transpileModule + compiles them to an IIFE the interpreter runs), hence enums deliberately unmentioned. + `supportedSyntaxMessage` (the in-diagnostic text in `codemode.ts`) is untouched. +- **Workflow/Rules deduped**: the call-by-exact-path, JSON.parse-string-results, and + return-small content now lives ONLY in the numbered Workflow steps (with their + compliance-driving justifications inline: "most tools return JSON as a string", "raw + payloads get truncated and waste context"); Rules keeps only bullets adding new + content - filter/aggregate collections in code, console.\* intermediates (logs ride + back), Promise.all parallelism, Object.keys/for...in enumeration, browse-namespace + (PARTIAL only), and the media rule compressed to one line. The no-.then/.catch + guidance moved to the Syntax not-supported line. Content upgrades: the PARTIAL search + step gained query-style guidance (`- short phrases like "list issues" work best`; a + clearly-a-query-string example, not a tool name), and the exact-path guidance is now + "call it with the result's `path` as-is (never guess segments)" / COMPLETE: "use it + as-is rather than guessing segments". +- **Fixed-prose measurements** (instructions split on `"\n## "`, catalog budget 0, + bytes/3.7 - same method as Fix 4; chars/4 in parentheses): + preamble 44 -> 44 (41 -> 41), Workflow 146 -> 187 (135 -> 171), Rules 362 -> 191 + (332 -> 176), Syntax 453 -> 188 (419 -> 174); fixed prose total 1,005 -> 610 (927 -> 562), + ~ 40% reduction with no behavioral content dropped. Workflow grew slightly because it + absorbed the deduped parse/return-small justifications. +- **Round-robin namespace inlining** (`discoveryPlan`): the ported stop-on-first-miss + behavior (alphabetically-late namespaces starved to "none shown" while an early + namespace inlines everything) is replaced by round-robin fairness - in each round + (namespaces alphabetical), every namespace still holding un-inlined tools attempts to + place its next-cheapest line against the shared token budget; a namespace whose next + line does not fit is done while the others keep going; stop when all are done. Every + namespace gets some representation before any namespace gets everything. Kept: + `estimate` (chars/4) budget accounting, unbudgeted namespace stub lines, per-namespace + `(N tools)`/`(N tools, K shown)`/`(N tools, none shown)` labels, COMPLETE vs PARTIAL + header, alphabetical namespace order in the output, cheapest-first within each + namespace's shown set. +- **Plural/singular search fix**: `tokenize`d terms matched one-directionally (term must + be substring of indexed text), so query "issues" missed a tool whose text only says + "issue". Now each term expands to `termForms` - the term plus naive singular variants + (trailing "es" stripped when length > 3, trailing "s" when length > 2) - and each of + the four field checks passes when ANY form matches. Weights, exact-path lookup, and + namespace scoping untouched. A true plural path match still outranks a singular-only + description match (path substring 8 + searchable 2 > description 4 + searchable 2). +- **Tests**: package instruction/structure assertions updated to the new text; new + syntax-section test (leads with "Standard modern JavaScript works", names the + verified not-supported list, keeps the data-boundary note); the budget-exhaustion + test rewritten to assert the new fairness (alpha.expensive not fitting must NOT + prevent beta.cheap from showing: PARTIAL 2 of 3, `- beta (1 tool)` fully shown); new + plural/singular test (query "issues" finds a singular-only tool; ranking still + prefers the true "issues" path match). Adapter: description assertions updated; the + large-catalog PARTIAL test now asserts `zeta_only_tool` IS shown (`- zeta (1 tool)` + + its inlined line) - it was "none shown" under starvation. README updated (budgeted + catalog paragraph -> round-robin; search paragraph -> singular variants; + instructions-structure paragraph -> new section contents). Package suite: 169 pass / + 0 fail; adapter suites: 34 + 16. + +**Fix 9 - prompting trims per user review of Fix 8** (user reviewed the condensed +instructions and directed further cuts): + +- Default `maxInlineCatalogTokens` 4,000 -> **2,000** (user wants ~2k tokens of signatures + auto-inlined; round-robin fairness from Fix 8 spreads it across all namespaces). +- Console rule and files/images rule DROPPED from `## Rules`. Replaced by a single + `unknown`-treatment warning: "A result typed `Promise` has no guaranteed + shape - verify what actually came back before relying on its fields." (Deliberately + does NOT suggest console.log - user review: naming it there nudges models to log AND + return the same data; the prompt stays console-neutral, neither for nor against.) + The media-stripping MECHANISM is unchanged and still tested; only the prose about it + is gone - the `[N images attached]` marker is self-explanatory in context. +- Kept as-is per user: the JSON.parse workflow step (maps to the original motivating + transcript failure; NOT copied from prior art - see section 5 note), the browse-namespace rule + (undecided), no no-fetch/ambient-authority rule added (proposed, not approved). +- Explicitly REJECTED for now: auto-parsing JSON-looking text results at the adapter + boundary ("could get weird" - type flips, program-sees vs tool-sent divergence). Logged + as a next-iteration follow-up below. + +**DSL-expansion pass - interpreter-surface batch from section 4** (the deferred medium-tier JS +parity items, done as one focused pass; no public API or limit changes): + +- **`instanceof` + real Error values**: the `errorConstructors` names (`Error`, + `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, `URIError`) are + bound globals (`ErrorConstructorReference`, callable with or without `new`; `typeof` -> + `"function"`). Error values stay the same plain `{ name, message }` null-prototype + objects as before - the constructor name additionally rides on a NON-ENUMERABLE symbol + key (`ErrorBrand`), which every `Object.entries`-based walk (copyIn/copyOut, spread, + JSON.stringify) is blind to, so serialization is byte-identical to the old shape and the + brand is lost on spread/boundary copies exactly like JS loses the prototype. + `caughtErrorValue` produces `{ name, message }` wrappers via `createErrorValue`, so + caught interpreter AND tool failures are `instanceof Error` and carry the `name` the + equivalent real-JS failure would have (follow-up fix, user-directed - "closest to real + JS"): `InterpreterRuntimeError` gained an `errorName` field ("Error" default) set + fluently at throw sites via `.as(name)` - `JSON.parse` failures are `"SyntaxError"` (and + now include the engine's position detail in the message; safe - derived from the + program-supplied string), invalid regex patterns/flags `"SyntaxError"`, unknown + identifiers and TDZ access `"ReferenceError"`, assignment to a constant `"TypeError"`, + a bad `normalize` form `"RangeError"`; a host Error reaching the catch path directly + keeps its own name when it is one of the standard seven. Tool failures and everything + without a specific analogue stay `"Error"` - internal class names never leak. Specific + names satisfy the specific `instanceof` (`e instanceof SyntaxError`), matching JS. + The operator is handled in `evaluateBinaryExpression` + BEFORE the data-only operand check (like `typeof`, it observes any lhs - promises and + functions included); recognized rhs: the error constructors (a specific type matches its + own brand or `Error`, never a sibling), `Date`/`RegExp`/`Map`/`Set` (sandbox classes), + `Array`, `Object` (any object/function-ish value), `Promise` (`SandboxPromise`), and + `Number`/`String`/`Boolean` (always false - no boxed values exist); anything else is a + catchable error naming the recognized constructors. +- **Array methods**: `splice` (mutating, returns the removed elements; insertions run + `rejectCircularInsertion` like push/unshift; one-arg form removes to the end, undefined + delete count removes nothing), `fill` (circular-checked value) and `copyWithin` + (host-delegated), and `keys`/`values`/`entries` returning **arrays** (the Map/Set + convention - for...of and spread work either way). The `retryableArrayMethods` + "rewrite using map/filter" hint set emptied out and was deleted with its branch; unknown + array properties still read `undefined`. +- **String methods**: `localeCompare(that)` (locale/options arguments ignored - host + default locale; the dominant use is a sort comparator), `normalize(form?)` (invalid form + -> catchable error naming the four valid forms), `trimLeft`/`trimRight` as + trimStart/trimEnd aliases. +- **Actionable regex failures**: `toHostRegex` and `constructRegExp` now show the + offending pattern (or flags) plus the engine reason (deduped "Invalid regular + expression:" prefix via `regexFailureReason`) and a shared escaping hint + (`escapeRegexHint`); flags failures list the valid flag letters; the + replaceAll/matchAll missing-`g` errors spell out the exact `/pattern/g` to write and + the single-match alternative. +- **copyIn split (the important one)**: `copyIn(value, label, preserveSandboxValues = +false)` - recursion moved to a private `copyBounded`; `boundedData` (every intra-sandbox + checkpoint: `Object.*` helpers, coercion/Array.from/join inputs, template + interpolation, expression-result checkpoints) is now `copyIn(value, label, true)`, + which passes `SandboxDate`/`SandboxRegExp`/`SandboxMap`/`SandboxSet` through **by + reference as leaves** (contents not walked - Map/Set members are validated at their + mutation sites) while keeping the depth (`MAX_VALUE_DEPTH`), circularity, + plain-objects-only, blocked-property, and data-only checks; un-awaited promises keep + the await-hinting rejection in BOTH modes (deliberate - JS-parity pass-through was + considered and skipped to preserve the nudge). The HOST boundary (final result, + tool-call arguments, `JSON.stringify`, tool-result intake) uses the default mode and + still serializes JSON forms (Date -> ISO, RegExp/Map/Set -> `{}`); host instances met on + the preserving path are defensively wrapped into sandbox equivalents. Ripple: the + `Object.*` helpers treat sandbox values as empty objects (`Object.keys(map)` -> `[]`, + assign sources contribute nothing, hasOwn -> false - JS has no own enumerable props + there), so interpreter internals (`.map`/`.time`/`.regex`) can never leak; the + template-literal sandbox carve-out collapsed into `boundedData`. Object/array spread + already preserved instances (reference copies, no checkpoint) - now tested. +- **Console formatting**: `formatConsoleArgument` is total and deep + (`formatConsoleValue`): numbers render via `String` (`NaN`/`Infinity`/`-Infinity` + literally - never the JSON `null`; finite numbers match their JSON form), nested + strings are JSON-quoted, sandbox values keep their friendly forms at ANY depth (ISO + date, `/regex/flags`, `Map(n) [...]`, `Set(n) [...]`), opaque references become + in-place `[CodeMode reference]` markers instead of collapsing the whole argument, + cycles render `[Circular]` (reachable via Map/Set members, which mutation never + checkpoints), and depth beyond `MAX_CONSOLE_DEPTH = 32` (fixed constant, not a knob) + degrades to `...` - console can no longer fail a program. `console.table` guards with + `containsOpaqueReference` (sandbox cells render, e.g. ISO dates) and its row/cell + walkers treat sandbox values as scalar cells. +- **Prose**: the instructions Syntax not-supported line dropped its `instanceof +Error`/splice mentions (nothing else reworded); README updated (checkpoint + preservation vs boundary serialization, error values/`instanceof`, new array/string + methods, regex-failure behavior); `supportedSyntaxMessage` left untouched (it lists + supported syntax, was already non-exhaustive, and stays accurate). +- **Tests**: package suite 169 -> 209 (parity: Error/instanceof + real-JS error-name + coverage, splice/fill/copyWithin/keys/values/entries, localeCompare/normalize/trim-alias + describes; stdlib: checkpoint survival incl. tool-arg boundary pinning, stdlib + `instanceof`, regex-message assertions; codemode: NaN/Infinity + nested/cyclic console + rendering, table cells, caught-tool-failure `instanceof`); adapter suites unchanged + (34 + 16, green); both packages `tsgo --noEmit` clean. + +**Truncation layering - CodeMode truncation off in OpenCode** (user direction; resolves the +section 4 outer-truncation item the OPPOSITE way from "kill the outer one"): + +- `maxOutputBytes` lost its 32,000 default and now behaves exactly like the other two + limits: absent = no truncation. All three limits are uniformly no-default - budgets are + host policy. `ResolvedExecutionLimits.maxOutputBytes` is `number | undefined`; + `boundOutput` only runs when the host set the limit. Explicit values validate as before + (safe integer >= 0). +- OpenCode continues to pass NO limits, which now also means no CodeMode truncation. + `execute` is a normal `Tool.define` tool, so OpenCode's native tool-output truncation + applies with no special-casing - verified by tracing `wrap()` (`tool.ts:130-144`, + 50KB/2000-line thresholds in `truncate.ts`, full output dumped to a file under + `tool-output/`): the `metadata.truncated` self-truncation exemption never fires for + `execute` (its metadata never sets that key). One truncation layer, the host's - and it + is the richer one (file dump + explore/grep hint vs an inline marker). +- Hosts without their own output bounding set `maxOutputBytes` explicitly; README table + and prose updated, adapter comment rewritten. Tests: codemode +1 (absent limit -> 100KB + value + 50KB log line pass through unbounded, `truncated` undefined); the adapter test + 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 `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). + +**Registry promotion + permission-aware catalog** (the "promote to a proper tool service" +restructure; fixes the section 4 permission-advertising bug): + +- **The adapter moved** `src/session/code-mode.ts` -> `src/tool/code-mode.ts` and is now a + registry-resident tool service on the TaskTool precedent: `CodeModeTool = +Tool.define(CODE_MODE_TOOL, ...)` whose init depends on `MCP.Service`, `Agent.Service`, + and `Session.Service`. It is yielded in `ToolRegistry.layer`, gated into `builtin` by + `flags.experimentalCodeMode` (like the lsp/plan experiments), and `MCP.node` joined the + registry's `node.deps` (`MCP.node` has no ToolRegistry dependency, so no cycle). The + session-level special-casing in `session/tools.ts` (ad-hoc `SessionCodeMode.define` + + append) is deleted; the early return that suppresses raw per-MCP registration when the + flag is on stays session-side, keyed on the same flag+tool-count condition. +- **Enablement** lives in `ToolRegistry.tools()` next to the WebSearchTool check: the MCP + tool count is consulted once (an Effect) before the synchronous filter, and code mode + passes the predicate iff `flags.experimentalCodeMode` && count > 0. +- **Description split on the `describeTask` precedent**: the tool's static base + description is a two-line summary; `describeCodeMode(agent)` in `registry.tools()` + appends the full CodeMode instructions (workflow/rules/syntax + grouped catalog, + `catalogInstructions` in the adapter) at the same composition point as task - so + `plugin.trigger("tool.definition")` sees the base description first. +- **Permission-aware catalog + dispatch** (the bug fix): the visibility predicate from + `llm/request.ts` `resolveTools` is hoisted to `Permission.visibleTools(tools, ruleset)` + (a record filter over `Permission.disabled` - only a hard `deny` with pattern `"*"` + hides a tool; ask-level rules stay fully visible and prompt at call time) and + `resolveTools` now uses it, so the two paths cannot drift. `describeCodeMode` filters + with the merged agent+session ruleset that `SessionTools.resolve` passes into the + registry before building the catalog/search index; `execute` rebuilds the runtime per + execution from a fresh, filtered `mcp.tools()` snapshot using the same merged ruleset + (`Agent.get(ctx.agent)` + `Session.get(ctx.sessionID)`, matching the merge + `SessionTools.context` wires into `ctx.ask`) - a denied tool is not dispatchable + even if the model guesses its name and yields the normal unknown-tool diagnostic. + Documented gap (out of scope by design): per-message `user.tools[key] === false` arrives + at request-prep after descriptions are built and has no child-call equivalent. +- **Preserved behavior**: cancellation race + pre-aborted-signal guard, `toSandboxResult` + unwrap order, attachment accumulation, `CODE_MODE_TOOL` at all title sites, no execution + limits (native truncation only), `displayInput`, per-child `ctx.ask` gating (now wired + through `Tool.Context` exactly like every registry tool). +- **Explicit non-goal**: memoizing the catalog builder keyed on (ToolsChanged generation, + permission ruleset) was considered and deliberately skipped - the per-turn rebuild is + cheap (grouping + string rendering); revisit only if profiling shows it matters. +- **Tests**: the two adapter suites moved to `test/tool/{code-mode,code-mode-integration} +.test.ts` (mocked `MCP.Service`/`Agent.Service`/`Session.Service` replacing the direct + `define(...)` construction; description assertions target `catalogInstructions`, the + registry's composition input) and gained permission coverage: deny excluded from + catalog/search, ask-level stays visible and callable, denied tool undispatchable + (unknown-tool diagnostic), `Permission.visibleTools` semantics. `test/tool/ +registry.test.ts` gained four registry-level tests: registered with flag+MCP tools, + excluded without MCP tools, excluded with flag off, and deny/ask catalog filtering + through `registry.tools()`. Suites: 43 + 16 adapter tests, 16 registry tests, all green. + +**Shared MCP invocation middle (`McpInvoke.invoke`)** (closes the section 4 "plugin hooks skip +child calls" gap): + +- `packages/opencode/src/mcp/invoke.ts` extracts the duplicated "invoke an MCP tool" + middle into one shared `McpInvoke.invoke(input)`: plugin `tool.execute.before` hook -> + permission ask (`{ permission: key, patterns: ["*"], always: ["*"] }` via the caller's + `ctx.ask`) -> dispatch through the ai-sdk tool's execute inside the `Tool.execute` + tracing span (`tool.name`/`tool.call_id`/`session.id`/`message.id` attributes) -> + plugin `tool.execute.after` hook. It returns the RAW result the ai-sdk execute + resolved with; each caller keeps its own shaping edge - the legacy per-MCP loop in + `SessionTools.resolve` applies its existing model-facing shaping/truncation, code + mode applies `toSandboxResult`. It lives under `src/mcp/` because both callers + already depend on MCP and the function is about invoking an MCP-backed ai-sdk tool, + not about sessions or code mode. +- **After-hook payload**: fired inside `McpInvoke.invoke` with the raw MCP result - + which is exactly what the legacy loop always passed (the raw `CallToolResult`, not + the shaped `{title, output, metadata}`), so legacy behavior is preserved bit-for-bit + and the hook payload cannot drift between callers. No callback/edge-firing design + was needed. +- **Synthetic child callID**: code-mode child calls pass `${parentCallID}/${n}` as the + hook/span callID (`parentCallID` = the `execute` call's `ctx.callID`, falling back to + the entry key; `n` = per-execution counter starting at 1, shared across all child + calls in one program). callID is an opaque string - nothing parses it. The ai-sdk + `toolCallId` (`options.toolCallId`) stays each caller's existing value + (`ctx.callID ?? entry.key` for code mode). +- **Child-scoped hook failures**: `CodeModeTool` (which now also yields + `Plugin.Service`) wraps the whole child call - hooks, ask, dispatch - in + `toCatchable` (the generalization of the old `askPermission` catchCause), so a plugin + hook failure fails ONLY that child call as a catchable in-program `toolError`; other + calls in the same program keep running and interruption still propagates as + interruption. Legacy semantics unchanged: a hook failure fails the tool call. +- **Tests**: `test/tool/code-mode.test.ts` +2 (child calls fire before/after with the + MCP key and `parent/1`, `parent/2` ids, after hook carries the raw MCP result; a + failing before hook is caught in-program, gates dispatch, and leaves the outer + execute ok) - both code-mode harnesses gained a `Plugin.Service` mock (pass-through + trigger by default, overridable). New `test/session/tools.test.ts` (3 tests) pins + `SessionTools.resolve` at the real-registry seam (LayerNode.compile, fake MCP layer): + flag on + MCP tools -> `execute` present, raw MCP keys suppressed; flag off -> raw + keys present, `execute` absent; and the legacy raw-MCP execute fires before/after + hooks keyed by the ai-sdk toolCallId with the raw result payload. Suites: adapter + 45 + 16, session/tool/permission all green; this package untouched (211 pass). + +**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-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-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-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 + (`number | "NaN" | "Infinity" | "-Infinity"`, emitted as single-value string enums), + while raw JSON Schema unions render every branch. Tests: `signature.test.ts` +3. +- **Compound assignment now matches binary-operator semantics** (`src/codemode.ts`): + `applyCompoundAssignment` did raw JS ops on interpreter wrapper objects, so `x += y` + diverged from `x = x + y` (sandbox Date `d += 1` produced `"[object Object]1"`; + `d -= 400` gave `NaN` instead of epoch arithmetic). The operator table + coercion moved + verbatim out of `evaluateBinaryExpression` into a shared `applyBinaryOperator`; + compound assignment validates against a `compoundOperators` set (`+=` ... `>>>=`) and + dispatches through it (`operator.slice(0, -1)`). Logical assignments (`&&=`/`||=`/`??=`) + keep their separate short-circuit path (`evaluateLogicalAssignment`), and both + assignment call sites still wrap results in `boundedData`. Deliberate side effect: + compound assignment now rejects opaque references, consistent with binary operators. + Tests: `parity.test.ts` +5 (Date `+=` concat parity, Date `-=`/`/=` epoch parity, + string `+=` object/array, member-target compound, 13-case operator sweep vs real JS). + Package suite: 220 pass. + +--- + +## 4. Remaining work (detailed TODO) + +### Next DSL-expansion pass (done - see the DSL-expansion pass entry in section 3) + +Batch these together - per user direction: important, but deliberately deferred to one +focused interpreter-surface pass rather than picked off piecemeal. + +- [x] Medium-tier JS parity items deferred from the original audit: caught errors are plain + `{ name, message }` objects, not `instanceof Error` (and `Error` isn't a value - + `x instanceof Error` is unsupported syntax); `splice` (still a + "rewrite using map/filter" hint) and array `entries()/keys()/values()`; + `localeCompare`/`normalize`/`trimLeft`/`trimRight`; friendlier regex-y error messages. + (`fill`/`copyWithin` - which the hint set also covered - were implemented too since + they are trivial host delegations, so the hint set is gone entirely.) +- [x] `Date`/`Map`/`Set`/`RegExp` values passing through `Object.*` helpers and coercion + checkpoints take their JSON forms (e.g. `Object.values({ d: date })` yields the ISO + string, not the Date - calling `.getTime()` on it then fails). Currently deliberate + (documented in README) but flagged as important: fix in this pass by letting sandbox + values survive `Object.*`/spread checkpoints instead of JSON-serializing them. +- [x] `console.log(NaN)` prints `"null"` (goes through the boundary chokepoint) - could + special-case number formatting in `formatConsoleArgument`. +- [x] Sandbox values nested inside logged containers print `[CodeMode reference]` + (`console.log({ m: map })`) - could deep-format instead. + +### Next iteration: text-result handling (deliberate follow-up, user-directed) + +- [ ] Revisit how MCP text results reach the program. Today: `structuredContent` when the + server sends it, else joined text as a plain string (the program JSON.parses it, + guided by a workflow step). Considered and deferred: (a) conservative boundary + auto-parse (text starting with `{`/`[` that parses cleanly becomes an object) - + rejected for now as potentially confusing (type flips; program sees something other + than what the tool sent); (b) raw-envelope passthrough with the envelope shape + stamped into every output schema - rejected (more digging per call, verbose + signatures). Result quality is dominated by whether servers declare output schemas; + revisit once real usage shows which failure modes matter. + +### Next iteration: stdlib surface (prioritized) + +Current instructions say "usual Array/String/Object/Math/JSON methods," but the interpreter is +intentionally a subset. Keep CodeMode focused on orchestration and data shaping, not a full host +runtime, but close the high-friction gaps models are likely to reach for. + +- [ ] **P0: tighten wording first** - change instructions/docs to say "common stdlib subset" + until the surface is broader. This avoids misleading the model into assuming every JS + helper exists. +- [ ] **P1: URL parsing helpers** - add `URL` and `URLSearchParams`. These are high-value for + tool orchestration (query strings, ids in URLs, API links), deterministic, and do not add + ambient host authority. +- [ ] **P2: Math completion** - add the missing standard deterministic `Math` methods + (`sin`/`cos`/`tan`, inverse/hyperbolic variants, `atan2`, `log1p`, `expm1`, `imul`, + `fround`, `clz32`, etc.). Decide explicitly on `Math.random`: likely acceptable because + `Date.now()` is already exposed, but document the nondeterminism if enabled. +- [ ] **P3: base64 helpers** - add string-only `atob`/`btoa` equivalents. Useful for API/tool + payload cleanup and does not require opening the broader binary boundary. +- [ ] **P4: small crypto helper** - consider `crypto.randomUUID()` only, not full `crypto`. + UUID generation is a common orchestration need; broader crypto can wait until there is a + concrete use case and a clear capability boundary. +- [ ] **P5: text/binary primitives** - consider `TextEncoder`/`TextDecoder` first, then + `ArrayBuffer`/typed arrays/`DataView`/`Blob`/`File` only with an explicit boundary design + (serialization, size limits, and how values cross tool args/results). This is reasonable + but lower priority than URL/base64 because CodeMode is still plain-data oriented. +- [ ] **P6: date/formatting conveniences** - consider `Date` setters and common formatting + helpers (`toUTCString`, maybe `Intl` later). Lower priority; most orchestration can use + existing getters, `Date.parse`, `Date.UTC`, and ISO strings. +- [ ] **P7: environment/config access** - do not expose raw `process.env` as a global ambient + authority. If this becomes useful, add an explicit host-provided/whitelisted capability + (for example a small env/config tool or injected read-only object) so secrets are not + accidentally exposed to arbitrary CodeMode programs. + +Explicit non-goals for now: `structuredClone`, `WeakMap`/`WeakSet`, and timers +(`setTimeout`/`setInterval`/`queueMicrotask`). They do not materially improve the current tool +orchestration use case. + +### Wiring-review findings (subagent code review of the OpenCode integration, triaged) + +Pre-PR fixes (user-approved cut): + +- [x] **Cancellation does not interrupt the interpreter** - the no-limits rationale claimed + "user cancel interrupts the execution fiber," but `tools.ts` runs tools via + `run.promise` -> `Effect.runPromise` (`effect/bridge.ts:64-66`) with NO abort wiring; + on cancel the ai-sdk abandons the promise, child MCP calls abort (they hold + `ctx.abort`) but the interpreter fiber spun on - `while(true){}` or a try/catch + loop was uncancellable with no timeout backstop. Verified by hand, not just the + reviewer. FIXED in the adapter: `Effect.raceFirst(runtime.execute(code), cancelled)` + where `cancelled` is an `Effect.callback` abort-signal watcher (listener removed on + interruption) resuming with an `ok: false` "Execution cancelled." result - the abort + winning the race interrupts the execution fiber (interpreter auto-yield makes busy + loops preemptible, same mechanism as timeoutMs) and returning a value keeps the + runner's post-abort `completeToolCall` bookkeeping on its normal path. A pre-aborted + signal short-circuits at entry before the program starts (racing alone still lets + the loser run its first steps). Tests: +2 adapter (child call triggers abort + deterministically then the program enters `while(true){}` - would hang if + interruption broke; pre-aborted signal runs nothing). Adapter suite 34 -> 36. + (Wiring abort->interrupt into the shared `tools.ts` runner for ALL tools remains a + worthwhile separate change.) +- [x] **Permission-denied/disabled MCP tools are still advertised in the catalog** - the + non-code-mode path filters them from the model's view (`llm/request.ts:208-213`); + code mode builds the catalog from all of `mcp.tools()`, so the model is invited to + call tools that can only fail at permission time, and per-message `tools[key]=false` + disabling has no child-call equivalent. Fix: filter the catalog with the same + ruleset. + DONE (see the "Registry promotion + permission-aware catalog" entry in section 3): the + shared `Permission.visibleTools` predicate filters both the appended + catalog/description (`describeCodeMode`, agent ruleset) and the execute-time tool + tree (merged agent+session ruleset) - hard-denied tools are neither advertised nor + dispatchable. Ask-level tools stay visible/callable. Per-message + `tools[key] === false` remains a documented gap by design (it arrives at + request-prep, after descriptions are built). +- [x] Style: `code-mode.ts` is the only `src/session` sibling without the + `export * as ... from "./..."` self-reexport footer, forcing a star import at + `tools.ts:26` (AGENTS.md violation). Add footer + import the projection. + DONE: added `export * as SessionCodeMode from "./code-mode"` footer; `tools.ts` now + imports the named `SessionCodeMode` projection. +- [x] Trivial: latent `groupByServer` fallback bug - `key.slice(0, key.indexOf("_"))` is + `slice(0, -1)` when no underscore (unreachable today; guard or drop); dead + `CODE_MODE_TOOL` export (integration points hardcode `"execute"` - use it or inline + it). + DONE: no-underscore key now falls back to the whole key (test pins it); the four + `title: "execute"` sites in `code-mode.ts` now reference `CODE_MODE_TOOL`. + +Post-MVP (logged, not blocking an experimental flag): + +- [x] **Plugin `tool.execute.before/after` hooks skip child calls** - legacy MCP + registration fires them per tool (`tools.ts:419-441`); under code mode only the + outer `execute` fires them, so auditing/intercepting plugins silently lose MCP + coverage when the flag flips. + DONE (see the "Shared MCP invocation middle" entry in section 3): both paths now run + `McpInvoke.invoke` (`src/mcp/invoke.ts`) - hooks AND the `Tool.execute` span fire + for child calls with synthetic `${parentCallID}/${n}` callIDs; hook failures are + child-scoped, catchable in-program errors. +- [x] Description/preview rebuilt every assistant turn - `registry.tools()` re-runs + `groupByServer` + a throwaway `CodeMode.make(...).instructions()` per turn + (`describeCodeMode`). DECIDED as an explicit non-goal: memoizing the catalog + builder keyed on (ToolsChanged generation, permission ruleset) was considered and + deliberately skipped - the per-turn rebuild is cheap (grouping + string + rendering); revisit only if profiling shows it matters. A second `CodeMode.make` + per execution is inherent (description precedes execution). +- [ ] Child permission rejection round-trips through the defect channel - `ctx.ask` + defect (`tools.ts:90` orDie) recovered via `catchCause` + `Cause.squash` + (`code-mode.ts:238-245`). Works, interrupts preserved, but fragile coupling; + exposing the typed rejection on `Tool.Context.ask` would be cleaner. +- [ ] No collision guard on the `execute` tool id (a plugin/custom tool named `execute` + is silently shadowed; a log line would do). +- [ ] Style nits: triple-nested `yield*` in `tools.ts:101-107` argument position (bind + first, like neighbors); single-use micro-helpers (`toJsonSchema` is a bare cast); + comment density far above session-neighbor norm; adapter tests use raw + `Effect.runPromise` + hand-built layers with `as any` instead of the + `testEffect`/`LayerNode.compile` fixture pattern (`test/tool/grep.test.ts:25-31`) + and star-import `Truncate`. +- [ ] 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) + +- [ ] `evaluateUpdateExpression` (`++`/`--`) still uses raw `Number(current)`, so `d++` on a + sandbox Date yields `NaN` where `d += 1` now uses epoch semantics (and real JS `d++` + would give epoch+0 numeric). Pre-existing, out of scope of the compound-assignment + parity fix; route it through `applyBinaryOperator` if it ever matters. +- [ ] Media-only marker could name what it attached when MCP provides names: `image`/`audio` + blocks carry no filename (mime + data only) so the generic + `[N images attached to the result]` stays, but `resource`/`resource_link` blocks have + URIs/names we could surface, e.g. `[2 files attached: chart.png, data.csv]`. Minor. +- [x] Truncation layering decided (user direction): the OPPOSITE of killing the outer layer - + CodeMode truncation off in OpenCode (`maxOutputBytes` lost its default; absent = no + truncation, uniform with the other two limits), native tool-output truncation is the + single active layer (verified: `execute` flows through `tool.ts` `wrap()` like any + normal tool, no exemption). See the section 3 entry. +- [x] Flaky wall-clock assertion removed from `test/promise.test.ts`: the parallelism test + now relies solely on the deterministic `trace.maxActive > 1` counter (which proves + true temporal overlap). The timeout tests were never flaky - 100ms timeout vs 60s + tool sleeps (600x margin) with counter-based assertions. +- [ ] Attachment propagation believed correct but unverified end-to-end at the OpenCode + wiring layer (codemode strips -> `Tool.ExecuteResult.attachments` -> processor + normalizes -> `FilePart`s visible to the model). Code-reviewed as sound; confirm with + one interactive session (an image-returning MCP tool) when convenient. Same session + can eyeball TUI child-call rendering via `metadata.toolCalls`. +- [x] Commit hygiene: all work committed and pushed on `codemode-v2` as six commits, in + generic-package + OpenCode-integration pairs (waves 0-5; Fixes 4-9; DSL pass + + error names + truncation layering). Future work: commit only when explicitly asked; + push with `--no-verify` per repo convention. The scratch `.opencode/opencode.jsonc` + stays uncommitted. +- [ ] MVP scope decided (user direction): the interactive e2e eyeball is NOT required - + remaining pre-PR work is essentially just opening the PR. Attachment-propagation + verification (below) stays parked as post-MVP. + +--- + +## 5. Context and gotchas for whoever picks this up + +- **Motivating failure (why forgiving semantics + prompting matter):** in a real transcript, + the model wrote `me.result?.login ?? me.result` where the tool result was a JSON _string_ - + the old strict interpreter threw (`String property 'login' is not available`); then the + model returned a raw 105KB payload, which native truncation dumped to a file, costing a + subagent round-trip to extract one number. Interpreter forgiveness stops the crashes; + Wave 4 prompting stops the payload dumping. Both are needed. +- Realistically **all MCP tools render `Promise`** (no outputSchema), so the + instructions prose is the only lever for result-shape behavior in the dominant case. +- **`copyIn` has two roles, split by a mode flag** (DSL-expansion pass): host<->sandbox + boundary (default mode - final result, tool arguments, `JSON.stringify`, tool-result + intake; sandbox value types serialize to JSON forms) AND intra-sandbox data checkpoint + (`boundedData` = `copyIn(value, label, true)` - sandbox value instances pass through by + reference as leaves, everything else keeps the same plain-data validation). If you add a + new value type, follow the Wave 1b-i pattern: class in `values.ts`, opaque-by-default via + `isRuntimeReference`, explicit carve-outs, JSON form in `copyIn`'s boundary mode plus + pass-through in its preserving mode, console formatting (`formatConsoleValue`), tests - + and make sure the `Object.*` helpers treat it as an empty object so class fields never + leak. +- The interpreter throws synchronously inside `Effect.gen`/`Effect.sync` freely; everything is + normalized by `catchCause` -> `normalizeError` into `Diagnostic` data. Program failures are + **data, never Effect failures**; only interruption propagates. +- `parseProgram` wraps source in `async function __codemode__() { ... }`, transpiles TS, then + slices between the first `{` and last `}` - line/col diagnostics are offset accordingly + (`sourceLocation`). Don't inject prologue code; it breaks the offsets. +- OpenCode wraps every tool's output with auto-truncation (`Tool.define` wrapper, + `truncate.output`, 2000 lines / 50KB, saves full output to disk and appends a hint) unless + `metadata.truncated` is set. The `execute` tool currently rides that for free. +- Effect version: both repos pin `effect@4.0.0-beta.83` via bun catalogs. This package uses + v4-only APIs (`Schema.Decoder`, `Schema.toJsonSchemaDocument`, `Context.Service`, + `Cause.hasInterruptsOnly`, `Effect.timeoutOrElse`). The effect-smol checkout referenced in + 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` - 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 + `src/session/code-mode.ts`); `src/tool/registry.ts` (`describeCodeMode`, enablement in + `tools()`, `MCP.node` dep); `src/session/tools.ts` (raw-MCP-registration suppression + when the flag is on); `src/permission/index.ts` (`Permission.visibleTools`, the shared + visibility predicate, also used by `src/session/llm/request.ts` `resolveTools`); + `src/mcp/index.ts` (`MCP.tools()`/`MCP.defs()`); `src/mcp/catalog.ts` (`convertTool`, + `server_tool` naming); `src/tool/tool.ts` (`ExecuteResult.attachments`, truncation + wrapper); `src/session/message-v2.ts` (attachments -> vision); + `packages/tui/src/routes/session/index.tsx` (`Execute` progress component); + `src/effect/runtime-flags.ts` (feature flag). diff --git a/packages/codemode/package.json b/packages/codemode/package.json new file mode 100644 index 0000000000..b2d9ec3ea2 --- /dev/null +++ b/packages/codemode/package.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/codemode", + "version": "0.0.1", + "description": "Effect-native confined code execution over schema-described tools", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsgo --noEmit", + "test": "bun test" + }, + "dependencies": { + "acorn": "8.15.0", + "effect": "catalog:", + "typescript": "catalog:" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + } +} diff --git a/packages/codemode/src/codemode.ts b/packages/codemode/src/codemode.ts new file mode 100644 index 0000000000..b207efe46a --- /dev/null +++ b/packages/codemode/src/codemode.ts @@ -0,0 +1,4074 @@ +import { parse } from "acorn" +import { Cause, Effect, Exit, Fiber, Schema, Semaphore } from "effect" +import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript" +import { + copyIn, + copyOut, + isBlockedMember, + ToolReference, + ToolRuntime, + ToolRuntimeError, + type HostTools, + type SafeObject, + type ToolCall, + type ToolDescription, + type Services, +} from "./tool-runtime.js" +import type { Definition } from "./tool.js" +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, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescription } from "./tool-runtime.js" + +/** Resource budgets enforced independently during each CodeMode program execution. */ +export type ExecutionLimits = { + /** Maximum wall-clock execution time in milliseconds. No default: absent means no timeout. */ + readonly timeoutMs?: number + /** Maximum number of tool calls admitted by the runtime. No default: absent means unlimited. */ + readonly maxToolCalls?: number + /** + * Maximum UTF-8 bytes of model-facing output: the serialized result value plus captured + * logs. Excess output is truncated with an explanatory marker instead of failing. No + * default: absent means no truncation (for hosts with their own output bounding). + */ + readonly maxOutputBytes?: number +} + +/** Controls how much of the tool catalog is inlined in agent instructions. */ +export type DiscoveryOptions = { + /** + * Estimated-token budget (chars/4, default 2000) for inlined full tool signatures in agent + * instructions. Signatures that fit are inlined round-robin across namespaces; every + * namespace is always listed with its tool count regardless of budget, and + * `tools.$codemode.search` is always registered. + */ + readonly maxInlineCatalogTokens?: number +} + +type ToolTree = { + readonly [name: string]: Definition | ToolTree +} + +type ResolvedExecutionLimits = { + /** Undefined means no timeout. */ + readonly timeoutMs: number | undefined + /** Undefined means unlimited tool calls. */ + readonly maxToolCalls: number | undefined + /** Undefined means no output truncation. */ + readonly maxOutputBytes: number | undefined +} + +/** Options for one CodeMode execution. */ +export type ExecuteOptions = {}> = { + /** Source for one program in the supported JavaScript subset. */ + code: string + /** Explicit tool tree exposed to the program as `tools`. */ + tools?: Tools & ToolTree> + /** Per-execution overrides for the default resource limits. */ + limits?: ExecutionLimits + /** Observes decoded tool input immediately before tool execution. */ + onToolCallStart?: (call: ToolRuntime.ToolCallStarted) => Effect.Effect> + /** Observes each admitted tool call as it settles, with outcome and duration. */ + onToolCallEnd?: (call: ToolRuntime.ToolCallEnded) => Effect.Effect> +} + +/** A JSON value that can cross the confined interpreter boundary. */ +export type DataValue = Schema.Json + +/** Configuration shared by `CodeMode.make` and `CodeMode.execute`. */ +export type Options = {}> = Omit, "code"> & { + /** Progressive-disclosure configuration for the agent-facing tool catalog. */ + readonly discovery?: DiscoveryOptions +} + +/** Schema for a host tool input containing CodeMode source. */ +export const Input = Schema.Struct({ code: Schema.String }) +export type Input = typeof Input.Type + +export const DiagnosticKind = Schema.Literals([ + "ParseError", + "UnsupportedSyntax", + "UnknownTool", + "InvalidToolInput", + "InvalidToolOutput", + "InvalidDataValue", + "ToolCallLimitExceeded", + "TimeoutExceeded", + "ToolFailure", + "ExecutionFailure", +]) +/** Stable categories produced by program, schema, tool, and limit failures. */ +export type DiagnosticKind = typeof DiagnosticKind.Type + +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 + +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 Runtime = { + /** Lists schema-described tool paths provided by the host. */ + readonly catalog: () => ReadonlyArray + /** Builds model-facing syntax guidance and visible tool signatures. */ + readonly instructions: () => string + /** Executes a program using this runtime's configured host tools. */ + readonly execute: (code: string) => Effect.Effect +} + +type SourcePosition = { + line: number + column: number +} + +type SourceLocation = { + start: SourcePosition + end: SourcePosition +} + +type AstNode = { + type: string + loc?: SourceLocation + [key: string]: unknown +} + +type ProgramNode = AstNode & { + type: "Program" + body: Array +} + +type Binding = { + mutable: boolean + value: unknown + // Absent means initialized. `false` marks a parameter binding seeded into its scope but not + // yet bound, so a default that forward-references a later parameter sees a TDZ error (as in JS) + // rather than silently resolving to an outer binding of the same name. + initialized?: boolean +} + +type StatementResult = + | { kind: "none" } + | { kind: "value"; value: unknown } + | { kind: "return"; value: unknown } + | { kind: "break" } + | { kind: "continue" } + +type MemberReference = { + target: SafeObject | Array + key: string | number +} + +class CodeModeFunction { + constructor( + readonly parameters: ReadonlyArray, + readonly body: AstNode, + readonly capturedScopes: ReadonlyArray>, + ) {} +} + +class IntrinsicReference { + constructor( + readonly receiver: unknown, + readonly name: string, + ) {} +} + +class ComputedValue { + constructor(readonly value: unknown) {} +} + +class PromiseNamespace {} + +type PromiseMethodName = "all" | "allSettled" | "race" | "resolve" | "reject" + +class PromiseMethodReference { + constructor(readonly name: PromiseMethodName) {} +} + +// A built-in global namespace (`Object`, `Math`, `JSON`, `Array`, ...); members resolve to a +// GlobalMethodReference, except known constants (e.g. `Math.PI`) which resolve to a value. +type GlobalNamespaceName = "Object" | "Math" | "JSON" | "Array" | "console" | "Date" | "RegExp" | "Map" | "Set" + +class GlobalNamespace { + constructor(readonly name: GlobalNamespaceName) {} +} + +class GlobalMethodReference { + constructor( + readonly namespace: GlobalNamespaceName | "Number" | "String", + readonly name: string, + ) {} +} + +class CoercionFunction { + constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat") {} +} + +class ProgramThrow { + constructor(readonly value: unknown) {} +} + +class ErrorConstructorReference { + constructor(readonly name: string) {} +} + +// Non-enumerable so spread/copyOut preserve the plain `{ name, message }` data shape. +const ErrorBrand: unique symbol = Symbol("codemode.error") + +const brandError = (errorValue: SafeObject, name: string): SafeObject => { + Object.defineProperty(errorValue, ErrorBrand, { value: name }) + return errorValue +} + +const createErrorValue = (name: string, message: string): SafeObject => + brandError(Object.assign(Object.create(null) as SafeObject, { name, message }), name) + +const errorBrandName = (value: unknown): string | undefined => + value !== null && typeof value === "object" + ? ((value as Record)[ErrorBrand] as string | undefined) + : undefined + +const arrayMethods = new Set([ + "map", + "filter", + "find", + "findIndex", + "findLast", + "findLastIndex", + "some", + "every", + "includes", + "join", + "reduce", + "reduceRight", + "flatMap", + "forEach", + "sort", + "toSorted", + "slice", + "concat", + "indexOf", + "lastIndexOf", + "at", + "flat", + "reverse", + "toReversed", + "with", + "push", + "pop", + "shift", + "unshift", + "splice", + "fill", + "copyWithin", + "keys", + "values", + "entries", +]) + +const mathConstants = new Set(["PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2"]) + +const numberMethods = new Set(["toFixed", "toPrecision", "toExponential", "toString"]) + +const stringMethods = new Set([ + "toLowerCase", + "toUpperCase", + "trim", + "trimStart", + "trimEnd", + "trimLeft", + "trimRight", + "split", + "slice", + "substring", + "substr", + "includes", + "startsWith", + "endsWith", + "indexOf", + "lastIndexOf", + "replace", + "replaceAll", + "repeat", + "padStart", + "padEnd", + "charAt", + "charCodeAt", + "codePointAt", + "at", + "concat", + "toString", + "match", + "matchAll", + "search", + "localeCompare", + "normalize", +]) + +const numberConstants = new Set(["MAX_SAFE_INTEGER", "MIN_SAFE_INTEGER", "MAX_VALUE", "MIN_VALUE", "EPSILON"]) + +const numberStatics = new Set(["isInteger", "isFinite", "isNaN", "isSafeInteger", "parseInt", "parseFloat"]) + +const stringStatics = new Set(["fromCharCode", "fromCodePoint"]) + +const consoleMethods = new Set(["log", "info", "debug", "warn", "error", "dir", "table"]) + +const promiseStatics = new Set(["all", "allSettled", "race", "resolve", "reject"]) + +const errorConstructors = new Set([ + "Error", + "TypeError", + "RangeError", + "SyntaxError", + "ReferenceError", + "EvalError", + "URIError", +]) + +const valueConstructors = new Set(["Date", "RegExp", "Map", "Set"]) + +const dateMethods = new Set([ + "getTime", + "valueOf", + "toISOString", + "toJSON", + "toString", + "getFullYear", + "getMonth", + "getDate", + "getDay", + "getHours", + "getMinutes", + "getSeconds", + "getMilliseconds", + "getUTCFullYear", + "getUTCMonth", + "getUTCDate", + "getUTCDay", + "getUTCHours", + "getUTCMinutes", + "getUTCSeconds", + "getUTCMilliseconds", + "getTimezoneOffset", +]) +const dateStatics = new Set(["now", "parse", "UTC"]) + +const regexpMethods = new Set(["test", "exec", "toString"]) +// Read-only host regex fields surfaced as plain values. +const regexpProperties = new Set([ + "source", + "flags", + "lastIndex", + "global", + "ignoreCase", + "multiline", + "sticky", + "unicode", + "dotAll", +]) + +const mapMethods = new Set(["get", "set", "has", "delete", "clear", "forEach", "keys", "values", "entries"]) +const setMethods = new Set(["add", "has", "delete", "clear", "forEach", "keys", "values", "entries"]) + +const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit") + +const supportedSyntaxMessage = + "Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, and Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls (promise chaining with .then/.catch is not supported - use await with try/catch)." + +const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError => + new InterpreterRuntimeError( + `Syntax '${kind}' is not supported in CodeMode. ${supportedSyntaxMessage}`, + node, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) + +/** How many eagerly forked tool calls may run at once. Fixed; not a configurable knob. */ +const TOOL_CALL_CONCURRENCY = 8 + +/** Console formatting recursion ceiling; deeper values render as "...". Fixed; not a knob. */ +const MAX_CONSOLE_DEPTH = 32 + +const validateLimit = ( + name: keyof ExecutionLimits, + value: Value, + minimum: number, +): Value => { + if (value !== undefined && (!Number.isSafeInteger(value) || value < minimum)) { + throw new RangeError(`${name} must be a safe integer greater than or equal to ${minimum}.`) + } + return value +} + +// No limit has a default: absent means no timeout / unlimited calls / no output truncation - +// budgets are host policy, not library policy. A host without its own output bounding should +// pass maxOutputBytes explicitly, or oversized results flood model context. +const resolveExecutionLimits = (limits?: ExecutionLimits): ResolvedExecutionLimits => ({ + timeoutMs: validateLimit("timeoutMs", limits?.timeoutMs, 1), + maxToolCalls: validateLimit("maxToolCalls", limits?.maxToolCalls, 0), + maxOutputBytes: validateLimit("maxOutputBytes", limits?.maxOutputBytes, 0), +}) + +class InterpreterRuntimeError extends Error { + readonly node?: AstNode + /** + * The constructor name a program observes when it catches this failure (`caught.name`, and + * the brand behind `caught instanceof SyntaxError` etc.). "Error" unless the failing + * operation names a standard type in real JS - e.g. JSON.parse and invalid regex patterns + * throw SyntaxError, an unknown identifier is a ReferenceError, a bad normalize form is a + * RangeError. + */ + errorName: string = "Error" + + constructor( + message: string, + node?: AstNode, + readonly kind: DiagnosticKind = "ExecutionFailure", + readonly suggestions?: ReadonlyArray, + ) { + super(message) + this.name = "InterpreterRuntimeError" + + if (node) { + this.node = node + } + } + + as(errorName: string): this { + this.errorName = errorName + return this + } +} + +const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null + +const asNode = (value: unknown, context: string): AstNode => { + if (!isRecord(value) || typeof value.type !== "string") { + throw new InterpreterRuntimeError(`Invalid AST node while reading ${context}.`) + } + + return value as AstNode +} + +const getArray = (node: AstNode, key: string): Array => { + const value = node[key] + if (!Array.isArray(value)) { + throw new InterpreterRuntimeError(`Expected '${key}' to be an array.`, node) + } + + return value +} + +const getString = (node: AstNode, key: string): string => { + const value = node[key] + if (typeof value !== "string") { + throw new InterpreterRuntimeError(`Expected '${key}' to be a string.`, node) + } + + return value +} + +const getBoolean = (node: AstNode, key: string): boolean => { + const value = node[key] + if (typeof value !== "boolean") { + throw new InterpreterRuntimeError(`Expected '${key}' to be a boolean.`, node) + } + + return value +} + +const getOptionalNode = (node: AstNode, key: string): AstNode | undefined => { + const value = node[key] + if (value === undefined || value === null) { + return undefined + } + + return asNode(value, key) +} + +const getNode = (node: AstNode, key: string): AstNode => { + const value = node[key] + return asNode(value, key) +} + +const parseProgram = (code: string): ProgramNode => { + const transpiled = transpileModule(`async function __codemode__() {\n${code}\n}`, { + reportDiagnostics: true, + compilerOptions: { + target: ScriptTarget.ESNext, + module: ModuleKind.ESNext, + }, + }) + const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error) + + if (diagnostic) { + throw new InterpreterRuntimeError( + `Failed to parse TypeScript: ${flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`, + undefined, + "ParseError", + ) + } + + const bodyStart = transpiled.outputText.indexOf("{") + 1 + const bodyEnd = transpiled.outputText.lastIndexOf("}") + const executableCode = transpiled.outputText.slice(bodyStart, bodyEnd) + const parsed = parse(executableCode, { + ecmaVersion: "latest", + sourceType: "script", + allowReturnOutsideFunction: true, + allowAwaitOutsideFunction: true, + locations: true, + }) as unknown + + if (!isRecord(parsed) || parsed.type !== "Program" || !Array.isArray(parsed.body)) { + throw new InterpreterRuntimeError("Failed to parse script as a Program node.") + } + + return parsed as ProgramNode +} + +const formatLocation = (node?: AstNode): string => { + if (!node || !node.loc) { + return "" + } + + const location = sourceLocation(node) + return ` (line ${location.line}, col ${location.column})` +} + +const sourceLocation = (node: AstNode): { readonly line: number; readonly column: number } => ({ + line: Math.max(1, (node.loc?.start.line ?? 2) - 1), + column: Math.max(1, (node.loc?.start.column ?? 4) - 3), +}) + +const publicErrorMessage = (message: string): string => + message.replace(/\/(?:Users|home|private|tmp|var\/folders)\/[^\s"'`]+/g, "") + +const normalizeError = (error: unknown): Diagnostic => { + if (error instanceof InterpreterRuntimeError) { + return { + kind: error.kind, + message: `${error.message}${formatLocation(error.node)}`, + ...(error.node?.loc ? { location: sourceLocation(error.node) } : {}), + ...(error.suggestions ? { suggestions: error.suggestions } : {}), + } + } + + if (error instanceof ToolRuntimeError) { + return { + kind: error.kind, + message: error.message, + ...(error.suggestions.length > 0 ? { suggestions: error.suggestions } : {}), + } + } + + if (error instanceof ToolError) { + return { kind: "ToolFailure", message: publicErrorMessage(error.message) } + } + + if (error instanceof ProgramThrow) { + const value = error.value + let message: string + if (containsRuntimeReference(value)) { + // A thrown tool/function reference must not leak its internal structure. + message = "a non-data value" + } else if (typeof value === "string") { + message = value + } else if ( + value !== null && + typeof value === "object" && + typeof (value as { message?: unknown }).message === "string" + ) { + message = (value as { message: string }).message + } else { + try { + message = JSON.stringify(copyOut(value)) ?? String(value) + } catch { + message = String(value) + } + } + return { kind: "ExecutionFailure", message: `Uncaught: ${message}` } + } + + if (error instanceof RangeError && /call stack|recursion/i.test(error.message)) { + return { + kind: "ExecutionFailure", + message: "Execution exceeded the maximum nesting depth.", + } + } + + if (error instanceof Error) { + return { + kind: error.name === "SyntaxError" ? "ParseError" : "ExecutionFailure", + message: publicErrorMessage(error.message), + } + } + + // A non-Error thrown by a host tool (raw string / number / Symbol) still routes through + // path redaction so filesystem paths can never leak through the catch-all branch. + return { + kind: "ExecutionFailure", + message: publicErrorMessage(String(error)), + } +} + +// Shared by catch bindings, Promise.allSettled rejection reasons, and Promise.race losers. +const caughtErrorValue = (thrown: unknown): unknown => { + if (thrown instanceof ProgramThrow) return thrown.value + if (thrown instanceof InterpreterRuntimeError) return createErrorValue(thrown.errorName, thrown.message) + const name = thrown instanceof Error && errorConstructors.has(thrown.name) ? thrown.name : "Error" + return createErrorValue(name, normalizeError(thrown).message) +} + +const boundedData = (value: unknown, label: string): unknown => copyIn(value, label, true) + +const isRuntimeReference = (value: unknown): boolean => + value instanceof CodeModeFunction || + value instanceof ToolReference || + value instanceof IntrinsicReference || + value instanceof GlobalNamespace || + value instanceof GlobalMethodReference || + value instanceof PromiseNamespace || + value instanceof PromiseMethodReference || + value instanceof SandboxPromise || + value instanceof CoercionFunction || + value instanceof ErrorConstructorReference || + isSandboxValue(value) + +const containsRuntimeReference = (value: unknown, seen = new Set()): boolean => { + if (isRuntimeReference(value)) return true + if (value === null || typeof value !== "object") return false + if (seen.has(value)) return false + seen.add(value) + const contains = Array.isArray(value) + ? value.some((item) => containsRuntimeReference(item, seen)) + : Object.values(value).some((item) => containsRuntimeReference(item, seen)) + seen.delete(value) + return contains +} + +// Like containsRuntimeReference, but sandbox value types (Date/RegExp/Map/Set) count as data: +// operators and switch treat them as ordinary object operands (identity equality, ToPrimitive +// coercion) rather than rejecting them as opaque interpreter machinery. +const containsOpaqueReference = (value: unknown, seen = new Set()): boolean => { + if (isSandboxValue(value)) return false + if (isRuntimeReference(value)) return true + if (value === null || typeof value !== "object") return false + if (seen.has(value)) return false + seen.add(value) + const contains = Array.isArray(value) + ? value.some((item) => containsOpaqueReference(item, seen)) + : Object.values(value).some((item) => containsOpaqueReference(item, seen)) + seen.delete(value) + return contains +} + +// `typeof` never throws in JS; map every interpreter value to its JS-visible category. +// A SandboxPromise falls through to the final `typeof value` and reports "object", exactly +// like a real JS promise. +const typeofValue = (value: unknown): string => { + if ( + value instanceof CodeModeFunction || + value instanceof CoercionFunction || + value instanceof IntrinsicReference || + value instanceof GlobalMethodReference || + value instanceof PromiseMethodReference || + value instanceof PromiseNamespace || + value instanceof ErrorConstructorReference + ) + return "function" + if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object" + if (value instanceof GlobalNamespace) { + return value.name === "Math" || value.name === "JSON" || value.name === "console" ? "object" : "function" + } + return typeof value +} + +// `x instanceof C` against the constructors CodeMode knows. Like `typeof`, it observes any +// left-hand value (opaque references included) without coercing it. Error checks use the +// error brand: `instanceof Error` accepts every branded error; a specific error type matches +// its own brand only (as in JS, where TypeError instances are also Error instances). +const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => { + if (rhs instanceof ErrorConstructorReference) { + const brand = errorBrandName(lhs) + return brand !== undefined && (rhs.name === "Error" || brand === rhs.name) + } + if (rhs instanceof GlobalNamespace) { + switch (rhs.name) { + case "Date": + return lhs instanceof SandboxDate + case "RegExp": + return lhs instanceof SandboxRegExp + case "Map": + return lhs instanceof SandboxMap + case "Set": + return lhs instanceof SandboxSet + case "Array": + return Array.isArray(lhs) + case "Object": + return lhs !== null && (typeof lhs === "object" || typeofValue(lhs) === "function") + } + } + if (rhs instanceof PromiseNamespace) return lhs instanceof SandboxPromise + // Number/String/Boolean wrap primitives in JS; no boxed values exist in CodeMode, so + // `x instanceof Number` is always false - exactly what it is for primitives in JS. + if (rhs instanceof CoercionFunction && (rhs.name === "Number" || rhs.name === "String" || rhs.name === "Boolean")) { + return false + } + throw new InterpreterRuntimeError( + "The right-hand side of 'instanceof' must be a constructor CodeMode knows: Error (or a specific error type like TypeError), Date, RegExp, Map, Set, Array, Object, or Promise.", + node, + ) +} + +// A regex engine failure message without the engine's own "Invalid regular expression:" +// prefix, so composed diagnostics read as one sentence instead of stuttering the phrase. +const regexFailureReason = (error: unknown): string => + (error instanceof Error ? error.message : String(error)).replace(/^Invalid regular expression:\s*/i, "") + +const escapeRegexHint = + 'To match special characters like ( ) [ ] { } + * ? . literally, escape them with a backslash (e.g. "\\\\(") or test for them with String.includes instead.' + +// A string method's pattern argument as a host regex: a sandbox regex passes its own host +// instance through (so `g` lastIndex semantics follow the spec across calls); a string becomes +// a pattern, exactly as String.prototype.match/matchAll/search do (`extraFlags` adds matchAll's +// implicit `g`). Invalid patterns fail as catchable program errors that say what was wrong +// with the pattern and how to fix it. +const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFlags = ""): RegExp => { + if (arg instanceof SandboxRegExp) return arg.regex + if (typeof arg === "string") { + try { + return new RegExp(arg, extraFlags) + } catch (error) { + throw new InterpreterRuntimeError( + `String.${method} received the string ${JSON.stringify(arg)}, which is not a valid regular expression pattern (${regexFailureReason(error)}). ${escapeRegexHint}`, + node, + ).as("SyntaxError") + } + } + throw new InterpreterRuntimeError( + `String.${method} expects a regular expression (a /pattern/flags literal or new RegExp(...)) or a string pattern, not ${arg === null ? "null" : typeof arg}.`, + node, + ) +} + +// A host match result as a sandbox value: a plain array of the full match and captures, with +// `index` and named `groups` attached as own array properties (readable, and dropped at data +// boundaries exactly like JSON.stringify drops them in JS). `input` is omitted - it duplicates +// the whole subject string per match. +const matchToValue = (match: RegExpMatchArray): Array => { + const result: Array = Array.from(match, (group) => group) + if (match.index !== undefined) (result as Record & Array).index = match.index + if (match.groups) { + const groups: SafeObject = Object.create(null) as SafeObject + for (const [key, group] of Object.entries(match.groups)) { + if (!isBlockedMember(key)) groups[key] = group + } + ;(result as Record & Array).groups = groups + } + return result +} + +const invokeStringMethod = (value: string, name: string, args: Array, node: AstNode): unknown => { + const str = (index: number): string => { + const arg = args[index] + if (typeof arg !== "string") + throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a string.`, node) + return arg + } + const num = (index: number): number => { + const arg = args[index] + if (typeof arg !== "number") + throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a number.`, node) + return arg + } + const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index)) + const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index)) + + let result: unknown + switch (name) { + case "toLowerCase": + result = value.toLowerCase() + break + case "toUpperCase": + result = value.toUpperCase() + break + case "trim": + result = value.trim() + break + // trimLeft/trimRight are the legacy aliases of trimStart/trimEnd, kept because models write them. + case "trimStart": + case "trimLeft": + result = value.trimStart() + break + case "trimEnd": + case "trimRight": + result = value.trimEnd() + break + // Locale/options arguments are ignored: comparison runs with the host default locale, and + // the common use is a sort comparator where any consistent order works. + case "localeCompare": + result = value.localeCompare(str(0)) + break + case "normalize": { + const form = optStr(0) + try { + result = value.normalize(form) + } catch { + throw new InterpreterRuntimeError( + `String.normalize expects the form "NFC", "NFD", "NFKC", or "NFKD" (got ${JSON.stringify(form)}).`, + node, + ).as("RangeError") + } + break + } + case "split": { + if (args.length === 0) { + result = [value] + break + } + if (args[0] instanceof SandboxRegExp) { + result = value.split((args[0] as SandboxRegExp).regex, optNum(1)) + break + } + const requestedLimit = optNum(1) + result = value.split(str(0), requestedLimit === undefined ? undefined : requestedLimit >>> 0) + break + } + case "slice": + result = value.slice(optNum(0), optNum(1)) + break + case "includes": + result = value.includes(str(0), optNum(1)) + break + case "startsWith": + result = value.startsWith(str(0), optNum(1)) + break + case "endsWith": + result = value.endsWith(str(0), optNum(1)) + break + case "indexOf": + result = value.indexOf(str(0), optNum(1)) + break + case "lastIndexOf": + result = value.lastIndexOf(str(0), optNum(1)) + break + case "replace": + case "replaceAll": { + if (args[0] instanceof CodeModeFunction || args[1] instanceof CodeModeFunction) { + throw new InterpreterRuntimeError( + `String.${name} does not support function replacers in CodeMode; use match/matchAll and rebuild the string instead.`, + node, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) + } + if (args[0] instanceof SandboxRegExp) { + const pattern = (args[0] as SandboxRegExp).regex + const replacement = str(1) + if (name === "replaceAll" && !pattern.global) { + throw new InterpreterRuntimeError( + `String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.replace to replace only the first match.`, + node, + ) + } + result = name === "replace" ? value.replace(pattern, replacement) : value.replaceAll(pattern, replacement) + break + } + if (name === "replace") { + result = value.replace(str(0), str(1)) + break + } + result = value.replaceAll(str(0), str(1)) + break + } + case "match": { + const pattern = toHostRegex(args[0], name, node) + const matched = value.match(pattern) + if (matched === null) return null + // A global match is a plain array of matched strings; a non-global match carries + // index/groups own properties, so bypass the copying data checkpoint to keep them. + if (pattern.global) return boundedData(matched, "String.match result") + return matchToValue(matched) + } + case "matchAll": { + const pattern = toHostRegex(args[0], name, node, "g") + if (!pattern.global) { + throw new InterpreterRuntimeError( + `String.matchAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.match for a single match.`, + node, + ) + } + // Materialized as an array (not an iterator); each entry is a match array with + // index/groups own properties. Match count is bounded by the subject length. + return Array.from(value.matchAll(pattern), matchToValue) + } + case "search": { + result = value.search(toHostRegex(args[0], name, node)) + break + } + case "repeat": { + const count = num(0) + if (!Number.isFinite(count) || count < 0) + throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node) + result = value.repeat(count) + break + } + case "padStart": + result = value.padStart(num(0), optStr(1)) + break + case "padEnd": + result = value.padEnd(num(0), optStr(1)) + break + case "charAt": + result = value.charAt(optNum(0) ?? 0) + break + case "at": + result = value.at(optNum(0) ?? 0) + break + case "substring": + result = value.substring(optNum(0) ?? 0, optNum(1)) + break + case "substr": + result = value.substr(optNum(0) ?? 0, optNum(1)) + break + // JS charCodeAt returns NaN out of range; NaN flows as an ordinary in-sandbox value + // (normalized to null only at the data boundary - see copyOut), so return it as-is. + case "charCodeAt": + result = value.charCodeAt(optNum(0) ?? 0) + break + case "codePointAt": + result = value.codePointAt(optNum(0) ?? 0) + break + case "toString": + result = value + break + case "concat": { + result = value.concat(...args.map((_, index) => str(index))) + break + } + default: + throw new InterpreterRuntimeError(`String method '${name}' is not available in CodeMode.`, node) + } + return boundedData(result, `String.${name} result`) +} + +const invokeNumberMethod = (value: number, name: string, args: Array, node: AstNode): unknown => { + const optNum = (index: number): number | undefined => { + const arg = args[index] + if (arg === undefined) return undefined + if (typeof arg !== "number") throw new InterpreterRuntimeError(`Number.${name} expects a number argument.`, node) + return arg + } + let result: unknown + switch (name) { + case "toFixed": + result = value.toFixed(optNum(0)) + break + case "toExponential": + result = value.toExponential(optNum(0)) + break + case "toPrecision": { + const digits = optNum(0) + result = digits === undefined ? value.toString() : value.toPrecision(digits) + break + } + case "toString": { + const radix = optNum(0) + if (radix !== undefined && (radix < 2 || radix > 36)) { + throw new InterpreterRuntimeError("Number.toString radix must be between 2 and 36.", node) + } + result = value.toString(radix) + break + } + default: + throw new InterpreterRuntimeError(`Number method '${name}' is not available in CodeMode.`, node) + } + return boundedData(result, `Number.${name} result`) +} + +// JavaScript's String(...) without tripping over CodeMode's null-prototype data objects. +const coerceToString = (value: unknown): string => { + if (value === null) return "null" + if (value === undefined) return "undefined" + // Sandbox values stringify deterministically: Date as ISO (not the host's locale/timezone + // toString), RegExp as its literal form, Map/Set with their JS Object.prototype tags. + if (value instanceof SandboxDate) + return Number.isFinite(value.time) ? new Date(value.time).toISOString() : "Invalid Date" + if (value instanceof SandboxRegExp) return `/${value.regex.source}/${value.regex.flags}` + if (value instanceof SandboxMap) return "[object Map]" + if (value instanceof SandboxSet) return "[object Set]" + if (typeof value === "object") { + return Array.isArray(value) + ? value.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",") + : "[object Object]" + } + return String(value) +} + +/** Compound assignment operators (`x op= y`), each applying the binary operator `op`. */ +const compoundOperators = new Set(["+=", "-=", "*=", "/=", "%=", "**=", "&=", "|=", "^=", "<<=", ">>=", ">>>="]) + +const coerceToNumber = (value: unknown): number => { + if (value instanceof SandboxDate) return value.time + if (isSandboxValue(value)) return Number.NaN + return value !== null && typeof value === "object" && !Array.isArray(value) ? Number.NaN : Number(value) +} + +const invokeCoercion = (ref: CoercionFunction, args: Array, node: AstNode): unknown => { + // Sandbox values coerce before the data checkpoint (which would JSON-serialize them): + // Number(date) is its time value, String(date) its ISO form, Boolean(x) is true. + const raw = args[0] + if (isSandboxValue(raw)) { + if (ref.name === "Boolean") return true + if (ref.name === "Number") return coerceToNumber(raw) + if (ref.name === "String") return coerceToString(raw) + if (ref.name === "parseInt") return parseInt(coerceToString(raw)) + return parseFloat(coerceToString(raw)) + } + const value = boundedData(args[0], `${ref.name} input`) + if (ref.name === "Number") return coerceToNumber(value) + if (ref.name === "Boolean") return Boolean(value) + if (ref.name === "parseInt") { + const radix = args[1] + if (radix !== undefined && typeof radix !== "number") + throw new InterpreterRuntimeError("parseInt expects a numeric radix.", node) + return parseInt(coerceToString(value), radix) + } + if (ref.name === "parseFloat") return parseFloat(coerceToString(value)) + return coerceToString(value) +} + +const invokeObjectMethod = (name: string, args: Array, node: AstNode): unknown => { + const requireObject = (): Record => { + const value = boundedData(args[0], `Object.${name} input`) + // Sandbox values (Date/RegExp/Map/Set) have no own enumerable properties in JS, so the + // Object.* helpers see them as empty objects - never their interpreter internals. + if (isSandboxValue(value)) return {} + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new InterpreterRuntimeError(`Object.${name} expects a data object.`, node) + } + return value as Record + } + const guardedSet = (out: Record, key: string, item: unknown): void => { + if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, node) + out[key] = item + } + switch (name) { + case "keys": { + // Object.keys(array) yields index strings (["0", "1", ...]) exactly as in JS; objects + // yield their own enumerable keys. (Tool references never reach here - the interpreter + // resolves them against the host tool tree first.) + const value = boundedData(args[0], "Object.keys input") + if (isSandboxValue(value)) return [] + if (Array.isArray(value)) return Object.keys(value) + if (value === null || typeof value !== "object") { + throw new InterpreterRuntimeError("Object.keys expects a data object or array.", node) + } + return Object.keys(value) + } + case "values": + return Object.values(requireObject()) + case "entries": + return Object.entries(requireObject()).map(([key, item]) => [key, item]) + case "hasOwn": + return Object.hasOwn(requireObject(), String(args[1])) + case "assign": { + const out: Record = Object.create(null) + for (const source of args) { + if (source === null || source === undefined) continue + const value = boundedData(source, "Object.assign input") + // A sandbox value source contributes nothing (no own enumerable properties in JS). + if (isSandboxValue(value)) continue + if (value === null || typeof value !== "object" || Array.isArray(value)) + throw new InterpreterRuntimeError("Object.assign expects data objects.", node) + for (const [key, item] of Object.entries(value)) guardedSet(out, key, item) + } + return out + } + case "fromEntries": { + // A Map is the idiomatic fromEntries source; use its entries directly (the data + // checkpoint would serialize a Map to {}). + if (args[0] instanceof SandboxMap) { + const out: Record = Object.create(null) + for (const [key, item] of (args[0] as SandboxMap).map.entries()) guardedSet(out, coerceToString(key), item) + return out + } + const pairs = boundedData(args[0], "Object.fromEntries input") + if (!Array.isArray(pairs)) + throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node) + const out: Record = Object.create(null) + for (const pair of pairs) { + if (!Array.isArray(pair)) + throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] pairs.", node) + guardedSet(out, String(pair[0]), pair[1]) + } + return out + } + default: + throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node) + } +} + +const invokeMathMethod = (name: string, args: Array, node: AstNode): number => { + const nums = args.map((arg) => { + if (typeof arg !== "number") throw new InterpreterRuntimeError(`Math.${name} expects number arguments.`, node) + return arg + }) + const [a = Number.NaN, b = Number.NaN] = nums + switch (name) { + case "max": + return Math.max(...nums) + case "min": + return Math.min(...nums) + case "abs": + return Math.abs(a) + case "floor": + return Math.floor(a) + case "ceil": + return Math.ceil(a) + case "round": + return Math.round(a) + case "trunc": + return Math.trunc(a) + case "sign": + return Math.sign(a) + case "sqrt": + return Math.sqrt(a) + case "cbrt": + return Math.cbrt(a) + case "pow": + return Math.pow(a, b) + case "hypot": + return Math.hypot(...nums) + case "log": + return Math.log(a) + case "log2": + return Math.log2(a) + case "log10": + return Math.log10(a) + case "exp": + return Math.exp(a) + default: + throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node) + } +} + +const invokeJsonMethod = (name: string, args: Array, node: AstNode): unknown => { + switch (name) { + case "stringify": { + const replacer = args[1] + if (Array.isArray(replacer) || replacer instanceof CodeModeFunction) { + throw new InterpreterRuntimeError( + "JSON.stringify replacers are not supported in CodeMode.", + node, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) + } + const space = args[2] + const indent = typeof space === "number" || typeof space === "string" ? space : undefined + // copyIn first so only Data Values serialize, never a CodeModeFunction/ToolReference. + return JSON.stringify(copyOut(copyIn(args[0], "JSON.stringify value")), null, indent) + } + case "parse": { + const text = args[0] + if (typeof text !== "string") throw new InterpreterRuntimeError("JSON.parse expects a string.", node) + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch (error) { + // The engine reason is derived from the program-supplied string (token/position), so + // it is safe to surface - and the position is exactly what a model needs to fix it. + throw new InterpreterRuntimeError( + `JSON.parse received invalid JSON: ${error instanceof Error ? error.message : String(error)}`, + node, + ).as("SyntaxError") + } + return copyIn(parsed, "JSON.parse result") + } + default: + throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node) + } +} + +const invokeArrayStatic = (name: string, args: Array, node: AstNode): unknown => { + switch (name) { + case "isArray": + return Array.isArray(args[0]) + case "of": + return [...args] + case "from": { + if (args.length > 1) { + throw new InterpreterRuntimeError( + "Array.from(...) does not support a map function in CodeMode; call .map() on the result instead.", + node, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) + } + // Map/Set materialize directly (the data checkpoint would serialize them to {}). + if (args[0] instanceof SandboxMap) + return Array.from((args[0] as SandboxMap).map.entries(), ([key, item]) => [key, item]) + if (args[0] instanceof SandboxSet) return Array.from((args[0] as SandboxSet).set.values()) + const source = boundedData(args[0], "Array.from input") + if (typeof source === "string") return Array.from(source) + if (Array.isArray(source)) return [...source] + if ( + source !== null && + typeof source === "object" && + typeof (source as { length?: unknown }).length === "number" + ) { + return Array.from(source as ArrayLike) + } + throw new InterpreterRuntimeError("Array.from expects an array, string, Map, Set, or array-like value.", node) + } + default: + throw new InterpreterRuntimeError(`Array.${name} is not available in CodeMode.`, node) + } +} + +const invokeNumberStatic = (name: string, args: Array, node: AstNode): unknown => { + const value = args[0] + switch (name) { + case "isInteger": + return Number.isInteger(value) + case "isFinite": + return Number.isFinite(value) + case "isNaN": + return Number.isNaN(value) + case "isSafeInteger": + return Number.isSafeInteger(value) + case "parseInt": { + const radix = args[1] + if (radix !== undefined && typeof radix !== "number") + throw new InterpreterRuntimeError("Number.parseInt expects a numeric radix.", node) + return parseInt(coerceToString(value), radix) + } + case "parseFloat": + return parseFloat(coerceToString(value)) + default: + throw new InterpreterRuntimeError(`Number.${name} is not available in CodeMode.`, node) + } +} + +const invokeStringStatic = (name: string, args: Array, node: AstNode): unknown => { + const codes = args.map((arg) => { + if (typeof arg !== "number") throw new InterpreterRuntimeError(`String.${name} expects number arguments.`, node) + return arg + }) + switch (name) { + case "fromCharCode": + return String.fromCharCode(...codes) + case "fromCodePoint": + return String.fromCodePoint(...codes) + default: + throw new InterpreterRuntimeError(`String.${name} is not available in CodeMode.`, node) + } +} + +const invokeDateStatic = (name: string, args: Array, node: AstNode): number => { + switch (name) { + case "now": + return Date.now() + case "parse": + return Date.parse(coerceToString(args[0])) + case "UTC": { + const parts = args.map((arg) => coerceToNumber(arg)) + return Date.UTC(...(parts as Parameters)) + } + default: + throw new InterpreterRuntimeError(`Date.${name} is not available in CodeMode.`, node) + } +} + +const invokeDateMethod = (value: SandboxDate, name: string, node: AstNode): unknown => { + const hosted = new Date(value.time) + switch (name) { + case "getTime": + case "valueOf": + return value.time + case "toISOString": { + if (!Number.isFinite(value.time)) throw new InterpreterRuntimeError("Invalid time value.", node) + return hosted.toISOString() + } + // toJSON of an invalid date is null in JS (never a throw); toString stays ISO for + // determinism across host timezones/locales. + case "toJSON": + return Number.isFinite(value.time) ? hosted.toISOString() : null + case "toString": + return coerceToString(value) + case "getFullYear": + return hosted.getFullYear() + case "getMonth": + return hosted.getMonth() + case "getDate": + return hosted.getDate() + case "getDay": + return hosted.getDay() + case "getHours": + return hosted.getHours() + case "getMinutes": + return hosted.getMinutes() + case "getSeconds": + return hosted.getSeconds() + case "getMilliseconds": + return hosted.getMilliseconds() + case "getUTCFullYear": + return hosted.getUTCFullYear() + case "getUTCMonth": + return hosted.getUTCMonth() + case "getUTCDate": + return hosted.getUTCDate() + case "getUTCDay": + return hosted.getUTCDay() + case "getUTCHours": + return hosted.getUTCHours() + case "getUTCMinutes": + return hosted.getUTCMinutes() + case "getUTCSeconds": + return hosted.getUTCSeconds() + case "getUTCMilliseconds": + return hosted.getUTCMilliseconds() + case "getTimezoneOffset": + return hosted.getTimezoneOffset() + default: + throw new InterpreterRuntimeError(`Date method '${name}' is not available in CodeMode.`, node) + } +} + +const invokeRegExpMethod = (value: SandboxRegExp, name: string, args: Array, node: AstNode): unknown => { + switch (name) { + // test/exec run on the sandbox regex's own host instance, so `g`-flag lastIndex advances + // across calls per the spec. + case "test": + return value.regex.test(coerceToString(args[0])) + case "exec": { + const matched = value.regex.exec(coerceToString(args[0])) + if (matched === null) return null + return matchToValue(matched) + } + case "toString": + return coerceToString(value) + default: + throw new InterpreterRuntimeError(`RegExp method '${name}' is not available in CodeMode.`, node) + } +} + +const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array, node: AstNode): unknown => { + if (ref.namespace === "console") + throw new InterpreterRuntimeError(`console.${ref.name} is not available in CodeMode.`, node) + if (ref.namespace === "Object") return invokeObjectMethod(ref.name, args, node) + if (ref.namespace === "Math") return invokeMathMethod(ref.name, args, node) + if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node) + if (ref.namespace === "Number") return invokeNumberStatic(ref.name, args, node) + if (ref.namespace === "String") return invokeStringStatic(ref.name, args, node) + if (ref.namespace === "Date") { + if (!dateStatics.has(ref.name)) + throw new InterpreterRuntimeError(`Date.${ref.name} is not available in CodeMode.`, node) + return invokeDateStatic(ref.name, args, node) + } + if (ref.namespace === "RegExp" || ref.namespace === "Map" || ref.namespace === "Set") { + throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available in CodeMode.`, node) + } + return invokeJsonMethod(ref.name, args, node) +} + +// Iterable spread sources: arrays, strings (code points), Maps (entry pairs), and Sets (values). +const spreadItems = (spread: unknown): Array | undefined => { + if (Array.isArray(spread)) return spread + if (typeof spread === "string") return Array.from(spread) + if (spread instanceof SandboxMap) + return Array.from(spread.map.entries(), ([key, item]): Array => [key, item]) + if (spread instanceof SandboxSet) return Array.from(spread.set.values()) + return undefined +} + +// Every identifier a parameter pattern binds, used to seed TDZ slots before defaults run. +const collectPatternNames = (pattern: AstNode, out: Array = []): Array => { + switch (pattern.type) { + case "Identifier": + out.push(getString(pattern, "name")) + break + case "AssignmentPattern": + collectPatternNames(getNode(pattern, "left"), out) + break + case "RestElement": + collectPatternNames(getNode(pattern, "argument"), out) + break + case "ArrayPattern": + for (const element of getArray(pattern, "elements")) { + if (element !== null) collectPatternNames(asNode(element, "elements"), out) + } + break + case "ObjectPattern": + for (const property of getArray(pattern, "properties")) { + const prop = asNode(property, "properties") + collectPatternNames(prop.type === "RestElement" ? getNode(prop, "argument") : getNode(prop, "value"), out) + } + break + } + return out +} + +class Interpreter { + private scopes: Array> + private readonly invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect + // Enumerable namespace/tool names at a node of the host tool tree, threaded from + // ToolRuntime.make like invokeTool: the interpreter never holds the tree itself. + private readonly toolKeys: (path: ReadonlyArray) => ReadonlyArray + private readonly logs: Array + private lastValue: unknown + // Caps how many eagerly forked tool calls run at once (the parallel-call concurrency cap). + private readonly callPermits: Semaphore.Semaphore + // Fiber-backed promises whose settlement no program construct has observed yet. Successful + // program completion drains these (like a runtime waiting on in-flight work at exit) and + // surfaces a never-awaited failure as an unhandled-rejection diagnostic. + private readonly pendingSettlements = new Set() + + constructor( + invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect, + toolKeys: (path: ReadonlyArray) => ReadonlyArray, + logs: Array = [], + ) { + const globalScope = new Map() + this.scopes = [globalScope] + this.invokeTool = invokeTool + this.toolKeys = toolKeys + this.logs = logs + this.lastValue = undefined + this.callPermits = Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY) + globalScope.set("tools", { mutable: false, value: new ToolReference([]) }) + globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() }) + globalScope.set("undefined", { mutable: false, value: undefined }) + globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") }) + globalScope.set("Math", { mutable: false, value: new GlobalNamespace("Math") }) + globalScope.set("JSON", { mutable: false, value: new GlobalNamespace("JSON") }) + globalScope.set("Number", { mutable: false, value: new CoercionFunction("Number") }) + globalScope.set("String", { mutable: false, value: new CoercionFunction("String") }) + globalScope.set("Boolean", { mutable: false, value: new CoercionFunction("Boolean") }) + globalScope.set("Array", { mutable: false, value: new GlobalNamespace("Array") }) + globalScope.set("console", { mutable: false, value: new GlobalNamespace("console") }) + globalScope.set("parseInt", { mutable: false, value: new CoercionFunction("parseInt") }) + globalScope.set("parseFloat", { mutable: false, value: new CoercionFunction("parseFloat") }) + globalScope.set("Date", { mutable: false, value: new GlobalNamespace("Date") }) + globalScope.set("RegExp", { mutable: false, value: new GlobalNamespace("RegExp") }) + globalScope.set("Map", { mutable: false, value: new GlobalNamespace("Map") }) + globalScope.set("Set", { mutable: false, value: new GlobalNamespace("Set") }) + // Error constructors are real values, so `x instanceof Error` works and `Error("msg")` + // (with or without `new`) constructs a branded { name, message } error object. + for (const name of errorConstructors) { + globalScope.set(name, { mutable: false, value: new ErrorConstructorReference(name) }) + } + // NaN/Infinity flow as ordinary in-sandbox values (normalized to null only at the data + // boundary - see copyOut), so their global bindings must exist too, e.g. `reduce(max, -Infinity)`. + globalScope.set("NaN", { mutable: false, value: NaN }) + globalScope.set("Infinity", { mutable: false, value: Infinity }) + } + + run(program: ProgramNode): Effect.Effect { + const self = this + // Run the program body in its own module scope on top of the builtin global scope, so + // top-level declarations (`let undefined = 5`, `const Object = ...`) shadow builtins like + // JS module scope, instead of colliding with the seeded globals. + this.pushScope() + return Effect.gen(function* () { + self.hoistFunctions(program.body) + let value: unknown = undefined + let returned = false + for (const statement of program.body) { + const result = yield* self.evaluateStatement(statement) + + if (result.kind === "return") { + value = result.value + returned = true + break + } + + if (result.kind === "break" || result.kind === "continue") { + throw new InterpreterRuntimeError(`Unexpected '${result.kind}' outside of a loop.`, statement) + } + + if (result.kind === "value") { + self.lastValue = result.value + } + } + if (!returned) value = self.lastValue + + // The program body runs inside an implicit async function, so a returned promise + // resolves before crossing the data boundary - `return tools.ns.tool(...)` works + // without an explicit await, exactly as in JS. + if (value instanceof SandboxPromise) value = yield* self.settlePromise(value) + yield* self.drainPendingSettlements() + return value + }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + } + + // Awaits every fiber-backed promise the program abandoned (fire-and-forget tool calls), so + // their work completes before the execution ends - mirroring a JS runtime waiting on + // in-flight I/O at exit. A failure nobody could have handled becomes an unhandled-rejection + // diagnostic (interrupted calls, e.g. Promise.race losers, are ignored). + private drainPendingSettlements(): Effect.Effect { + const self = this + return Effect.gen(function* () { + for (const promise of [...self.pendingSettlements]) { + const exit = yield* self.observePromise(promise) + if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) continue + const failure = normalizeError(Cause.squash(exit.cause)) + throw new InterpreterRuntimeError( + `Unhandled rejection from an un-awaited tool call: ${failure.message}`, + undefined, + failure.kind, + ["Await tool calls - `const result = await tools.ns.tool(...)` - so failures can be caught and handled."], + ) + } + }) + } + + // Eagerly starts a tool call on a supervised child fiber (so the execution timeout and + // scope teardown interrupt it) gated by the concurrency semaphore, and wraps the fiber in a + // first-class promise value. `startImmediately` makes the runtime admit the call - charging + // the tool-call budget and firing onToolCallStart - at the call site, before any await. + private createToolCallPromise( + path: ReadonlyArray, + args: Array, + ): Effect.Effect { + const self = this + return Effect.map( + Effect.forkChild(this.callPermits.withPermit(Effect.suspend(() => self.invokeTool(path, args))), { + startImmediately: true, + }), + (fiber) => { + const promise = new SandboxPromise(fiber) + self.pendingSettlements.add(promise) + return promise + }, + ) + } + + // The promise's settlement as an Exit, marking it observed for unhandled-rejection tracking. + // Fiber settlement is idempotent, so observing the same promise repeatedly (await twice, + // Promise.all([p, p])) never re-runs the underlying call. + private observePromise(promise: SandboxPromise): Effect.Effect> { + this.pendingSettlements.delete(promise) + return promise.fiber !== undefined ? Fiber.await(promise.fiber) : Effect.exit(promise.immediate ?? Effect.void) + } + + // `await promise`: succeed with the fulfilled value or re-raise the failure so try/catch + // observes it exactly like a synchronous throw at the await site. + private settlePromise(promise: SandboxPromise, node?: AstNode): Effect.Effect { + const self = this + return Effect.flatMap(this.observePromise(promise), (exit) => self.unwrapPromiseExit(promise, exit, node)) + } + + private unwrapPromiseExit( + promise: SandboxPromise | undefined, + exit: Exit.Exit, + node?: AstNode, + ): Effect.Effect { + if (Exit.isSuccess(exit)) return Effect.succeed(exit.value) + // A call Promise.race interrupted after losing settles as a catchable program failure; + // any other interruption is execution teardown (timeout/host) and must keep propagating + // as interruption rather than becoming program-visible data. + if (promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause)) { + return Effect.fail( + new InterpreterRuntimeError( + "This tool call was interrupted because another value settled a Promise.race first.", + node, + ), + ) + } + return Effect.failCause(exit.cause) + } + + private evaluateStatement(node: AstNode): Effect.Effect { + switch (node.type) { + case "ExpressionStatement": + return Effect.map(this.evaluateExpression(getNode(node, "expression")), (value) => ({ kind: "value", value })) + case "VariableDeclaration": + return Effect.map(this.evaluateVariableDeclaration(node), () => ({ kind: "none" })) + case "ReturnStatement": { + const argumentNode = getOptionalNode(node, "argument") + return argumentNode + ? Effect.map(this.evaluateExpression(argumentNode), (value) => ({ kind: "return", value })) + : Effect.succeed({ kind: "return", value: undefined }) + } + case "BlockStatement": + return this.evaluateBlock(node) + case "IfStatement": + return this.evaluateIfStatement(node) + case "SwitchStatement": + return this.evaluateSwitchStatement(node) + case "WhileStatement": + return this.evaluateWhileStatement(node) + case "DoWhileStatement": + return this.evaluateDoWhileStatement(node) + case "ForStatement": + return this.evaluateForStatement(node) + case "ForOfStatement": + return this.evaluateForOfStatement(node) + case "ForInStatement": + return this.evaluateForInStatement(node) + case "BreakStatement": + return Effect.succeed(this.evaluateBreakStatement(node)) + case "ContinueStatement": + return Effect.succeed(this.evaluateContinueStatement(node)) + case "ThrowStatement": + return this.evaluateThrowStatement(node) + case "TryStatement": + return this.evaluateTryStatement(node) + case "EmptyStatement": + return Effect.succeed({ kind: "none" }) + case "FunctionDeclaration": + return Effect.succeed({ kind: "none" }) // bound ahead of time by hoistFunctions + default: + throw unsupportedSyntax(node.type, node) + } + } + + private evaluateBlock(node: AstNode): Effect.Effect { + this.pushScope() + const self = this + return Effect.gen(function* () { + const body = getArray(node, "body") + self.hoistFunctions(body) + + for (const statementValue of body) { + const statement = asNode(statementValue, "body") + const result = yield* self.evaluateStatement(statement) + + if (result.kind === "value") { + self.lastValue = result.value + continue + } + + if (result.kind !== "none") { + return result + } + } + + return { kind: "none" } satisfies StatementResult + }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + } + + private createFunction(node: AstNode): CodeModeFunction { + if (node.generator === true) { + throw new InterpreterRuntimeError( + "Generator functions are not supported in CodeMode.", + node, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) + } + return new CodeModeFunction( + getArray(node, "params").map((parameter, index) => asNode(parameter, `params[${index}]`)), + getNode(node, "body"), + this.scopes.slice(), + ) + } + + // Function declarations are hoisted: bound in their scope before the body runs, so a + // program can call a helper defined further down (matching JavaScript). + private hoistFunctions(statements: Array): void { + for (const statementValue of statements) { + if (!isRecord(statementValue) || statementValue.type !== "FunctionDeclaration") continue + const node = statementValue as AstNode + this.declare(getString(getNode(node, "id"), "name"), this.createFunction(node), true, node) + } + } + + private evaluateIfStatement(node: AstNode): Effect.Effect { + const testNode = getNode(node, "test") + const consequentNode = getNode(node, "consequent") + const alternateNode = getOptionalNode(node, "alternate") + + return Effect.flatMap(this.evaluateExpression(testNode), (test) => + test + ? this.evaluateStatement(consequentNode) + : alternateNode + ? this.evaluateStatement(alternateNode) + : Effect.succeed({ kind: "none" }), + ) + } + + private evaluateSwitchStatement(node: AstNode): Effect.Effect { + const self = this + this.pushScope() + return Effect.gen(function* () { + const discriminant = yield* self.evaluateExpression(getNode(node, "discriminant")) + if (containsOpaqueReference(discriminant)) { + throw new InterpreterRuntimeError( + "Switch discriminants must be data values in CodeMode.", + node, + "InvalidDataValue", + ) + } + const cases = getArray(node, "cases").map((value, index) => asNode(value, `cases[${index}]`)) + let defaultIndex: number | undefined + let selected: number | undefined + for (const [index, branch] of cases.entries()) { + const test = getOptionalNode(branch, "test") + if (!test) { + defaultIndex = index + continue + } + const candidate = yield* self.evaluateExpression(test) + if (containsOpaqueReference(candidate)) { + throw new InterpreterRuntimeError( + "Switch case values must be data values in CodeMode.", + test, + "InvalidDataValue", + ) + } + if (candidate === discriminant) { + selected = index + break + } + } + const start = selected ?? defaultIndex + if (start === undefined) return { kind: "none" } satisfies StatementResult + for (let index = start; index < cases.length; index += 1) { + for (const statementValue of getArray(cases[index]!, "consequent")) { + const result = yield* self.evaluateStatement(asNode(statementValue, "consequent")) + if (result.kind === "break") return { kind: "none" } satisfies StatementResult + if (result.kind === "return" || result.kind === "continue") return result + if (result.kind === "value") self.lastValue = result.value + } + } + return { kind: "none" } satisfies StatementResult + }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + } + + private evaluateWhileStatement(node: AstNode): Effect.Effect { + const testNode = getNode(node, "test") + const bodyNode = getNode(node, "body") + + const self = this + return Effect.gen(function* () { + while (yield* self.evaluateExpression(testNode)) { + const result = yield* self.evaluateStatement(bodyNode) + + if (result.kind === "continue") { + continue + } + + if (result.kind === "break") { + return { kind: "none" } satisfies StatementResult + } + + if (result.kind === "return") { + return result + } + + if (result.kind === "value") { + self.lastValue = result.value + } + } + + return { kind: "none" } satisfies StatementResult + }) + } + + private evaluateDoWhileStatement(node: AstNode): Effect.Effect { + const bodyNode = getNode(node, "body") + const testNode = getNode(node, "test") + + const self = this + return Effect.gen(function* () { + do { + const result = yield* self.evaluateStatement(bodyNode) + + if (result.kind === "continue") { + continue + } + + if (result.kind === "break") { + return { kind: "none" } satisfies StatementResult + } + + if (result.kind === "return") { + return result + } + + if (result.kind === "value") { + self.lastValue = result.value + } + } while (yield* self.evaluateExpression(testNode)) + + return { kind: "none" } satisfies StatementResult + }) + } + + private evaluateForStatement(node: AstNode): Effect.Effect { + this.pushScope() + const self = this + return Effect.gen(function* () { + const initNode = getOptionalNode(node, "init") + const testNode = getOptionalNode(node, "test") + const updateNode = getOptionalNode(node, "update") + const bodyNode = getNode(node, "body") + + if (initNode) { + if (initNode.type === "VariableDeclaration") { + yield* self.evaluateVariableDeclaration(initNode) + } else { + yield* self.evaluateExpression(initNode) + } + } + + const perIterationBindings = + initNode?.type === "VariableDeclaration" && getString(initNode, "kind") !== "var" + ? Array.from(self.currentScope().keys()) + : [] + + while (testNode ? yield* self.evaluateExpression(testNode) : true) { + let iterationScope: Map | undefined + if (perIterationBindings.length > 0) { + iterationScope = new Map( + perIterationBindings.map((name) => { + const binding = self.currentScope().get(name)! + return [name, { ...binding }] + }), + ) + self.scopes.push(iterationScope) + } + const result = yield* self.evaluateStatement(bodyNode).pipe( + Effect.ensuring( + Effect.sync(() => { + if (iterationScope) self.popScope() + }), + ), + ) + + if (result.kind === "return") { + return result + } + + if (result.kind === "break") { + return { kind: "none" } satisfies StatementResult + } + + if (result.kind === "value") { + self.lastValue = result.value + } + + if (iterationScope) { + const loopScope = self.currentScope() + for (const name of perIterationBindings) { + loopScope.set(name, { ...iterationScope.get(name)! }) + } + } + + if (updateNode) { + yield* self.evaluateExpression(updateNode) + } + + if (result.kind === "continue") { + continue + } + } + + return { kind: "none" } satisfies StatementResult + }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + } + + private evaluateForOfStatement(node: AstNode): Effect.Effect { + if (getBoolean(node, "await")) { + throw new InterpreterRuntimeError("for await...of is not supported.", node) + } + + const self = this + return Effect.gen(function* () { + const left = getNode(node, "left") + const right = yield* self.evaluateExpression(getNode(node, "right")) + const body = getNode(node, "body") + + // Arrays iterate in place; strings iterate code points; Maps iterate [key, value] + // pairs and Sets iterate values over a snapshot (mutation during iteration is safe). + const iterable = Array.isArray(right) ? right : spreadItems(right) + if (iterable === undefined) { + throw new InterpreterRuntimeError("for...of requires an array, string, Map, or Set value in CodeMode.", node) + } + + let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined + let assignmentName: string | undefined + + if (left.type === "VariableDeclaration") { + const declarations = getArray(left, "declarations") + if (declarations.length !== 1) { + throw new InterpreterRuntimeError("for...of supports one declared binding.", left) + } + + const declarator = asNode(declarations[0], "declarations[0]") + declaration = { pattern: getNode(declarator, "id"), mutable: getString(left, "kind") !== "const" } + } else if (left.type === "Identifier") { + assignmentName = getString(left, "name") + } else { + throw new InterpreterRuntimeError("Unsupported for...of binding.", left) + } + + for (const value of iterable) { + if (declaration) { + self.pushScope() + yield* self.declarePattern(declaration.pattern, value, declaration.mutable, left) + } else if (assignmentName) { + self.setIdentifierValue(assignmentName, value, left) + } + + const result = yield* self.evaluateStatement(body).pipe( + Effect.ensuring( + Effect.sync(() => { + if (declaration) self.popScope() + }), + ), + ) + + if (result.kind === "return") { + return result + } + + if (result.kind === "break") { + return { kind: "none" } + } + + if (result.kind === "value") { + self.lastValue = result.value + } + + if (result.kind === "continue") { + continue + } + } + + return { kind: "none" } + }) + } + + // Own enumerable string keys of a value, shared by `for...in` and `Object.keys` over tool + // references: plain data objects enumerate their own keys, arrays their index strings (plus + // any own non-index properties, e.g. match results' index/groups - exactly Object.keys in + // JS), and a tool reference the namespace/tool names at its path in the host tool tree. + // Returns undefined for everything else so callers can raise a contextual error. + private enumerableKeys(value: unknown): Array | undefined { + if (value instanceof ToolReference) { + return [...this.toolKeys(value.path)] + } + if (Array.isArray(value)) { + return Object.keys(value) + } + if (value !== null && typeof value === "object" && !isRuntimeReference(value)) { + return Object.keys(value) + } + return undefined + } + + private evaluateForInStatement(node: AstNode): Effect.Effect { + const self = this + return Effect.gen(function* () { + const left = getNode(node, "left") + const right = yield* self.evaluateExpression(getNode(node, "right")) + const body = getNode(node, "body") + + // Keys are snapshotted up front (mutation during iteration is safe): plain objects + // enumerate their own keys, arrays their index strings, and tool references the + // namespace/tool names at that node - the same enumeration Object.keys performs. + // Anything else (strings, Maps, Sets, numbers, null, ...) is a deliberate error rather + // than real JS's surprising behavior (indices for strings, zero iterations for + // Maps/Sets/null): the hint points at the constructs that do what the program means. + const keys = self.enumerableKeys(right) + if (keys === undefined) { + throw new InterpreterRuntimeError( + "for...in requires a plain object, array, or tools reference in CodeMode. Use for...of for arrays/strings/Maps/Sets, or Object.keys(value) for a key list.", + node, + ) + } + + let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined + let assignmentName: string | undefined + + if (left.type === "VariableDeclaration") { + const declarations = getArray(left, "declarations") + if (declarations.length !== 1) { + throw new InterpreterRuntimeError("for...in supports one declared binding.", left) + } + + const declarator = asNode(declarations[0], "declarations[0]") + declaration = { pattern: getNode(declarator, "id"), mutable: getString(left, "kind") !== "const" } + } else if (left.type === "Identifier") { + assignmentName = getString(left, "name") + } else { + throw new InterpreterRuntimeError("Unsupported for...in binding.", left) + } + + for (const key of keys) { + if (declaration) { + self.pushScope() + yield* self.declarePattern(declaration.pattern, key, declaration.mutable, left) + } else if (assignmentName) { + self.setIdentifierValue(assignmentName, key, left) + } + + const result = yield* self.evaluateStatement(body).pipe( + Effect.ensuring( + Effect.sync(() => { + if (declaration) self.popScope() + }), + ), + ) + + if (result.kind === "return") { + return result + } + + if (result.kind === "break") { + return { kind: "none" } + } + + if (result.kind === "value") { + self.lastValue = result.value + } + + if (result.kind === "continue") { + continue + } + } + + return { kind: "none" } + }) + } + + private evaluateBreakStatement(node: AstNode): StatementResult { + const labelNode = getOptionalNode(node, "label") + + if (labelNode) { + throw new InterpreterRuntimeError("Labeled break is not supported in v1.", node) + } + + return { kind: "break" } + } + + private evaluateContinueStatement(node: AstNode): StatementResult { + const labelNode = getOptionalNode(node, "label") + + if (labelNode) { + throw new InterpreterRuntimeError("Labeled continue is not supported in v1.", node) + } + + return { kind: "continue" } + } + + private evaluateThrowStatement(node: AstNode): Effect.Effect { + const argument = getNode(node, "argument") + return Effect.flatMap(this.evaluateExpression(argument), (value) => Effect.fail(new ProgramThrow(value))) + } + + private evaluateTryStatement(node: AstNode): Effect.Effect { + const body = getNode(node, "block") + const handler = getOptionalNode(node, "handler") + const finalizer = getOptionalNode(node, "finalizer") + const self = this + + const attempted = Effect.matchCauseEffect(this.evaluateStatement(body), { + onFailure: (cause) => { + if (cause.reasons.some(Cause.isInterruptReason) || !handler) { + return Effect.failCause(cause) + } + + // The program sees a plain { message } error (or the thrown value itself) - see + // caughtErrorValue, shared with Promise.allSettled rejection reasons. + const caught = caughtErrorValue(Cause.squash(cause)) + const parameter = getOptionalNode(handler, "param") + self.pushScope() + return Effect.gen(function* () { + if (parameter) yield* self.declarePattern(parameter, caught, true, handler) + return yield* self.evaluateStatement(getNode(handler, "body")) + }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + }, + onSuccess: Effect.succeed, + }) + + if (!finalizer) return attempted + + const isAbrupt = (result: StatementResult): boolean => + result.kind === "return" || result.kind === "break" || result.kind === "continue" + + return Effect.matchCauseEffect(attempted, { + onFailure: (cause) => + cause.reasons.some(Cause.isInterruptReason) + ? Effect.failCause(cause) + : Effect.flatMap(this.evaluateStatement(finalizer), (final) => + isAbrupt(final) ? Effect.succeed(final) : Effect.failCause(cause), + ), + onSuccess: (result) => + Effect.flatMap(this.evaluateStatement(finalizer), (final) => + isAbrupt(final) ? Effect.succeed(final) : Effect.succeed(result), + ), + }) + } + + private evaluateVariableDeclaration(node: AstNode): Effect.Effect { + const kind = getString(node, "kind") + const declarations = getArray(node, "declarations") + const self = this + return Effect.gen(function* () { + for (const declarationValue of declarations) { + const declaration = asNode(declarationValue, "declarations") + + if (declaration.type !== "VariableDeclarator") { + throw new InterpreterRuntimeError("Unsupported variable declaration shape.", declaration) + } + + const init = getOptionalNode(declaration, "init") + const value = init ? yield* self.evaluateExpression(init) : undefined + yield* self.declarePattern(getNode(declaration, "id"), value, kind !== "const", declaration) + } + }) + } + + private declarePattern( + pattern: AstNode, + value: unknown, + mutable: boolean, + node: AstNode, + ): Effect.Effect { + const self = this + return Effect.gen(function* () { + if (pattern.type === "Identifier") { + self.declare(getString(pattern, "name"), value, mutable, node) + return + } + + // Default values: `x = expr` / `{ a = 1 }` - the default is evaluated only when the value is undefined. + if (pattern.type === "AssignmentPattern") { + const resolved = value === undefined ? yield* self.evaluateExpression(getNode(pattern, "right")) : value + yield* self.declarePattern(getNode(pattern, "left"), resolved, mutable, node) + return + } + + if (pattern.type === "ObjectPattern") { + if (value === null || typeof value !== "object" || Array.isArray(value) || isRuntimeReference(value)) { + throw new InterpreterRuntimeError( + "Object destructuring requires a data object value.", + pattern, + "InvalidDataValue", + ) + } + + const consumed = new Set() + for (const propertyValue of getArray(pattern, "properties")) { + const property = asNode(propertyValue, "properties") + + // Object rest: `{ a, ...others }` - gather the not-yet-consumed own keys. + if (property.type === "RestElement") { + const rest: SafeObject = Object.create(null) as SafeObject + for (const [key, item] of Object.entries(value as SafeObject)) { + if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item + } + yield* self.declarePattern(getNode(property, "argument"), rest, mutable, property) + continue + } + + if ( + property.type !== "Property" || + getBoolean(property, "computed") || + getString(property, "kind") !== "init" + ) { + throw new InterpreterRuntimeError("Only named object destructuring properties are supported.", property) + } + + const keyNode = getNode(property, "key") + const key = keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value) + if (isBlockedMember(key)) { + throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, keyNode) + } + consumed.add(key) + yield* self.declarePattern(getNode(property, "value"), (value as SafeObject)[key], mutable, property) + } + return + } + + if (pattern.type === "ArrayPattern") { + if (!Array.isArray(value)) { + throw new InterpreterRuntimeError("Array destructuring requires an array value.", pattern) + } + + for (const [index, item] of getArray(pattern, "elements").entries()) { + if (item === null) continue + const element = asNode(item, `elements[${index}]`) + // Array rest: `[head, ...tail]` - binds the remaining elements (must be last). + if (element.type === "RestElement") { + yield* self.declarePattern(getNode(element, "argument"), value.slice(index), mutable, element) + break + } + yield* self.declarePattern(element, value[index], mutable, pattern) + } + return + } + + throw new InterpreterRuntimeError(`Unsupported binding pattern '${pattern.type}'.`, pattern) + }) + } + + private evaluateExpression(node: AstNode): Effect.Effect { + switch (node.type) { + case "Literal": { + // A regex literal parses as a Literal node carrying { pattern, flags }; construct the + // sandbox regex from those (the host `value` instance is never exposed). + const regex = node.regex + if (isRecord(regex) && typeof regex.pattern === "string") { + return Effect.sync(() => + this.constructRegExp([regex.pattern, typeof regex.flags === "string" ? regex.flags : ""], node), + ) + } + return Effect.sync(() => boundedData(node.value, "Literal")) + } + case "Identifier": + return Effect.sync(() => this.getIdentifierValue(getString(node, "name"), node)) + case "BinaryExpression": + return this.evaluateBinaryExpression(node) + case "LogicalExpression": + return this.evaluateLogicalExpression(node) + case "UnaryExpression": + return this.evaluateUnaryExpression(node) + case "AssignmentExpression": + return this.evaluateAssignmentExpression(node) + case "CallExpression": + return this.evaluateCallExpression(node) + case "ArrowFunctionExpression": + case "FunctionExpression": + return Effect.sync(() => this.createFunction(node)) + case "MemberExpression": + return this.readMember(node) + case "ChainExpression": + return Effect.map(this.evaluateExpression(getNode(node, "expression")), (value) => + value === OptionalShortCircuit ? undefined : value, + ) + case "ObjectExpression": + return this.evaluateObjectExpression(node) + case "ArrayExpression": + return this.evaluateArrayExpression(node) + case "TemplateLiteral": + return this.evaluateTemplateLiteral(node) + case "ConditionalExpression": + return this.evaluateConditionalExpression(node) + case "UpdateExpression": + return this.evaluateUpdateExpression(node) + case "AwaitExpression": { + // `await` resolves a promise value; awaiting anything else is a passthrough no-op, + // matching real JS semantics for non-thenables. + const self = this + return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) => + value instanceof SandboxPromise ? self.settlePromise(value, node) : Effect.succeed(value), + ) + } + case "NewExpression": + return this.evaluateNewExpression(node) + default: + throw unsupportedSyntax(node.type, node) + } + } + + private evaluateNewExpression(node: AstNode): Effect.Effect { + const callee = getNode(node, "callee") + if (callee.type !== "Identifier") { + throw unsupportedSyntax("NewExpression", node) + } + const name = getString(callee, "name") + const argNodes = getArray(node, "arguments") + const self = this + if (name === "Promise") { + throw new InterpreterRuntimeError( + "new Promise(...) is not supported in CodeMode; tool calls already return promises - call the tool and await the result.", + node, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) + } + if (errorConstructors.has(name)) { + return Effect.gen(function* () { + const arg = + argNodes.length > 0 ? yield* self.evaluateExpression(asNode(argNodes[0], "arguments[0]")) : undefined + return createErrorValue(name, arg === undefined ? "" : coerceToString(arg)) + }) + } + if (valueConstructors.has(name)) { + return Effect.gen(function* () { + const args = yield* self.evaluateCallArguments(argNodes) + switch (name) { + case "Date": + return self.constructDate(args) + case "RegExp": + return self.constructRegExp(args, node) + case "Map": + return self.constructMap(args[0], node) + default: + return self.constructSet(args[0], node) + } + }) + } + throw unsupportedSyntax("NewExpression", node) + } + + private constructDate(args: Array): SandboxDate { + if (args.length === 0) return new SandboxDate(Date.now()) + if (args.length === 1) { + const arg = args[0] + if (arg instanceof SandboxDate) return new SandboxDate(arg.time) + if (typeof arg === "number") return new SandboxDate(new Date(arg).getTime()) + if (typeof arg === "string") return new SandboxDate(Date.parse(arg)) + return new SandboxDate(Number.NaN) + } + // new Date(year, month, day?, hours?, ...) - local-time component form. + const parts = args.map((arg) => coerceToNumber(arg)) + return new SandboxDate(new Date(...(parts as [number, number])).getTime()) + } + + private constructRegExp(args: Array, node: AstNode): SandboxRegExp { + const first = args[0] + const pattern = + first instanceof SandboxRegExp ? first.regex.source : first === undefined ? "" : coerceToString(first) + const flagsArg = args[1] + if (flagsArg !== undefined && typeof flagsArg !== "string") { + throw new InterpreterRuntimeError( + `RegExp flags must be a string of flag characters (e.g. "g", "gi"), not ${flagsArg === null ? "null" : typeof flagsArg}.`, + node, + ) + } + const flags = flagsArg ?? (first instanceof SandboxRegExp ? first.regex.flags : "") + try { + return new SandboxRegExp(pattern, flags) + } catch (error) { + // Say which part was rejected and how to fix it, instead of passing the engine + // message through bare. A flags failure names the flags; a pattern failure gets the + // escaping hint (the usual cause is an unescaped metacharacter in a built-up string). + const reason = regexFailureReason(error) + throw new InterpreterRuntimeError( + /flag/i.test(reason) + ? `new RegExp(...) received invalid flags ${JSON.stringify(flags)} (${reason}). Valid flags are d, g, i, m, s, u, v, and y.` + : `new RegExp(...) received ${JSON.stringify(pattern)}, which is not a valid regular expression pattern (${reason}). ${escapeRegexHint}`, + node, + ).as("SyntaxError") + } + } + + private constructMap(init: unknown, node: AstNode): SandboxMap { + const target = new SandboxMap() + if (init === undefined || init === null) return target + const entries = Array.isArray(init) + ? init + : init instanceof SandboxMap + ? Array.from(init.map.entries(), ([key, item]): Array => [key, item]) + : undefined + if (entries === undefined) { + throw new InterpreterRuntimeError( + "new Map(...) expects an array of [key, value] pairs, a Map, or no argument.", + node, + ) + } + for (const pair of entries) { + if (!Array.isArray(pair)) { + throw new InterpreterRuntimeError("new Map(...) expects [key, value] pairs.", node) + } + target.map.set(pair[0], pair[1]) + } + return target + } + + private constructSet(init: unknown, node: AstNode): SandboxSet { + const target = new SandboxSet() + if (init === undefined || init === null) return target + const items = Array.isArray(init) + ? init + : init instanceof SandboxSet + ? Array.from(init.set.values()) + : typeof init === "string" + ? Array.from(init) + : undefined + if (items === undefined) { + throw new InterpreterRuntimeError("new Set(...) expects an array, Set, string, or no argument.", node) + } + for (const item of items) target.set.add(item) + return target + } + + private evaluateBinaryExpression(node: AstNode): Effect.Effect { + const operator = getString(node, "operator") + const self = this + return Effect.gen(function* () { + const lhs = yield* self.evaluateExpression(getNode(node, "left")) + const rhs = yield* self.evaluateExpression(getNode(node, "right")) + // Like `typeof`, `instanceof` observes any value without coercing it (a promise or + // function operand is a legitimate question, not an error), so it is handled before + // the data-only operand check. + if (operator === "instanceof") return instanceofValue(lhs, rhs, node) + return boundedData(self.applyBinaryOperator(operator, lhs, rhs, node), "Binary expression result") + }) + } + + /** + * Applies a binary operator to two already-evaluated operands with CodeMode's coercion + * semantics. Shared by binary expressions and compound assignment (`x op= y` must behave + * exactly like `x = x op y`, coercion included). + */ + private applyBinaryOperator(operator: string, lhs: unknown, rhs: unknown, node: AstNode): unknown { + if (containsOpaqueReference(lhs) || containsOpaqueReference(rhs)) { + throw new InterpreterRuntimeError("Binary operators require data values in CodeMode.", node, "InvalidDataValue") + } + // Data objects/arrays are null-prototype, so JS's ToPrimitive throws an opaque host + // "No default value" TypeError when an operator coerces them. Coerce to their JS string + // form first (as String(x) / template literals do) so operators behave like JavaScript. + // A Date follows its ToPrimitive hints: string for `+` (concatenation), its time value + // for arithmetic and ordering - so `end - start` and `a < b` work as in JS. + // Identity (=== / !==) and the right operand of `in` keep their raw object value. + const coerceOperand = (operand: unknown): unknown => { + if (operand instanceof SandboxDate) return operator === "+" ? coerceToString(operand) : operand.time + return operand !== null && typeof operand === "object" ? coerceToString(operand) : operand + } + const bothObjects = lhs !== null && typeof lhs === "object" && rhs !== null && typeof rhs === "object" + const l = coerceOperand(lhs) + const r = coerceOperand(rhs) + switch (operator) { + case "+": + return (l as string) + (r as string) + case "-": + return (l as number) - (r as number) + case "*": + return (l as number) * (r as number) + case "/": + return (l as number) / (r as number) + case "%": + return (l as number) % (r as number) + case "**": + return (l as number) ** (r as number) + // Two objects compare by identity in JS (no ToPrimitive); only object-vs-primitive coerces. + case "==": + return bothObjects ? lhs === rhs : l == r + case "===": + return lhs === rhs + case "!=": + return bothObjects ? lhs !== rhs : l != r + case "!==": + return lhs !== rhs + case "<": + return (l as string) < (r as string) + case "<=": + return (l as string) <= (r as string) + case ">": + return (l as string) > (r as string) + case ">=": + return (l as string) >= (r as string) + case "&": + return (l as number) & (r as number) + case "|": + return (l as number) | (r as number) + case "^": + return (l as number) ^ (r as number) + case "<<": + return (l as number) << (r as number) + case ">>": + return (l as number) >> (r as number) + case ">>>": + return (l as number) >>> (r as number) + case "in": + if (rhs === null || typeof rhs !== "object") { + throw new InterpreterRuntimeError("The 'in' operator requires a data object on the right-hand side.", node) + } + // Own properties only, so arrays don't leak the host Array.prototype (map/constructor/...). + return Object.hasOwn(rhs as object, coerceOperand(lhs) as PropertyKey) + default: + throw new InterpreterRuntimeError(`Unsupported binary operator '${operator}'.`, node) + } + } + + private evaluateLogicalExpression(node: AstNode): Effect.Effect { + const operator = getString(node, "operator") + return Effect.flatMap(this.evaluateExpression(getNode(node, "left")), (left) => { + if (operator === "&&") return left ? this.evaluateExpression(getNode(node, "right")) : Effect.succeed(left) + if (operator === "||") return left ? Effect.succeed(left) : this.evaluateExpression(getNode(node, "right")) + if (operator === "??") + return left !== null && left !== undefined + ? Effect.succeed(left) + : this.evaluateExpression(getNode(node, "right")) + throw new InterpreterRuntimeError(`Unsupported logical operator '${operator}'.`, node) + }) + } + + private evaluateUnaryExpression(node: AstNode): Effect.Effect { + const operator = getString(node, "operator") + const argument = getNode(node, "argument") + // `typeof undeclaredIdentifier` is `"undefined"` in JS (never a ReferenceError), so + // feature-detection guards like `typeof x !== "undefined"` don't crash. Short-circuit before + // evaluating the argument; a declared-but-TDZ binding still falls through to the normal throw. + if (operator === "typeof" && argument.type === "Identifier" && !this.resolveBinding(getString(argument, "name"))) { + return Effect.succeed("undefined") + } + return Effect.map(this.evaluateExpression(argument), (value) => { + // `typeof` and `!` never throw in JS - they observe any value (functions and runtime + // references included) without coercing it, so feature detection and negation work. + if (operator === "typeof") return typeofValue(value) + if (operator === "!") return !value + if (containsOpaqueReference(value)) { + throw new InterpreterRuntimeError("Unary operators require data values in CodeMode.", node, "InvalidDataValue") + } + // Numeric/bitwise unary operators ToPrimitive their operand; a Date yields its time value + // (`+date` is the epoch-ms idiom), other null-prototype data objects/arrays coerce to + // their JS string form first (see evaluateBinaryExpression). + const operand = + value instanceof SandboxDate + ? value.time + : value !== null && typeof value === "object" + ? coerceToString(value) + : value + let result: unknown + switch (operator) { + case "+": + result = +(operand as number) + break + case "-": + result = -(operand as number) + break + case "~": + result = ~(operand as number) + break + default: + throw new InterpreterRuntimeError(`Unsupported unary operator '${operator}'.`, node) + } + return boundedData(result, "Unary expression result") + }) + } + + private evaluateAssignmentExpression(node: AstNode): Effect.Effect { + const left = getNode(node, "left") + const operator = getString(node, "operator") + const self = this + return Effect.gen(function* () { + if (operator === "??=" || operator === "||=" || operator === "&&=") { + return yield* self.evaluateLogicalAssignment(node, left, operator) + } + const rightValue = yield* self.evaluateExpression(getNode(node, "right")) + if (left.type === "Identifier") { + const name = getString(left, "name") + if (operator === "=") return self.setIdentifierValue(name, rightValue, left) + const next = boundedData( + self.applyCompoundAssignment(operator, self.getIdentifierValue(name, left), rightValue, node), + "Assignment result", + ) + return self.setIdentifierValue(name, next, left) + } + if (left.type === "MemberExpression") { + if (operator === "=") return yield* self.writeMember(left, rightValue) + return yield* self.modifyMember(left, (current) => { + const next = boundedData( + self.applyCompoundAssignment(operator, current, rightValue, node), + "Assignment result", + ) + return Effect.succeed({ write: true, next, result: next }) + }) + } + throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left) + }) + } + + private evaluateLogicalAssignment( + node: AstNode, + left: AstNode, + operator: string, + ): Effect.Effect { + const self = this + const shouldAssign = (current: unknown): boolean => + operator === "??=" ? current === null || current === undefined : operator === "||=" ? !current : Boolean(current) + if (left.type === "Identifier") { + const name = getString(left, "name") + return Effect.gen(function* () { + const current = self.getIdentifierValue(name, left) + if (!shouldAssign(current)) return current + const rightValue = yield* self.evaluateExpression(getNode(node, "right")) + return self.setIdentifierValue(name, rightValue, left) + }) + } + if (left.type === "MemberExpression") { + // Resolve the member exactly once; evaluate the RHS only if we actually assign. + return self.modifyMember(left, (current) => + shouldAssign(current) + ? Effect.map(self.evaluateExpression(getNode(node, "right")), (rightValue) => ({ + write: true, + next: rightValue, + result: rightValue, + })) + : Effect.succeed({ write: false, next: current, result: current }), + ) + } + throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left) + } + + private evaluateUpdateExpression(node: AstNode): Effect.Effect { + const operator = getString(node, "operator") + const argument = getNode(node, "argument") + const prefix = getBoolean(node, "prefix") + + const increment = operator === "++" ? 1 : operator === "--" ? -1 : undefined + + if (increment === undefined) { + throw new InterpreterRuntimeError(`Unsupported update operator '${operator}'.`, node) + } + + if (argument.type === "Identifier") { + return Effect.sync(() => { + const name = getString(argument, "name") + const current = Number(this.getIdentifierValue(name, argument)) + const next = current + increment + this.setIdentifierValue(name, next, argument) + return prefix ? next : current + }) + } + + if (argument.type === "MemberExpression") { + return this.modifyMember(argument, (current) => { + const value = Number(current) + const next = value + increment + return Effect.succeed({ write: true, next, result: prefix ? next : value }) + }) + } + + throw new InterpreterRuntimeError("Update target must be an Identifier or MemberExpression.", argument) + } + + private evaluateCallExpression(node: AstNode): Effect.Effect { + const callee = getNode(node, "callee") + const argNodes = getArray(node, "arguments") + + const self = this + return Effect.gen(function* () { + const callable = yield* self.evaluateExpression(callee) + if (callable === OptionalShortCircuit) return OptionalShortCircuit + if ((callable === null || callable === undefined) && node.optional === true) return OptionalShortCircuit + + const args = yield* self.evaluateCallArguments(argNodes) + + if (callable instanceof ToolReference) { + if (callable.path.length === 0) throw new InterpreterRuntimeError("The tools root is not callable.", callee) + // An un-awaited tool call is a first-class promise value; the call itself starts now. + return yield* self.createToolCallPromise(callable.path, args) + } + if (callable instanceof PromiseMethodReference) { + return yield* self.invokePromiseMethod(callable, args, node) + } + if (callable instanceof CodeModeFunction) { + return yield* self.invokeFunction(callable, args) + } + if (callable instanceof IntrinsicReference) { + return yield* self.invokeIntrinsic(callable, args, node) + } + if (callable instanceof GlobalMethodReference) { + if (callable.namespace === "console") return self.invokeConsole(callable.name, args, node) + if (callable.namespace === "Object" && args[0] instanceof ToolReference) { + return self.invokeObjectMethodOnTools(callable.name, args[0] as ToolReference, node) + } + return boundedData(invokeGlobalMethod(callable, args, node), `${callable.namespace}.${callable.name} result`) + } + if (callable instanceof CoercionFunction) { + return boundedData(invokeCoercion(callable, args, node), `${callable.name} result`) + } + // `Error("msg")` without `new` constructs an error exactly like `new Error("msg")`, as in JS. + if (callable instanceof ErrorConstructorReference) { + return createErrorValue(callable.name, args[0] === undefined ? "" : coerceToString(args[0])) + } + throw new InterpreterRuntimeError("Only tools are callable in CodeMode.", callee) + }) + } + + // Object.* over a tool reference: `Object.keys(tools)` / `Object.keys(tools.ns)` enumerate + // namespace/tool names from the host tool tree - the discovery idiom a model reaches for + // first. Every other Object helper cannot produce data from a tool reference, so it fails + // with a pointer at the working idioms instead of the generic plain-objects-only message. + private invokeObjectMethodOnTools(name: string, ref: ToolReference, node: AstNode): unknown { + if (name === "keys") { + return boundedData(this.enumerableKeys(ref)!, "Object.keys result") + } + throw new InterpreterRuntimeError( + `Object.${name}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or tools.$codemode.search({ query }) for signatures.`, + node, + "InvalidDataValue", + ) + } + + private invokeConsole(name: string, args: Array, node: AstNode): undefined { + if (!consoleMethods.has(name)) + throw new InterpreterRuntimeError(`console.${name} is not available in CodeMode.`, node) + this.logs.push(publicErrorMessage(this.formatConsoleMessage(name, args, node))) + return undefined + } + + private formatConsoleMessage(name: string, args: Array, node: AstNode): string { + if (name === "dir") return args.length === 0 ? "undefined" : this.formatConsoleArgument(args[0]) + if (name === "table") return this.formatConsoleTable(args[0], args[1], node) + const prefix = name === "warn" ? "[warn] " : name === "error" ? "[error] " : name === "debug" ? "[debug] " : "" + return `${prefix}${args.map((arg) => this.formatConsoleArgument(arg)).join(" ")}` + } + + // Console arguments format deeply and totally: values render as a debugger would show them + // rather than as boundary JSON - numbers keep NaN/Infinity (JSON would say null), sandbox + // values keep their friendly forms at ANY depth (ISO date, /regex/flags, Map(n) [...], + // Set(n) [...]), opaque runtime references become "[CodeMode reference]" markers in place, + // and plain objects/arrays render JSON-style. Formatting never fails the program: cycles + // render "[Circular]" and extreme depth degrades to "...". + private formatConsoleArgument(value: unknown): string { + if (value === undefined) return "undefined" + // A top-level string prints bare; nested strings are JSON-quoted (see formatConsoleValue). + if (typeof value === "string") return value + return this.formatConsoleValue(value, new Set(), 0) + } + + private formatConsoleValue(value: unknown, seen: Set, depth: number): string { + // Nested undefined renders as null, matching what JSON boundary output would show. + if (value === null || value === undefined) return "null" + if (typeof value === "string") return JSON.stringify(value) + // String(value) keeps NaN/Infinity/-Infinity readable; finite numbers match their JSON form. + if (typeof value === "number" || typeof value === "boolean") return String(value) + if (typeof value !== "object") return String(value) + if (value instanceof SandboxPromise) return "[Promise (await it to get its value)]" + if (value instanceof SandboxDate) return coerceToString(value) + if (value instanceof SandboxRegExp) return coerceToString(value) + if (depth > MAX_CONSOLE_DEPTH) return "..." + if (seen.has(value)) return "[Circular]" + if (value instanceof SandboxMap) { + seen.add(value) + try { + const entries = Array.from(value.map.entries(), ([key, item]): Array => [key, item]) + return `Map(${value.map.size}) ${this.formatConsoleValue(entries, seen, depth + 1)}` + } finally { + seen.delete(value) + } + } + if (value instanceof SandboxSet) { + seen.add(value) + try { + return `Set(${value.set.size}) ${this.formatConsoleValue(Array.from(value.set.values()), seen, depth + 1)}` + } finally { + seen.delete(value) + } + } + if (isRuntimeReference(value)) return "[CodeMode reference]" + seen.add(value) + try { + if (Array.isArray(value)) { + return `[${value.map((item) => this.formatConsoleValue(item, seen, depth + 1)).join(",")}]` + } + return `{${Object.entries(value) + .map(([key, item]) => `${JSON.stringify(key)}:${this.formatConsoleValue(item, seen, depth + 1)}`) + .join(",")}}` + } finally { + seen.delete(value) + } + } + + private formatConsoleTable(value: unknown, columnsArgument: unknown, node: AstNode): string { + if (value === undefined) return "undefined" + // Sandbox values are legitimate table data (cells render their friendly forms); only + // truly opaque references (functions, tools, promises) collapse to the marker. + if (containsOpaqueReference(value)) return "[CodeMode reference]" + const data = boundedData(value, "console.table argument") + const columns = this.consoleTableColumns(columnsArgument, node) + const rows = this.consoleTableRows(data, columns) + const keys = columns ?? Array.from(new Set(rows.flatMap((row) => Object.keys(row.values)))) + const header = ["(index)", ...keys].join("\t") + return [ + header, + ...rows.map((row) => [row.index, ...keys.map((key) => this.formatConsoleTableCell(row.values[key]))].join("\t")), + ].join("\n") + } + + private consoleTableColumns(value: unknown, node: AstNode): ReadonlyArray | undefined { + if (value === undefined) return undefined + if (containsRuntimeReference(value)) return undefined + const columns = copyOut(copyIn(value, "console.table columns"), true) + return Array.isArray(columns) ? columns.map((column) => String(column)) : undefined + } + + private consoleTableRows( + data: unknown, + columns: ReadonlyArray | undefined, + ): Array<{ readonly index: string; readonly values: Record }> { + if (Array.isArray(data)) { + return data.map((item, index) => ({ index: String(index), values: this.consoleTableValues(item, columns) })) + } + if (data !== null && typeof data === "object" && !isSandboxValue(data)) { + return Object.entries(data).map(([index, item]) => ({ index, values: this.consoleTableValues(item, columns) })) + } + return [{ index: "0", values: { Value: data } }] + } + + private consoleTableValues(value: unknown, columns: ReadonlyArray | undefined): Record { + if (value !== null && typeof value === "object" && !Array.isArray(value) && !isSandboxValue(value)) { + const source = value as Record + if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, source[column]])) + return Object.fromEntries(Object.entries(source)) + } + return { Value: value } + } + + private formatConsoleTableCell(value: unknown): string { + if (value === undefined) return "" + if (typeof value === "string") return value + return this.formatConsoleValue(value, new Set(), 0) + } + + private evaluateCallArguments(argNodes: Array): Effect.Effect, unknown, R> { + const self = this + return Effect.gen(function* () { + const args: Array = [] + for (const [index, arg] of argNodes.entries()) { + const argNode = asNode(arg, `arguments[${index}]`) + if (argNode.type === "SpreadElement") { + const spread = yield* self.evaluateExpression(getNode(argNode, "argument")) + const items = spreadItems(spread) + if (items === undefined) + throw new InterpreterRuntimeError( + "Spread arguments require an array, string, Map, or Set in CodeMode.", + argNode, + ) + args.push(...items) + } else { + args.push(yield* self.evaluateExpression(argNode)) + } + } + return args + }) + } + + // Promise.* over ordinary runtime values. Combinators accept ANY array (or spreadable + // collection) mixing promise values and plain data - built inline, beforehand, via spread, + // whatever - because tool calls already run eagerly on their own fibers; the combinators + // only observe settlements. Joining is therefore sequential (no extra fibers) without + // costing parallelism, and the concurrency cap stays where the work is: the fork semaphore. + private invokePromiseMethod( + ref: PromiseMethodReference, + args: Array, + node: AstNode, + ): Effect.Effect { + const self = this + if (ref.name === "resolve") { + // Promise.resolve of a promise is that promise (JS flattens); anything else is a + // promise already fulfilled with the value. + const value = args[0] + return Effect.succeed( + value instanceof SandboxPromise ? value : new SandboxPromise(undefined, Effect.succeed(value)), + ) + } + if (ref.name === "reject") { + return Effect.sync(() => new SandboxPromise(undefined, Effect.fail(new ProgramThrow(args[0])))) + } + + const items = Array.isArray(args[0]) ? args[0] : spreadItems(args[0]) + if (items === undefined) { + throw new InterpreterRuntimeError( + `Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`, + node, + ) + } + + switch (ref.name) { + case "all": { + // Mark every promise element observed up-front (Promise.all handles all of its + // members' failures, as in JS), then join in index order; the first failure rejects + // the whole call while unrelated in-flight members keep running. + const settles = items.map((item) => + item instanceof SandboxPromise ? this.settlePromise(item, node) : Effect.succeed(item), + ) + return Effect.gen(function* () { + const values: Array = [] + for (const settle of settles) values.push(yield* settle) + return values + }) + } + case "allSettled": { + const observations = items.map((item) => + item instanceof SandboxPromise + ? Effect.map(this.observePromise(item), (exit) => ({ promise: item as SandboxPromise | undefined, exit })) + : Effect.succeed({ promise: undefined as SandboxPromise | undefined, exit: Exit.succeed(item as unknown) }), + ) + return Effect.gen(function* () { + const outcomes: Array = [] + for (const observation of observations) { + const { exit, promise } = yield* observation + if (Exit.isSuccess(exit)) { + outcomes.push( + Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }), + ) + continue + } + const raceInterrupted = promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause) + if (Cause.hasInterruptsOnly(exit.cause) && !raceInterrupted) { + // Execution teardown (timeout/host interruption), not a program-level rejection. + return yield* Effect.failCause(exit.cause) + } + const thrown = raceInterrupted + ? new InterpreterRuntimeError( + "This tool call was interrupted because another value settled a Promise.race first.", + node, + ) + : Cause.squash(exit.cause) + outcomes.push( + Object.assign(Object.create(null) as SafeObject, { + status: "rejected", + reason: caughtErrorValue(thrown), + }), + ) + } + return outcomes + }) + } + case "race": { + if (items.length === 0) { + throw new InterpreterRuntimeError( + "Promise.race([]) would never settle; provide at least one promise or value.", + node, + ) + } + const observations = items.map((item, index) => + item instanceof SandboxPromise + ? Effect.map(this.observePromise(item), (exit) => ({ index, exit })) + : Effect.succeed({ index, exit: Exit.succeed(item as unknown) }), + ) + return Effect.gen(function* () { + // First settlement (fulfilled OR rejected) wins; the observations never fail, so + // racing them yields exactly that. Losing in-flight calls are then interrupted. + const winner = yield* Effect.raceAll(observations) + for (const [index, item] of items.entries()) { + if (index === winner.index || !(item instanceof SandboxPromise) || item.fiber === undefined) continue + item.interrupted = true + yield* Fiber.interrupt(item.fiber) + } + const winningItem = items[winner.index] + return yield* self.unwrapPromiseExit( + winningItem instanceof SandboxPromise ? winningItem : undefined, + winner.exit, + node, + ) + }) + } + } + } + + private invokeFunction(fn: CodeModeFunction, args: Array): Effect.Effect { + const self = this + return Effect.suspend(() => { + const savedScopes = self.scopes + self.scopes = [...fn.capturedScopes, new Map()] + const run = Effect.gen(function* () { + // Seed every parameter name into the scope as a TDZ slot first, so a default that + // references another parameter resolves to that (uninitialized) param rather than + // silently falling through to an outer binding of the same name - matching JS. + const paramScope = self.currentScope() + for (const parameter of fn.parameters) { + for (const name of collectPatternNames(parameter)) { + paramScope.set(name, { mutable: true, value: undefined, initialized: false }) + } + } + for (const [index, parameter] of fn.parameters.entries()) { + if (parameter.type === "RestElement") { + yield* self.declarePattern(getNode(parameter, "argument"), args.slice(index), true, parameter) + break + } + yield* self.declarePattern(parameter, args[index], true, parameter) + } + + if (fn.body.type === "BlockStatement") { + const result = yield* self.evaluateStatement(fn.body) + return result.kind === "return" || result.kind === "value" ? result.value : undefined + } + + return yield* self.evaluateExpression(fn.body) + }) + return run.pipe( + Effect.ensuring( + Effect.sync(() => { + self.scopes = savedScopes + }), + ), + ) + }) + } + + private invokeIntrinsic( + ref: IntrinsicReference, + args: Array, + node: AstNode, + ): Effect.Effect { + if (typeof ref.receiver === "string") { + return Effect.succeed(invokeStringMethod(ref.receiver, ref.name, args, node)) + } + if (typeof ref.receiver === "number") { + return Effect.succeed(invokeNumberMethod(ref.receiver, ref.name, args, node)) + } + if (Array.isArray(ref.receiver)) { + return this.invokeArrayMethod(ref.receiver, ref.name, args, node) + } + if (ref.receiver instanceof SandboxDate) { + return Effect.succeed(invokeDateMethod(ref.receiver, ref.name, node)) + } + if (ref.receiver instanceof SandboxRegExp) { + return Effect.succeed(invokeRegExpMethod(ref.receiver, ref.name, args, node)) + } + if (ref.receiver instanceof SandboxMap) { + return this.invokeMapMethod(ref.receiver, ref.name, args, node) + } + if (ref.receiver instanceof SandboxSet) { + return this.invokeSetMethod(ref.receiver, ref.name, args, node) + } + throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in CodeMode.`, node) + } + + // Runs a Map/Set callback (forEach) accepting a user function or a builtin coercion, + // mirroring the array-method callback contract. + private applyCollectionCallback( + callback: unknown, + name: string, + node: AstNode, + ): (args: Array) => Effect.Effect { + if (!(callback instanceof CodeModeFunction) && !(callback instanceof CoercionFunction)) { + throw new InterpreterRuntimeError(`${name} expects a function callback.`, node) + } + return (callbackArgs) => + callback instanceof CoercionFunction + ? Effect.succeed(invokeCoercion(callback, callbackArgs, node)) + : this.invokeFunction(callback, callbackArgs) + } + + private invokeMapMethod( + target: SandboxMap, + name: string, + args: Array, + node: AstNode, + ): Effect.Effect { + switch (name) { + case "get": + return Effect.succeed(target.map.get(args[0])) + case "has": + return Effect.succeed(target.map.has(args[0])) + case "set": + return Effect.sync(() => { + target.map.set(args[0], args[1]) + return target + }) + case "delete": + return Effect.sync(() => target.map.delete(args[0])) + case "clear": + return Effect.sync(() => { + target.map.clear() + return undefined + }) + case "keys": + return Effect.sync(() => Array.from(target.map.keys())) + case "values": + return Effect.sync(() => Array.from(target.map.values())) + case "entries": + return Effect.sync(() => Array.from(target.map.entries(), ([key, item]): Array => [key, item])) + case "forEach": { + const apply = this.applyCollectionCallback(args[0], "Map.forEach", node) + return Effect.gen(function* () { + // Snapshot iteration, matching the array-method callback contract. + for (const [key, item] of Array.from(target.map.entries())) yield* apply([item, key, target]) + return undefined + }) + } + default: + throw new InterpreterRuntimeError(`Map method '${name}' is not available in CodeMode.`, node) + } + } + + private invokeSetMethod( + target: SandboxSet, + name: string, + args: Array, + node: AstNode, + ): Effect.Effect { + switch (name) { + case "has": + return Effect.succeed(target.set.has(args[0])) + case "add": + return Effect.sync(() => { + target.set.add(args[0]) + return target + }) + case "delete": + return Effect.sync(() => target.set.delete(args[0])) + case "clear": + return Effect.sync(() => { + target.set.clear() + return undefined + }) + case "keys": + case "values": + return Effect.sync(() => Array.from(target.set.values())) + case "entries": + return Effect.sync(() => Array.from(target.set.values(), (item): Array => [item, item])) + case "forEach": { + const apply = this.applyCollectionCallback(args[0], "Set.forEach", node) + return Effect.gen(function* () { + for (const item of Array.from(target.set.values())) yield* apply([item, item, target]) + return undefined + }) + } + default: + throw new InterpreterRuntimeError(`Set method '${name}' is not available in CodeMode.`, node) + } + } + + private invokeArrayMethod( + target: Array, + name: string, + args: Array, + node: AstNode, + ): Effect.Effect { + const optNumber = (value: unknown, label: string): number | undefined => { + if (value === undefined) return undefined + if (typeof value !== "number") + throw new InterpreterRuntimeError(`Array.${name} expects ${label} to be a number.`, node) + return value + } + switch (name) { + case "join": { + if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) { + throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node) + } + const input = boundedData(target, "Array.join input") as Array + return Effect.succeed( + input.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : (args[0] as string)), + ) + } + case "includes": + if (args.length === 0 || args.length > 2) + throw new InterpreterRuntimeError("Array.includes expects a value and optional start index.", node) + return Effect.succeed(target.includes(args[0], optNumber(args[1], "start index"))) + case "indexOf": + return Effect.succeed(target.indexOf(args[0], optNumber(args[1], "start index"))) + case "lastIndexOf": + return Effect.succeed( + args[1] === undefined + ? target.lastIndexOf(args[0]) + : target.lastIndexOf(args[0], optNumber(args[1], "start index")), + ) + case "at": + return Effect.succeed(target.at(optNumber(args[0], "index") ?? 0)) + case "slice": + return Effect.succeed(target.slice(optNumber(args[0], "start"), optNumber(args[1], "end"))) + case "concat": + return Effect.succeed(target.concat(...args)) + case "flat": + return Effect.succeed(target.flat(optNumber(args[0], "depth") ?? 1)) + case "reverse": + return Effect.succeed([...target].reverse()) + case "sort": + case "toSorted": + return this.sortArray(target, args[0], node) + case "toReversed": + return Effect.succeed([...target].reverse()) + case "with": { + const index = optNumber(args[0], "index") ?? 0 + const resolved = index < 0 ? target.length + index : index + if (resolved < 0 || resolved >= target.length) { + throw new InterpreterRuntimeError("Array.with index is out of range.", node) + } + const copied = [...target] + copied[resolved] = args[1] + return Effect.succeed(copied) + } + case "push": { + // Validate before mutating (so no rollback is needed): inserting a container into + // itself would create a cycle no later walk could survive. + for (const item of args) this.rejectCircularInsertion(target, item, "Array.push result", node) + target.push(...args) + return Effect.succeed(target.length) + } + case "unshift": { + for (const item of args) this.rejectCircularInsertion(target, item, "Array.unshift result", node) + target.unshift(...args) + return Effect.succeed(target.length) + } + case "pop": + return Effect.succeed(target.pop()) + case "shift": + return Effect.succeed(target.shift()) + case "splice": { + // Mutates in place and returns the removed elements, exactly like JS: one argument + // removes to the end, an undefined delete count removes nothing. + if (args.length === 0) return Effect.succeed(target.splice(0, 0)) + const start = optNumber(args[0], "start") ?? 0 + if (args.length === 1) return Effect.succeed(target.splice(start)) + const deleteCount = optNumber(args[1], "delete count") ?? 0 + const inserted = args.slice(2) + for (const item of inserted) this.rejectCircularInsertion(target, item, "Array.splice result", node) + return Effect.succeed(target.splice(start, deleteCount, ...inserted)) + } + case "fill": { + this.rejectCircularInsertion(target, args[0], "Array.fill result", node) + return Effect.succeed(target.fill(args[0], optNumber(args[1], "start"), optNumber(args[2], "end"))) + } + case "copyWithin": + return Effect.succeed( + target.copyWithin( + optNumber(args[0], "target index") ?? 0, + optNumber(args[1], "start") ?? 0, + optNumber(args[2], "end"), + ), + ) + // keys/values/entries return arrays (not iterators), matching the Map/Set convention; + // they work with for...of and spread either way. + case "keys": + return Effect.succeed(Array.from(target.keys())) + case "values": + return Effect.succeed([...target]) + case "entries": + return Effect.succeed(Array.from(target.entries(), ([index, item]): Array => [index, item])) + } + + const callback = args[0] + if (!(callback instanceof CodeModeFunction) && !(callback instanceof CoercionFunction)) { + throw new InterpreterRuntimeError(`Array.${name} expects a function callback.`, node) + } + const self = this + // Accept a user arrow function or a builtin coercion callable (Boolean/String/Number), so the + // idioms `filter(Boolean)` / `map(String)` / `map(Number)` work as in JS. Coercions are + // synchronous; only CodeModeFunctions can await tool calls. + const apply = (callbackArgs: Array): Effect.Effect => + callback instanceof CoercionFunction + ? Effect.succeed(invokeCoercion(callback, callbackArgs, node)) + : self.invokeFunction(callback, callbackArgs) + return Effect.gen(function* () { + // Iterate a snapshot taken at call time so a callback that mutates the array can't + // self-extend the loop - matching JS, where elements appended during iteration are not visited. + const items = target.slice() + switch (name) { + case "map": { + const values: Array = [] + for (const [index, item] of items.entries()) values.push(yield* apply([item, index, items])) + return values + } + case "flatMap": { + const values: Array = [] + for (const [index, item] of items.entries()) { + const mapped = yield* apply([item, index, items]) + if (Array.isArray(mapped)) values.push(...mapped) + else values.push(mapped) + } + return values + } + case "filter": { + const values: Array = [] + for (const [index, item] of items.entries()) { + if (yield* apply([item, index, items])) values.push(item) + } + return values + } + case "find": + for (const [index, item] of items.entries()) { + if (yield* apply([item, index, items])) return item + } + return undefined + case "findIndex": + for (const [index, item] of items.entries()) { + if (yield* apply([item, index, items])) return index + } + return -1 + case "some": + for (const [index, item] of items.entries()) { + if (yield* apply([item, index, items])) return true + } + return false + case "every": + for (const [index, item] of items.entries()) { + if (!(yield* apply([item, index, items]))) return false + } + return true + case "forEach": + for (const [index, item] of items.entries()) yield* apply([item, index, items]) + return undefined + case "reduce": { + let accumulator: unknown + let start: number + if (args.length >= 2) { + accumulator = args[1] + start = 0 + } else { + if (items.length === 0) + throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node) + accumulator = items[0] + start = 1 + } + for (let index = start; index < items.length; index += 1) { + accumulator = yield* apply([accumulator, items[index], index, items]) + } + return accumulator + } + case "reduceRight": { + let accumulator: unknown + let start: number + if (args.length >= 2) { + accumulator = args[1] + start = items.length - 1 + } else { + if (items.length === 0) + throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node) + accumulator = items[items.length - 1] + start = items.length - 2 + } + for (let index = start; index >= 0; index -= 1) { + accumulator = yield* apply([accumulator, items[index], index, items]) + } + return accumulator + } + case "findLast": + for (let index = items.length - 1; index >= 0; index -= 1) { + if (yield* apply([items[index], index, items])) return items[index] + } + return undefined + case "findLastIndex": + for (let index = items.length - 1; index >= 0; index -= 1) { + if (yield* apply([items[index], index, items])) return index + } + return -1 + } + throw new InterpreterRuntimeError(`Array method '${name}' is not available in CodeMode.`, node) + }) + } + + private sortArray( + target: Array, + comparator: unknown, + node: AstNode, + ): Effect.Effect, unknown, R> { + if (comparator !== undefined && !(comparator instanceof CodeModeFunction)) { + throw new InterpreterRuntimeError("Array.sort expects an arrow function comparator.", node) + } + if (!(comparator instanceof CodeModeFunction)) { + return Effect.sync(() => + [...target].sort((a, b) => { + const left = coerceToString(a) + const right = coerceToString(b) + return left < right ? -1 : left > right ? 1 : 0 + }), + ) + } + const self = this + const mergeSort = (items: Array): Effect.Effect, unknown, R> => { + if (items.length <= 1) return Effect.succeed(items) + const midpoint = Math.floor(items.length / 2) + return Effect.gen(function* () { + const left = yield* mergeSort(items.slice(0, midpoint)) + const right = yield* mergeSort(items.slice(midpoint)) + const merged: Array = [] + let leftIndex = 0 + let rightIndex = 0 + while (leftIndex < left.length && rightIndex < right.length) { + // Coerce the comparator's result like JS ToNumber (data objects -> NaN, never a host + // crash) and treat NaN as 0 - the spec's "no consistent order" -> keep the left element. + const order = coerceToNumber(yield* self.invokeFunction(comparator, [left[leftIndex], right[rightIndex]])) + if (Number.isNaN(order) || order <= 0) merged.push(left[leftIndex++]) + else merged.push(right[rightIndex++]) + } + return [...merged, ...left.slice(leftIndex), ...right.slice(rightIndex)] + }) + } + // Per spec, undefined elements sort to the end and the comparator is never called on them. + const defined = target.filter((item) => item !== undefined) + const undefinedCount = target.length - defined.length + return Effect.map(mergeSort(defined), (items) => [...items, ...Array(undefinedCount).fill(undefined)]) + } + + private evaluateObjectExpression(node: AstNode): Effect.Effect, unknown, R> { + const objectValue: Record = Object.create(null) as Record + const properties = getArray(node, "properties") + const self = this + return Effect.gen(function* () { + for (const propertyValue of properties) { + const property = asNode(propertyValue, "properties") + + if (property.type === "SpreadElement") { + const spread = yield* self.evaluateExpression(getNode(property, "argument")) + // JS treats `{ ...null }` / `{ ...undefined }` as a no-op, so the common + // `{ ...maybeOpts, override }` merge works when the operand is absent. Sandbox values + // (Date/RegExp/Map/Set) have no own enumerable properties in JS, so they are no-ops too. + if (spread === null || spread === undefined || isSandboxValue(spread)) continue + if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) { + throw new InterpreterRuntimeError( + "Object spread requires a data object in CodeMode.", + property, + "InvalidDataValue", + ) + } + for (const [key, value] of Object.entries(spread)) { + if (isBlockedMember(key)) + throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, property) + objectValue[key] = value + } + continue + } + + if (property.type !== "Property") { + throw new InterpreterRuntimeError("Only standard object properties are supported.", property) + } + + if (getString(property, "kind") !== "init") { + throw new InterpreterRuntimeError("Only init object properties are supported.", property) + } + + const keyNode = getNode(property, "key") + const valueNode = getNode(property, "value") + const computed = getBoolean(property, "computed") + + let key: PropertyKey + + if (computed) { + key = self.toPropertyKey(yield* self.evaluateExpression(keyNode), keyNode) + } else if (keyNode.type === "Identifier") { + key = getString(keyNode, "name") + } else if (keyNode.type === "Literal") { + key = self.toPropertyKey(keyNode.value, keyNode) + } else { + throw new InterpreterRuntimeError("Unsupported object property key shape.", keyNode) + } + + if (isBlockedMember(String(key))) { + throw new InterpreterRuntimeError(`Property '${String(key)}' is not available in CodeMode.`, keyNode) + } + objectValue[String(key)] = yield* self.evaluateExpression(valueNode) + } + + return objectValue + }) + } + + private evaluateArrayExpression(node: AstNode): Effect.Effect, unknown, R> { + const elements = getArray(node, "elements") + const values: Array = [] + + const self = this + return Effect.gen(function* () { + for (const elementValue of elements) { + if (elementValue === null) { + values.push(undefined) + continue + } + const element = asNode(elementValue, "elements") + if (element.type === "SpreadElement") { + const spread = yield* self.evaluateExpression(getNode(element, "argument")) + const items = spreadItems(spread) + if (items === undefined) + throw new InterpreterRuntimeError( + "Array spread requires an array, string, Map, or Set in CodeMode.", + element, + ) + values.push(...items) + } else { + values.push(yield* self.evaluateExpression(element)) + } + } + return values + }) + } + + private evaluateTemplateLiteral(node: AstNode): Effect.Effect { + const quasis = getArray(node, "quasis") + const expressions = getArray(node, "expressions") + + let output = "" + + const self = this + return Effect.gen(function* () { + for (let index = 0; index < quasis.length; index += 1) { + const quasi = asNode(quasis[index], "quasis") + const rawValue = quasi.value + + if (!isRecord(rawValue) || typeof rawValue.cooked !== "string") { + throw new InterpreterRuntimeError("Invalid template literal quasi.", quasi) + } + + output += rawValue.cooked + + if (index < expressions.length) { + const raw = yield* self.evaluateExpression(asNode(expressions[index], "expressions")) + // The preserving checkpoint keeps sandbox values intact, so coerceToString renders + // them directly (ISO date, /regex/ literal form) instead of a JSON-serialized husk. + output += coerceToString(boundedData(raw, "Template interpolation")) + } + } + + return output + }) + } + + private evaluateConditionalExpression(node: AstNode): Effect.Effect { + return Effect.flatMap(this.evaluateExpression(getNode(node, "test")), (test) => + this.evaluateExpression(getNode(node, test ? "consequent" : "alternate")), + ) + } + + private applyCompoundAssignment(operator: string, current: unknown, incoming: unknown, node: AstNode): unknown { + // `x op= y` is `x = x op y`: dispatch through the shared binary operator implementation + // so compound assignment inherits the same coercion semantics (Dates, data objects, ...). + // Only the arithmetic/bitwise operators are compoundable; logical assignments (&&=/||=/??=) + // short-circuit and are handled by evaluateLogicalAssignment before reaching here. + if (!compoundOperators.has(operator)) { + throw new InterpreterRuntimeError(`Unsupported assignment operator '${operator}'.`, node) + } + return this.applyBinaryOperator(operator.slice(0, -1), current, incoming, node) + } + + private getMemberReference( + node: AstNode, + ): Effect.Effect< + | MemberReference + | ToolReference + | PromiseMethodReference + | IntrinsicReference + | GlobalMethodReference + | ComputedValue + | typeof OptionalShortCircuit + | undefined, + unknown, + R + > { + const objectNode = getNode(node, "object") + const propertyNode = getNode(node, "property") + const computed = getBoolean(node, "computed") + const optional = node.optional === true + const self = this + return Effect.gen(function* () { + const objectValue = yield* self.evaluateExpression(objectNode) + if (objectValue === OptionalShortCircuit) return OptionalShortCircuit + if ((objectValue === null || objectValue === undefined) && optional) return OptionalShortCircuit + + const key = computed + ? self.toPropertyKey(yield* self.evaluateExpression(propertyNode), propertyNode) + : propertyNode.type === "Identifier" + ? getString(propertyNode, "name") + : self.toPropertyKey(yield* self.evaluateExpression(propertyNode), propertyNode) + + if (objectValue instanceof ToolReference) { + if (typeof key !== "string" || isBlockedMember(key)) { + throw new InterpreterRuntimeError("Tool paths must use safe string property names.", propertyNode) + } + return new ToolReference([...objectValue.path, key]) + } + + if (objectValue instanceof PromiseNamespace) { + if (typeof key === "string" && promiseStatics.has(key as PromiseMethodName)) { + return new PromiseMethodReference(key as PromiseMethodName) + } + throw new InterpreterRuntimeError( + `Promise.${String(key)} is not available in CodeMode. Available: Promise.all, Promise.allSettled, Promise.race, Promise.resolve, and Promise.reject; consume promises with await.`, + propertyNode, + ) + } + + if (objectValue instanceof GlobalNamespace) { + if (typeof key !== "string" || isBlockedMember(key)) { + throw new InterpreterRuntimeError( + `${objectValue.name}.${String(key)} is not available in CodeMode.`, + propertyNode, + ) + } + if (objectValue.name === "Math" && mathConstants.has(key)) { + return new ComputedValue((Math as unknown as Record)[key]) + } + return new GlobalMethodReference(objectValue.name, key) + } + + if (typeof objectValue === "string") { + if (key === "length") return new ComputedValue(objectValue.length) + if (typeof key === "number") return new ComputedValue(objectValue[key]) + if (typeof key === "string" && /^\d+$/.test(key)) return new ComputedValue(objectValue[Number(key)]) + if (typeof key === "string" && stringMethods.has(key)) return new IntrinsicReference(objectValue, key) + // Unknown property on a string reads as `undefined`, matching JS (`"x".foo === undefined`), + // instead of throwing - so defensive access like `result?.login ?? result` on a JSON-string + // tool result doesn't crash. (Optional chaining only guards null/undefined receivers, so a + // real string still reaches here.) Only the method allowlist above yields callables. + return new ComputedValue(undefined) + } + + if (typeof objectValue === "number") { + if (typeof key === "string" && numberMethods.has(key)) return new IntrinsicReference(objectValue, key) + // Unknown property on a number reads as `undefined`, matching JS, rather than throwing. + return new ComputedValue(undefined) + } + + // Number / String expose a small allowlist of statics; everything else stays opaque. + if (objectValue instanceof CoercionFunction && typeof key === "string" && !isBlockedMember(key)) { + if (objectValue.name === "Number" && numberConstants.has(key)) { + return new ComputedValue((Number as unknown as Record)[key]) + } + if (objectValue.name === "Number" && numberStatics.has(key)) return new GlobalMethodReference("Number", key) + if (objectValue.name === "String" && stringStatics.has(key)) return new GlobalMethodReference("String", key) + } + + // Sandbox value types expose their method/property allowlists; any other key reads as + // `undefined`, consistent with unknown-property reads on strings/numbers/arrays. + if (objectValue instanceof SandboxDate) { + if (typeof key === "string" && dateMethods.has(key)) return new IntrinsicReference(objectValue, key) + return new ComputedValue(undefined) + } + if (objectValue instanceof SandboxRegExp) { + if (typeof key === "string" && regexpProperties.has(key)) { + return new ComputedValue((objectValue.regex as unknown as Record)[key]) + } + if (typeof key === "string" && regexpMethods.has(key)) return new IntrinsicReference(objectValue, key) + return new ComputedValue(undefined) + } + if (objectValue instanceof SandboxMap) { + if (key === "size") return new ComputedValue(objectValue.map.size) + if (typeof key === "string" && mapMethods.has(key)) return new IntrinsicReference(objectValue, key) + return new ComputedValue(undefined) + } + if (objectValue instanceof SandboxSet) { + if (key === "size") return new ComputedValue(objectValue.set.size) + if (typeof key === "string" && setMethods.has(key)) return new IntrinsicReference(objectValue, key) + return new ComputedValue(undefined) + } + + // Any property access on a promise is a confused program (`p.then(...)`, `p.value`); + // reading `undefined` here would hide the missing await, so both paths get an explicit, + // await-hinting error instead of the forgiving unknown-property fallthrough. + if (objectValue instanceof SandboxPromise) { + if (key === "then" || key === "catch" || key === "finally") { + throw new InterpreterRuntimeError( + `Promise.prototype.${String(key)} is not supported in CodeMode; use await instead (with try/catch to handle failures) - e.g. \`const result = await tools.ns.tool(...)\`.`, + propertyNode, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) + } + throw new InterpreterRuntimeError( + "This value is an un-awaited Promise and has no readable properties; await it first - e.g. `const result = await tools.ns.tool(...)`.", + objectNode, + "InvalidDataValue", + ) + } + + if (isRuntimeReference(objectValue)) { + throw new InterpreterRuntimeError( + "CodeMode runtime references are opaque and do not expose properties.", + objectNode, + "InvalidDataValue", + ) + } + + if (typeof objectValue !== "object" || objectValue === null) { + throw new InterpreterRuntimeError("Cannot access a property on a non-object value.", objectNode) + } + + if (typeof key === "string" && isBlockedMember(key)) { + throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, propertyNode) + } + + if (Array.isArray(objectValue)) { + if ( + key !== "length" && + !(typeof key === "string" && arrayMethods.has(key)) && + typeof key !== "number" && + !/^\d+$/.test(key) + ) { + // Own non-index properties read through (match results carry index/groups); like JS, + // they are readable in place and dropped by JSON at data boundaries. + if (typeof key === "string" && Object.hasOwn(objectValue, key)) { + return new ComputedValue((objectValue as Record & Array)[key]) + } + // Unknown property on an array reads as `undefined`, matching JS (`[1,2].foo === undefined`), + // instead of throwing - so defensive access under optional chaining behaves as expected. + return new ComputedValue(undefined) + } + return { target: objectValue, key } + } + + return { target: objectValue as SafeObject, key } + }) + } + + private readMember(node: AstNode): Effect.Effect { + return Effect.map(this.getMemberReference(node), (reference) => { + if (reference === OptionalShortCircuit) return OptionalShortCircuit + if (reference instanceof ComputedValue) return reference.value + if ( + reference === undefined || + reference instanceof ToolReference || + reference instanceof PromiseMethodReference || + reference instanceof IntrinsicReference || + reference instanceof GlobalMethodReference + ) + return reference + if (Array.isArray(reference.target)) { + if (typeof reference.key === "string" && arrayMethods.has(reference.key)) { + return new IntrinsicReference(reference.target, reference.key) + } + return reference.key === "length" ? reference.target.length : reference.target[Number(reference.key)] + } + return reference.target[String(reference.key)] + }) + } + + private writeMember(node: AstNode, value: unknown): Effect.Effect { + return this.modifyMember(node, () => Effect.succeed({ write: true, next: value, result: value })) + } + + // Resolves the member reference EXACTLY ONCE (so a side-effecting object/key expression + // runs once), then lets `compute` decide whether to write - enabling compound assignment, + // updates, plain writes, and short-circuiting logical assignment to share one safe path. + private modifyMember( + node: AstNode, + compute: (current: unknown) => Effect.Effect<{ write: boolean; next: unknown; result: unknown }, unknown, R>, + ): Effect.Effect { + const self = this + return Effect.gen(function* () { + const reference = yield* self.getMemberReference(node) + if ( + reference === OptionalShortCircuit || + reference instanceof ComputedValue || + reference === undefined || + reference instanceof ToolReference || + reference instanceof PromiseMethodReference || + reference instanceof IntrinsicReference || + reference instanceof GlobalMethodReference + ) { + throw new InterpreterRuntimeError("Only data fields may be assigned in CodeMode.", node) + } + if (Array.isArray(reference.target)) { + if (reference.key === "length") + throw new InterpreterRuntimeError("Array length cannot be assigned in CodeMode.", node) + if (typeof reference.key === "string" && arrayMethods.has(reference.key)) { + throw new InterpreterRuntimeError("Array methods cannot be assigned in CodeMode.", node) + } + } + const key = Array.isArray(reference.target) ? Number(reference.key) : String(reference.key) + const current = (reference.target as Record)[key] + const { write, next, result } = yield* compute(current) + if (write) self.assignToReference(reference, key, next, node) + return result + }) + } + + // Rejects inserting a value that (transitively) contains the container it is being inserted + // into - the mutation that would create a circular structure no later walk could survive. + private rejectCircularInsertion( + container: object, + value: unknown, + label: string, + node: AstNode, + seen = new Set(), + ): void { + if (value === container) + throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue") + if (value === null || typeof value !== "object" || isRuntimeReference(value) || seen.has(value)) return + seen.add(value) + const items = Array.isArray(value) ? value : Object.values(value) + for (const item of items) this.rejectCircularInsertion(container, item, label, node, seen) + seen.delete(value) + } + + private assignToReference(reference: MemberReference, key: number | string, next: unknown, node: AstNode): void { + if (Array.isArray(reference.target)) { + const target = reference.target + const index = key as number + if (!Number.isInteger(index) || index < 0) { + throw new InterpreterRuntimeError( + "Array assignment index must be a non-negative integer.", + node, + "InvalidDataValue", + ) + } + this.rejectCircularInsertion(target, next, "Array assignment result", node) + target[index] = next + return + } + const target = reference.target as SafeObject + const objectKey = key as string + this.rejectCircularInsertion(target, next, "Object assignment result", node) + target[objectKey] = next + } + + private toPropertyKey(value: unknown, node: AstNode): string | number { + if (typeof value === "string" || typeof value === "number") { + return value + } + + throw new InterpreterRuntimeError("Property key must be a string or number.", node) + } + + private declare(name: string, value: unknown, mutable: boolean, node: AstNode): void { + const scope = this.currentScope() + + // A pre-seeded parameter slot (initialized === false) is being bound for the first time; + // anything else already present is a genuine duplicate declaration. + const existing = scope.get(name) + if (existing && existing.initialized !== false) { + throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node) + } + + scope.set(name, { mutable, value, initialized: true }) + } + + private getIdentifierValue(name: string, node: AstNode): unknown { + const binding = this.resolveBinding(name) + + if (!binding) { + throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError") + } + + // A parameter default that forward-references a later (not-yet-bound) parameter - JS TDZ. + if (binding.initialized === false) { + throw new InterpreterRuntimeError(`Cannot access '${name}' before initialization.`, node).as("ReferenceError") + } + + return binding.value + } + + private setIdentifierValue(name: string, value: unknown, node: AstNode): unknown { + const binding = this.resolveBinding(name) + + if (!binding) { + throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError") + } + + if (!binding.mutable) { + throw new InterpreterRuntimeError(`Cannot assign to constant '${name}'.`, node).as("TypeError") + } + + binding.value = value + return value + } + + private resolveBinding(name: string): Binding | undefined { + for (let index = this.scopes.length - 1; index >= 0; index -= 1) { + const scope = this.scopes[index] + const binding = scope?.get(name) + + if (binding) { + return binding + } + } + + return undefined + } + + private currentScope(): Map { + const scope = this.scopes[this.scopes.length - 1] + + if (!scope) { + throw new InterpreterRuntimeError("Interpreter scope stack is empty.") + } + + return scope + } + + private pushScope(): void { + this.scopes.push(new Map()) + } + + private popScope(): void { + this.scopes.pop() + } +} + +/** + * Executes one Effect-native CodeMode program without constructing a reusable runtime. + * + * @example + * ```ts + * const result = yield* CodeMode.execute({ + * tools: { lookup }, + * code: `return await tools.lookup({ id: "order_42" })`, + * }) + * ``` + */ +const executeWithLimits = >( + options: ExecuteOptions, + limits: ResolvedExecutionLimits, + searchIndex: ToolRuntime.DiscoveryPlan["searchIndex"], +): Effect.Effect> => { + const hooks = { + ...(options.onToolCallStart === undefined ? {} : { onToolCallStart: options.onToolCallStart }), + ...(options.onToolCallEnd === undefined ? {} : { onToolCallEnd: options.onToolCallEnd }), + } + const tools = ToolRuntime.make( + (options.tools ?? {}) as HostTools>, + limits.maxToolCalls, + hooks, + searchIndex, + ) + const logs: Array = [] + const logged = () => (logs.length > 0 ? { logs: [...logs] } : {}) + + if (options.code.trim().length === 0) { + return Effect.succeed({ + ok: false, + error: { kind: "ParseError", message: "Code cannot be empty." }, + toolCalls: tools.calls, + }) + } + + const operation = Effect.gen(function* () { + const program = parseProgram(options.code) + const interpreter = new Interpreter>(tools.invoke, tools.keys, logs) + const value = yield* interpreter.run(program) + const result = copyOut(copyIn(value, "Execution result"), true) as DataValue + return { + ok: true, + value: result, + ...logged(), + toolCalls: tools.calls, + } satisfies Result + }).pipe((program) => { + const timeoutMs = limits.timeoutMs + if (timeoutMs === undefined) return program + return program.pipe( + Effect.timeoutOrElse({ + duration: timeoutMs, + orElse: () => + Effect.succeed({ + ok: false, + error: { kind: "TimeoutExceeded", message: `Execution timed out after ${timeoutMs}ms.` }, + ...logged(), + toolCalls: tools.calls, + } satisfies Result), + }), + ) + }) + + return operation.pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.succeed({ + ok: false, + error: normalizeError(Cause.squash(cause)), + ...logged(), + toolCalls: tools.calls, + } satisfies Result), + ), + Effect.map((result) => (limits.maxOutputBytes === undefined ? result : boundOutput(result, limits.maxOutputBytes))), + ) +} + +const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).byteLength + +// Truncates to a UTF-8 byte budget without splitting a code point (a split multi-byte +// sequence decodes to a replacement character, which is dropped). +const utf8Truncate = (value: string, maxBytes: number): string => { + const bytes = new TextEncoder().encode(value) + if (bytes.byteLength <= maxBytes) return value + const text = new TextDecoder("utf-8").decode(bytes.slice(0, Math.max(0, maxBytes))) + return text.endsWith("\uFFFD") ? text.slice(0, -1) : text +} + +/** + * Bounds the model-facing output (serialized result value plus logs) to `maxOutputBytes`. + * Oversized values are replaced by their truncated serialized text with an explanatory marker, + * and logs are kept from the start until the remaining budget is exhausted. Truncation never + * 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: Result, maxOutputBytes: number): Result => { + let truncated = false + + let value: DataValue = null + let valueBytes = 0 + if (result.ok) { + const serialized = JSON.stringify(result.value) ?? "null" + const bytes = utf8ByteLength(serialized) + if (bytes > maxOutputBytes) { + truncated = true + value = `${utf8Truncate(serialized, maxOutputBytes)} [result truncated: ${bytes} bytes exceeds the ${maxOutputBytes}-byte output limit; return a smaller value]` + valueBytes = maxOutputBytes + } else { + value = result.value + valueBytes = bytes + } + } + + const logs = result.logs ?? [] + const kept: Array = [] + const logBudget = Math.max(0, maxOutputBytes - valueBytes) + let logBytes = 0 + for (const line of logs) { + const lineBytes = utf8ByteLength(line) + 1 + if (logBytes + lineBytes > logBudget) break + logBytes += lineBytes + kept.push(line) + } + if (kept.length < logs.length) { + truncated = true + kept.push(`[logs truncated: showing ${kept.length} of ${logs.length} lines]`) + } + + if (!truncated) return result + const logsPart = kept.length > 0 ? { logs: kept } : {} + return result.ok + ? { ok: true, value, ...logsPart, truncated: true, toolCalls: result.toolCalls } + : { ok: false, error: result.error, ...logsPart, truncated: true, toolCalls: result.toolCalls } +} + +export const execute = >( + options: ExecuteOptions, +): Effect.Effect> => { + const tools = (options.tools ?? {}) as HostTools> + ToolRuntime.assertValidTools(tools) + return executeWithLimits(options, resolveExecutionLimits(options.limits), ToolRuntime.searchIndex(tools)) +} + +/** + * Creates an Effect-native runtime over explicit, schema-described tools. + * + * Use `execute` for host-driven execution. Tool requirements remain in the returned Effect environment. + * + * @example + * ```ts + * const runtime = CodeMode.make({ tools: { orders: { lookup } } }) + * const result = runtime.execute("return await tools.orders.lookup({ id: 'order_42' })") + * ``` + */ +export const make = = {}>( + options: Options = {} as Options, +): Runtime> => { + const tools = (options.tools ?? {}) as HostTools> + ToolRuntime.assertValidTools(tools) + const limits = resolveExecutionLimits(options.limits) + const discovery = ToolRuntime.discoveryPlan(tools, options.discovery?.maxInlineCatalogTokens) + const executeProgram = (code: string) => executeWithLimits({ ...options, code }, limits, discovery.searchIndex) + const catalog = discovery.catalog + const instructions = discovery.instructions + + return { + catalog: () => catalog, + instructions: () => instructions, + execute: executeProgram, + } +} diff --git a/packages/codemode/src/index.ts b/packages/codemode/src/index.ts new file mode 100644 index 0000000000..9201982500 --- /dev/null +++ b/packages/codemode/src/index.ts @@ -0,0 +1,4 @@ +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" diff --git a/packages/codemode/src/openapi/TODO.md b/packages/codemode/src/openapi/TODO.md new file mode 100644 index 0000000000..cbcfe81a68 --- /dev/null +++ b/packages/codemode/src/openapi/TODO.md @@ -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. diff --git a/packages/codemode/src/openapi/index.ts b/packages/codemode/src/openapi/index.ts new file mode 100644 index 0000000000..7f1770ef36 --- /dev/null +++ b/packages/codemode/src/openapi/index.ts @@ -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() + const namespaces = new Set() + const skipped: Array = [] + 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, definition: Definition): 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) +} diff --git a/packages/codemode/src/openapi/runtime.ts b/packages/codemode/src/openapi/runtime.ts new file mode 100644 index 0000000000..47312ae162 --- /dev/null +++ b/packages/codemode/src/openapi/runtime.ts @@ -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 => + 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>, +): Effect.Effect => + 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 => + Effect.gen(function* () { + const none: AppliedAuth = { headers: {}, query: {} } + if (plan.security.length === 0) return none + + const unavailable: Array = [] + alternatives: for (const requirement of plan.security) { + const names = Object.keys(requirement) + if (names.length === 0) return none + const credentials: Array = [] + 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, +): AppliedAuth | ToolError => { + const headers = new Map() + const query = new Map() + 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>): 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(([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((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((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 => + 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)) + }) diff --git a/packages/codemode/src/openapi/spec.ts b/packages/codemode/src/openapi/spec.ts new file mode 100644 index 0000000000..22cf1535a8 --- /dev/null +++ b/packages/codemode/src/openapi/spec.ts @@ -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 => + typeof value === "object" && value !== null && !Array.isArray(value) + +const asArray = (value: unknown): ReadonlyArray => (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 = (record: Readonly>, 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): 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((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> => { + 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>): 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): { 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 & { readonly properties: Record } => + 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 + +const operationParameters = ( + document: Document, + pathItem: Record, + operation: Record, +): Parsed> => { + // Operation-level parameters override path-level ones sharing (location, name). + const declared = new Map< + string, + { readonly name: string; readonly location: string; readonly parameter: Record } + >() + 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 = [] + 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, +): Parsed<{ readonly fields: ReadonlyArray; 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, + operation: Record, +): Parsed => { + 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() + 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, + definitions: Readonly>, +): 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, +): Parsed>> => { + 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> = [] + 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, + definitions: Readonly>, +): Parsed => { + 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 = [] + 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, + used: ReadonlySet, + namespaces: ReadonlySet, +): ReadonlyArray => { + 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, + used: ReadonlySet, + namespaces: ReadonlySet, +): 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): Parsed => { + 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 => { + 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> => { + if (value === undefined) return { ok: true, value: [] } + if (!Array.isArray(value)) return { ok: false, reason: "security declaration is not an array" } + const requirements: Array = [] + 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> + 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>, + schemes: Readonly>, +): Parsed> => { + 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> => { + const components = isRecord(document.components) ? document.components : {} + const declared = isRecord(components.securitySchemes) ? components.securitySchemes : {} + return Object.fromEntries( + Object.entries(declared).flatMap(([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 [] + }), + ) +} diff --git a/packages/codemode/src/openapi/types.ts b/packages/codemode/src/openapi/types.ts new file mode 100644 index 0000000000..cab772e701 --- /dev/null +++ b/packages/codemode/src/openapi/types.ts @@ -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 + +/** 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 + readonly operation: Operation +}) => Effect.Effect + +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> | 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 | Tools } + +export type Result = { + /** Tool subtree; the host places it under a key in its `tools` tree. */ + readonly tools: Tools + readonly skipped: ReadonlyArray +} + +export type Parsed = { 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 + readonly body: Body | undefined +} + +/** One OR alternative: scheme name -> required scopes. Empty object = unauthenticated is acceptable. */ +export type SecurityRequirement = Readonly>> + +export type Plan = { + readonly operation: Operation + readonly url: string + readonly fields: ReadonlyArray + readonly body: Body | undefined + readonly security: ReadonlyArray + readonly schemes: Readonly> + readonly auth: { readonly resolve: AuthResolver } | undefined + readonly headers: Readonly> +} + +export type AppliedAuth = { + readonly headers: Readonly> + readonly query: Readonly> +} diff --git a/packages/codemode/src/tool-error.ts b/packages/codemode/src/tool-error.ts new file mode 100644 index 0000000000..16460a019a --- /dev/null +++ b/packages/codemode/src/tool-error.ts @@ -0,0 +1,11 @@ +import { Schema } from "effect" + +/** Safe operational refusal from a standard tool pack, reported as `ToolFailure`. */ +export class ToolError extends Schema.TaggedErrorClass()("ToolError", { + message: Schema.String, + cause: Schema.optionalKey(Schema.Defect()), +}) {} + +/** Creates a tool refusal whose message is safe to include in an execution diagnostic. */ +export const toolError = (message: string, cause?: unknown): ToolError => + new ToolError({ message, ...(cause === undefined ? {} : { cause }) }) diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts new file mode 100644 index 0000000000..268953b56a --- /dev/null +++ b/packages/codemode/src/tool-runtime.ts @@ -0,0 +1,831 @@ +import { Cause, Effect } from "effect" +import { ToolError, toolError } from "./tool-error.js" +import { + decodeInput as decodeToolInput, + decodeOutput as decodeToolOutput, + identifierSegment, + inputProperties, + inputTypeScript, + outputTypeScript, +} 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 = (...args: Array) => Effect.Effect + +export type HostTools = { + [name: string]: HostTool | Definition | HostTools +} + +export type Services = ServicesOf + +type ServicesOf> = Depth["length"] extends 8 + ? never + : Tools extends (...args: Array) => Effect.Effect + ? R + : Tools extends { + readonly _tag: "CodeModeTool" + readonly run: (input: unknown) => Effect.Effect + } + ? R + : Tools extends object + ? string extends keyof Tools + ? ServicesOf + : ServicesOf + : never + +/** Minimal audit record retained for each admitted tool call. */ +export type ToolCall = { + readonly name: string +} + +/** Decoded tool call observed immediately before tool execution. */ +export type ToolCallStarted = { + readonly index: number + readonly name: string + readonly input: unknown +} + +/** Completed tool call observed immediately after tool execution settles. */ +export type ToolCallEnded = { + readonly index: number + readonly name: string + readonly input: unknown + readonly durationMs: number + readonly outcome: "success" | "failure" + /** Model-safe failure message; present only when `outcome` is `"failure"`. */ + readonly message?: string +} + +/** Non-throwing observation hooks fired around each admitted tool call. */ +export type ToolCallHooks = { + readonly onToolCallStart?: ((call: ToolCallStarted) => Effect.Effect) | undefined + readonly onToolCallEnd?: ((call: ToolCallEnded) => Effect.Effect) | undefined +} + +/** Model-visible description of one schema-backed tool. */ +export type ToolDescription = { + readonly path: string + readonly description: string + readonly signature: string +} + +export type SafeObject = Record + +const reservedNamespace = "$codemode" +const defaultMaxInlineCatalogTokens = 2_000 +const defaultSearchLimit = 10 +const searchSignature = + "tools.$codemode.search({ query?: string, namespace?: string, limit?: number }): Promise<{ items: Array<{ path: string; description: string; signature: string }>; total: number }>" +const toolExpression = (path: string) => + "tools" + + path + .split(".") + .map((segment) => (identifierSegment.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`)) + .join("") + +export class ToolReference { + constructor(readonly path: ReadonlyArray) {} +} + +/** + * Maximum nesting depth for values crossing a data boundary. Fixed (not a configurable + * limit) purely because it produces a clearer diagnostic than a native stack-overflow + * RangeError would. + */ +const MAX_VALUE_DEPTH = 32 + +export class ToolRuntimeError extends Error { + constructor( + readonly kind: + | "UnknownTool" + | "InvalidToolInput" + | "InvalidToolOutput" + | "InvalidDataValue" + | "ToolCallLimitExceeded", + message: string, + readonly suggestions: ReadonlyArray = [], + ) { + super(message) + this.name = "ToolRuntimeError" + } +} + +const isDefinition = (value: HostTool | Definition | HostTools): value is Definition => + isToolDefinition(value) + +const runHost = (effect: Effect.Effect): Effect.Effect => + effect.pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt + const error = Cause.squash(cause) + return Effect.fail(error instanceof ToolError ? error : toolError("Tool execution failed", error)) + }), + ) + +const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"]) + +export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name) + +/** + * Validates and copies a value against the plain-data contract (depth, circularity, plain + * objects only, blocked properties, data-only leaves). + * + * Two modes share the walk: + * - **Boundary** (`preserveSandboxValues` false, the default): the host<->sandbox boundary - + * final results, tool-call arguments, `JSON.stringify`. Sandbox value types serialize + * exactly as JSON.stringify would: Date -> ISO string (invalid -> null), RegExp/Map/Set -> {}. + * - **Intra-sandbox checkpoint** (`preserveSandboxValues` true; see `boundedData` in + * codemode.ts): Date/RegExp/Map/Set instances pass through untouched (treated as leaves, + * contents not walked), so values flowing through `Object.*` helpers, coercion inputs, and + * other in-sandbox checkpoints stay fully usable (`.getTime()`, `.has()`, ...). + * + * Both modes reject un-awaited promises with an await-hinting diagnostic. + */ +export const copyIn = (value: unknown, label: string, preserveSandboxValues = false): unknown => + copyBounded(value, label, 0, new Set(), preserveSandboxValues) + +const copyBounded = ( + value: unknown, + label: string, + depth: number, + seen: Set, + preserveSandboxValues: boolean, +): unknown => { + if (depth > MAX_VALUE_DEPTH) { + throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`) + } + if ( + value === null || + value === undefined || + typeof value === "string" || + typeof value === "boolean" || + // NaN/Infinity are allowed to exist as in-sandbox intermediates (matching real JS and a real + // engine) so defensive guards like `Number.isNaN(x)` / `parseInt(x) || 0` can run. They are + // normalized to `null` when the value leaves the sandbox - see copyOut - exactly as + // JSON.stringify already does at any tool boundary. + typeof value === "number" + ) { + return value + } + + if (typeof value !== "object") { + throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`) + } + + // An un-awaited promise never crosses a data checkpoint as `{}`; the diagnostic tells the + // model exactly how to fix the program instead. + if (value instanceof SandboxPromise) { + throw new ToolRuntimeError( + "InvalidDataValue", + `${label} contains an un-awaited Promise; await tool calls (e.g. \`const result = await tools.ns.tool(...)\`) before using their results.`, + ) + } + + if (preserveSandboxValues) { + // Intra-sandbox checkpoints keep sandbox value instances alive as leaves; their contents + // are never walked here (Map/Set members are validated where mutation happens, and the + // real boundary still serializes them below). + if ( + value instanceof SandboxDate || + value instanceof SandboxRegExp || + value instanceof SandboxMap || + value instanceof SandboxSet + ) { + return value + } + // Host instances cannot normally reach an intra-sandbox checkpoint (tool results cross + // the boundary first), but wrap them defensively rather than degrading to JSON forms. + if (value instanceof Date) return new SandboxDate(value.getTime()) + if (value instanceof RegExp) return new SandboxRegExp(value.source, value.flags) + if (value instanceof Map) { + const wrapped = new SandboxMap() + for (const [key, item] of value.entries()) { + wrapped.map.set(copyBounded(key, label, depth + 1, seen, true), copyBounded(item, label, depth + 1, seen, true)) + } + return wrapped + } + if (value instanceof Set) { + const wrapped = new SandboxSet() + for (const item of value.values()) wrapped.set.add(copyBounded(item, label, depth + 1, seen, true)) + return wrapped + } + } + + // Sandbox value types (and their host counterparts, which a host tool may legitimately + // return) serialize exactly as JSON.stringify would at the data boundary: a Date is its + // toJSON() ISO string (invalid -> null), and RegExp/Map/Set have no JSON form beyond {}. + if (value instanceof SandboxDate) { + return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null + } + if (value instanceof Date) { + return Number.isFinite(value.getTime()) ? value.toISOString() : null + } + if ( + value instanceof SandboxRegExp || + value instanceof SandboxMap || + value instanceof SandboxSet || + value instanceof RegExp || + value instanceof Map || + value instanceof Set + ) { + return Object.create(null) as SafeObject + } + + if (seen.has(value)) { + throw new ToolRuntimeError("InvalidDataValue", `${label} contains a circular value.`) + } + + seen.add(value) + + if (Array.isArray(value)) { + const copied = value.map((item) => copyBounded(item, label, depth + 1, seen, preserveSandboxValues)) + seen.delete(value) + return copied + } + + const prototype = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) { + throw new ToolRuntimeError("InvalidDataValue", `${label} must contain plain objects only.`) + } + + const copied: SafeObject = Object.create(null) as SafeObject + for (const [key, item] of Object.entries(value)) { + if (isBlockedMember(key)) { + throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`) + } + copied[key] = copyBounded(item, label, depth + 1, seen, preserveSandboxValues) + } + seen.delete(value) + return copied +} + +export const copyOut = (value: unknown, undefinedAsNull = false): unknown => { + if (value === undefined && undefinedAsNull) return null + // Normalize non-finite numbers to null as the value crosses out of the sandbox (final return + // and tool-call arguments both funnel through here), matching JSON semantics - NaN/Infinity + // have no JSON representation, so JSON.stringify would produce null anyway. + if (typeof value === "number" && !Number.isFinite(value)) { + return null + } + if (Array.isArray(value)) { + return value.map((item) => copyOut(item, undefinedAsNull)) + } + + if (value !== null && typeof value === "object" && !(value instanceof ToolReference)) { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, copyOut(item, undefinedAsNull)])) + } + + return value +} + +const definitions = ( + tools: HostTools, + path: ReadonlyArray = [], +): Array<{ path: string; definition: Definition }> => { + const entries: Array<{ path: string; definition: Definition }> = [] + for (const [name, value] of Object.entries(tools)) { + const next = [...path, name] + if (isDefinition(value)) entries.push({ path: next.join("."), definition: value }) + else if (typeof value !== "function") entries.push(...definitions(value, next)) + } + return entries +} + +const visibleDefinitions = (tools: HostTools) => + definitions(tools).map(({ path, definition }) => ({ + path, + definition, + description: { + path, + description: definition.description, + signature: `${toolExpression(path)}(input: ${inputTypeScript(definition)}): Promise<${outputTypeScript(definition)}>`, + }, + })) + +export const catalog = (tools: HostTools): ReadonlyArray => + visibleDefinitions(tools).map(({ description }) => description) + +export type DiscoveryPlan = { + readonly catalog: ReadonlyArray + readonly instructions: string + readonly searchIndex: ReadonlyArray +} + +export type SearchEntry = { + readonly description: ToolDescription + /** + * JSDoc-annotated multiline signature shown on search-result items; the compact + * single-line form (inline catalog lines) stays in `description.signature`. + */ + readonly signature: string + /** Top-level namespace (first path segment), matched by the search `namespace` option. */ + readonly namespace: string + /** Lowercased path + description + input property names/descriptions, for substring matching. */ + readonly searchText: string +} + +/** + * Split a query into lowercased search terms. camelCase boundaries are split + * (`resolveLibrary` -> `resolve library`) and every non-alphanumeric character is a + * separator, so `resolve-library-id`, `resolveLibraryId`, and `resolve library id` all + * tokenize alike. Empties and the `*` wildcard are dropped. + */ +const tokenize = (query: string): Array => + query + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((term) => term.length > 0 && term !== "*") + +/** + * A term plus its naive singular variants (trailing "s"/"es" stripped), so a plural + * query term ("issues") still matches indexed text that only carries the singular + * ("issue"). Matching is one-directional substring containment, so the variants are + * needed only on the query side; scoring weights are unchanged - each field check + * passes when ANY form matches. + */ +const termForms = (term: string): Array => { + const forms = [term] + if (term.endsWith("es") && term.length > 3) forms.push(term.slice(0, -2)) + if (term.endsWith("s") && term.length > 2) forms.push(term.slice(0, -1)) + return forms +} + +const catalogLine = (tool: ToolDescription) => { + // 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}` +} + +const toSearchEntry = (path: string, definition: Definition, description: ToolDescription): SearchEntry => ({ + description, + signature: `${toolExpression(path)}(input: ${inputTypeScript(definition, true)}): Promise<${outputTypeScript(definition, true)}>`, + namespace: path.split(".", 1)[0]!, + searchText: [ + path, + definition.description, + ...inputProperties(definition).flatMap(({ name, description: property }) => + property === undefined ? [name] : [name, property], + ), + ] + .join("\n") + .toLowerCase(), +}) + +/** The runtime search index over every described tool. Search is always registered. */ +export const searchIndex = (tools: HostTools): ReadonlyArray => + visibleDefinitions(tools).map(({ path, definition, description }) => toSearchEntry(path, definition, description)) + +export const assertValidTools = (tools: HostTools): void => { + if (Object.hasOwn(tools, reservedNamespace)) { + throw new Error(`Tool namespace '${reservedNamespace}' is reserved for CodeMode discovery tools.`) + } +} + +/** + * Budgeted catalog: every namespace is always listed with its tool count; full call + * signatures are inlined against the `maxInlineCatalogTokens` budget (estimated tokens, + * chars/4) round-robin across namespaces - in each round (namespaces alphabetical), every + * namespace still holding un-inlined tools attempts to place its next-cheapest line, and + * a namespace whose next line does not fit is done while the others keep going - so every + * namespace gets some representation before any namespace gets everything. The section + * states exactly how comprehensive it is - overall (COMPLETE vs PARTIAL) and per + * namespace. Namespace stub lines are never budgeted: every namespace appears with its + * tool count even at budget 0. + */ +export const discoveryPlan = ( + tools: HostTools, + maxInlineCatalogTokens = defaultMaxInlineCatalogTokens, +): DiscoveryPlan => { + if (!Number.isSafeInteger(maxInlineCatalogTokens) || maxInlineCatalogTokens < 0) { + throw new RangeError("discovery.maxInlineCatalogTokens must be a non-negative safe integer") + } + const visible = visibleDefinitions(tools) + const described = visible.map(({ description }) => description) + + const namespaces = new Map>() + for (const tool of described) { + const [namespace = tool.path] = tool.path.split(".") + const group = namespaces.get(namespace) ?? [] + group.push(tool) + namespaces.set(namespace, group) + } + const ordered = [...namespaces].sort(([left], [right]) => left.localeCompare(right)) + + // Select which signatures fit the budget before emitting, so the list can state + // exactly how comprehensive it is. Round-robin fairness: in each round (namespaces + // alphabetical), every namespace still holding un-inlined tools tries to place its + // next-cheapest line against the shared budget; a namespace whose next line does not + // fit is done - the others keep going - so every namespace gets some representation + // before any namespace gets everything. + const selections = ordered.map(([namespace, group]) => ({ + namespace, + picked: new Set(), + queue: [...group].sort( + (left, right) => + estimateTokens(catalogLine(left)) - estimateTokens(catalogLine(right)) || left.path.localeCompare(right.path), + ), + })) + let used = 0 + let active = selections.filter((selection) => selection.queue.length > 0) + while (active.length > 0) { + const stillActive: typeof active = [] + for (const selection of active) { + const tool = selection.queue[0]! + const cost = estimateTokens(catalogLine(tool)) + if (used + cost > maxInlineCatalogTokens) continue + selection.queue.shift() + selection.picked.add(tool) + used += cost + if (selection.queue.length > 0) stillActive.push(selection) + } + active = stillActive + } + const shown = new Map>( + selections.map(({ namespace, picked }) => [namespace, picked]), + ) + const totalShown = selections.reduce((total, { picked }) => total + picked.size, 0) + const complete = totalShown === described.length + + const empty = described.length === 0 + + // 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 placeholders - never a real or fabricated + // tool name - and show both dot and bracket notation so non-identifier names are not normalized. + const intro = [ + "Write a CodeMode program to answer the request. Return code only.", + empty + ? "Execute JavaScript in a confined runtime." + : 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 + // catalog already shows every signature, so step 1 picks from the list instead. + const workflow = empty + ? [] + : [ + "", + "## Workflow", + "", + ...(complete + ? [ + "1. Pick a tool from the list under `## Available tools` - each line is the exact call signature; use it as-is rather than guessing segments.", + '2. Call it using the exact signature shown; bracket notation and quotes are part of the path.', + '3. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string.', + "4. Return only the fields you need: `return { : data. }` - raw payloads get truncated and waste context.", + ] + : [ + '1. If the exact signature is not listed below, first search: `const { items } = await tools.$codemode.search({ query: "" })`.', + "2. Read the matches: each item is `{ path, description, signature }` - read the description before using an unfamiliar tool.", + "3. Call the result's `path` as-is; bracket notation and quotes are part of the path.", + '4. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string.', + "5. Return only the fields you need: `return { : data. }` - raw payloads get truncated and waste context.", + ]), + ] + + const rules = empty + ? [] + : [ + "", + "## Rules", + "", + complete + ? "- Only tools listed here are available inside `tools`; tools from the surrounding agent/runtime are not implicitly exposed." + : "- Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`; tools from the surrounding agent/runtime are not implicitly exposed.", + "- Filter, aggregate, and transform collections in code - never return them raw or call a tool per item across messages.", + "- A result typed `Promise` 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..(item)))`, or use `tools.["tool-name"](item)` when the listed signature uses bracket notation.', + "- `Object.keys(tools)` lists namespaces; `Object.keys(tools.)` lists its tools; `for...in` works on both.", + ...(complete + ? [] + : ['- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "" })`.']), + ] + + const syntax = [ + "", + "## Syntax", + "", + "Standard modern JavaScript works: functions/closures, destructuring, template literals, loops, try/catch, spread, optional chaining, the usual Array/String/Object/Math/JSON methods, plus Date, RegExp, Map, Set, and Promise.all/allSettled/race/resolve/reject.", + "TypeScript type annotations are allowed and stripped before execution (decorators are not supported).", + "Not supported (each fails with a message naming the alternative): classes, generators, for await...of, .then/.catch/.finally (use await with try/catch).", + "Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.", + ] + + const toolSection: Array = [""] + if (empty) { + toolSection.push("## Available tools", "", "No tools are currently available.") + } else { + toolSection.push( + complete + ? "## Available tools (COMPLETE list - every tool is shown below with its full call signature)" + : `## Available tools (PARTIAL - ${totalShown} of ${described.length} shown; find the rest with tools.$codemode.search)`, + "", + ) + for (const [namespace, group] of ordered) { + const picked = shown.get(namespace)! + const count = `${group.length} tool${group.length === 1 ? "" : "s"}` + // Annotate only when a namespace is not fully shown, so a comprehensive + // namespace reads cleanly and a truncated one is unambiguous. + const label = + picked.size === group.length + ? count + : picked.size === 0 + ? `${count}, none shown` + : `${count}, ${picked.size} shown` + toolSection.push(`- ${namespace} (${label})`) + for (const tool of group) if (picked.has(tool)) toolSection.push(catalogLine(tool)) + } + if (!complete) { + toolSection.push("", "Search returns complete callable signatures:", `- ${searchSignature}`) + } + } + + const lines = [...intro, ...workflow, ...rules, ...syntax, ...toolSection] + return { + catalog: described, + instructions: lines.join("\n"), + searchIndex: visible.map(({ path, definition, description }) => toSearchEntry(path, definition, description)), + } +} + +/** + * The enumerable names at one node of the host tool tree - namespace names at the root, + * tool/namespace names below - powering `Object.keys(tools)` and `for...in` over tool + * references. A callable tool is a leaf and enumerates as `[]` (like `Object.keys` of a + * function in JS). An unknown path is an `UnknownTool` error pointing at the working + * discovery idioms, mirroring how calling an unknown tool fails. + */ +const namespaceKeys = ( + tools: HostTools, + path: ReadonlyArray, + searchEnabled: boolean, +): ReadonlyArray => { + // The reserved discovery namespace is virtual (never present in the host tree); enumerate + // it explicitly so `Object.keys(tools.$codemode)` matches the callable surface. + if (searchEnabled && path.length === 1 && path[0] === reservedNamespace) return ["search"] + let value: HostTool | Definition | HostTools = tools + for (const segment of path) { + if ( + isBlockedMember(segment) || + typeof value === "function" || + isDefinition(value) || + !Object.hasOwn(value, segment) + ) { + throw new ToolRuntimeError( + "UnknownTool", + `Unknown tool namespace '${path.join(".")}'.`, + searchEnabled + ? [ + "Object.keys(tools) lists the available namespaces; tools.$codemode.search({ query }) finds described tools.", + ] + : ["Object.keys(tools) lists the available namespaces."], + ) + } + value = value[segment] as HostTool | Definition | HostTools + } + if (typeof value === "function" || isDefinition(value)) return [] + return Object.keys(value) +} + +const resolve = ( + tools: HostTools, + path: ReadonlyArray, + searchEnabled: boolean, +): HostTool | Definition => { + let value: HostTool | Definition | HostTools = tools + + for (const segment of path) { + if ( + isBlockedMember(segment) || + typeof value === "function" || + isDefinition(value) || + !Object.hasOwn(value, segment) + ) { + throw new ToolRuntimeError( + "UnknownTool", + `Unknown tool '${path.join(".")}'.`, + searchEnabled ? ["Use tools.$codemode.search({ query }) to find available described tools."] : [], + ) + } + value = value[segment] as HostTool | Definition | HostTools + } + + if (typeof value !== "function" && !isDefinition(value)) { + throw new ToolRuntimeError("UnknownTool", `Tool '${path.join(".")}' is not callable.`) + } + + return value +} + +export type ToolRuntime = { + readonly root: ToolReference + readonly calls: Array + readonly invoke: (path: ReadonlyArray, args: Array) => Effect.Effect + /** Enumerable namespace/tool names at one node of the host tool tree; see `namespaceKeys`. */ + readonly keys: (path: ReadonlyArray) => ReadonlyArray +} + +export const make = ( + tools: HostTools, + /** Undefined means unlimited tool calls. */ + maxToolCalls: number | undefined, + hooks?: ToolCallHooks, + searchIndex?: ReadonlyArray, +): ToolRuntime => { + const calls: Array = [] + const searchEnabled = searchIndex !== undefined + + // Wraps the settling portion of a tool call so onToolCallEnd observes success and failure + // symmetrically. Interruption (e.g. the execution timeout) fires neither outcome. + const observeEnd = (effect: Effect.Effect, call: ToolCallStarted): Effect.Effect => { + const onEnd = hooks?.onToolCallEnd + if (onEnd === undefined) return effect + const startedAt = Date.now() + return effect.pipe( + Effect.tap(() => onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "success" })), + 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, + }) + }), + ) + } + + const decodeOutput = (value: unknown, name: string) => + Effect.try({ + try: () => copyIn(value, `Result from tool '${name}'`), + catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`), + }) + + const recordCall = (call: ToolCall): void => { + if (maxToolCalls !== undefined && calls.length >= maxToolCalls) { + throw new ToolRuntimeError("ToolCallLimitExceeded", `Execution exceeded its tool-call limit of ${maxToolCalls}.`) + } + calls.push(call) + } + + return { + root: new ToolReference([]), + calls, + keys: (path) => namespaceKeys(tools, path, searchEnabled), + invoke: (path, args) => + Effect.gen(function* () { + const name = path.join(".") + const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`))) + const call = { name } + const recordAndObserve = (input: unknown) => + Effect.sync(() => { + recordCall(call) + return calls.length - 1 + }).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void)) + if (name === "$codemode.search") { + if (!searchEnabled) throw new ToolRuntimeError("UnknownTool", `Unknown tool '${name}'.`) + const input = externalArgs[0] + if (externalArgs.length !== 1 || input === null || typeof input !== "object" || Array.isArray(input)) { + throw new ToolRuntimeError( + "InvalidToolInput", + "tools.$codemode.search expects { query?: string; namespace?: string; limit?: number }.", + ) + } + const request = input as { query?: unknown; namespace?: unknown; limit?: unknown } + if (request.query !== undefined && typeof request.query !== "string") { + throw new ToolRuntimeError( + "InvalidToolInput", + "tools.$codemode.search query must be a string when provided.", + ) + } + if (request.namespace !== undefined && typeof request.namespace !== "string") { + throw new ToolRuntimeError( + "InvalidToolInput", + "tools.$codemode.search namespace must be a string when provided.", + ) + } + if ( + request.limit !== undefined && + (typeof request.limit !== "number" || !Number.isSafeInteger(request.limit) || request.limit <= 0) + ) { + throw new ToolRuntimeError( + "InvalidToolInput", + "tools.$codemode.search limit must be a positive safe integer when provided.", + ) + } + const query = typeof request.query === "string" ? request.query : "" + const namespace = typeof request.namespace === "string" ? request.namespace : undefined + const index = yield* recordAndObserve(request) + return yield* observeEnd( + Effect.try({ + try: () => { + const limit = typeof request.limit === "number" ? request.limit : defaultSearchLimit + const scoped = + namespace === undefined ? searchIndex : searchIndex.filter((entry) => entry.namespace === namespace) + // A query that names one tool path exactly (canonical path or rendered + // JavaScript expression) is a lookup, not a search: return that tool alone. + const trimmed = query.trim() + const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed + const exact = + pathQuery === "" + ? undefined + : scoped.find( + (entry) => + entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed, + ) + const terms = tokenize(query).map(termForms) + // Additive field-weighted scoring, summed across terms: exact path or path + // segment (20) > path substring (8) > description substring (4) > any + // searchable text, incl. input parameter names/descriptions (2). Each term + // matches a field when any of its forms (the term or a singular variant) + // does. An empty query browses everything, alphabetical by path. + const ranked = + exact !== undefined + ? [exact] + : scoped + .map((entry) => { + const path = entry.description.path.toLowerCase() + const description = entry.description.description.toLowerCase() + const score = terms.reduce( + (total, forms) => + total + + (forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) + + (forms.some((form) => path.includes(form)) ? 8 : 0) + + (forms.some((form) => description.includes(form)) ? 4 : 0) + + (forms.some((form) => entry.searchText.includes(form)) ? 2 : 0), + 0, + ) + return { entry, score } + }) + .filter(({ score }) => terms.length === 0 || score > 0) + .sort( + (left, right) => + right.score - left.score || + left.entry.description.path.localeCompare(right.entry.description.path), + ) + .map(({ entry }) => entry) + // Result paths are rendered as JavaScript expressions so each `path` is + // directly usable as the call site (`await tools.github.list({ ... })` or + // `await tools.ns["dashed-name"]({ ... })`). The signature is the pretty, + // JSDoc-annotated form (schema descriptions and constraints ride along as + // field comments). + const items = ranked.slice(0, limit).map(({ description, signature }) => ({ + ...description, + path: toolExpression(description.path), + signature, + })) + return copyIn({ items, total: ranked.length }, "Result from tool '$codemode.search'") + }, + catch: (cause) => cause, + }), + { index, name, input: request }, + ) + } + + const tool = resolve(tools, path, searchEnabled) + let describedInput: unknown + if (isDefinition(tool)) { + if (externalArgs.length !== 1) + throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`) + describedInput = yield* Effect.try({ + try: () => decodeToolInput(tool, externalArgs[0]), + catch: (cause) => + new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`), + }) + } + const input = isDefinition(tool) ? describedInput : externalArgs + const index = yield* recordAndObserve(input) + const currentCall = { index, name, input } + if (isDefinition(tool)) { + return yield* observeEnd( + Effect.gen(function* () { + const raw = yield* runHost(Effect.suspend(() => tool.run(describedInput))) + const result = yield* Effect.try({ + try: () => decodeToolOutput(tool, raw), + catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`), + }) + return yield* decodeOutput(result, name) + }), + currentCall, + ) + } + return yield* observeEnd( + Effect.gen(function* () { + return yield* decodeOutput(yield* runHost(Effect.suspend(() => tool(...externalArgs))), name) + }), + currentCall, + ) + }), + } +} + +export * as ToolRuntime from "./tool-runtime.js" diff --git a/packages/codemode/src/tool-schema.ts b/packages/codemode/src/tool-schema.ts new file mode 100644 index 0000000000..65c444083f --- /dev/null +++ b/packages/codemode/src/tool-schema.ts @@ -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 & 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 => { + 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> + /** Indented, JSDoc-annotated multiline rendering (search results); compact single line otherwise. */ + readonly pretty: boolean +} + +const hasUnresolvedRef = ( + schema: JsonSchema, + definitions: Readonly>, + seen: ReadonlySet = new Set(), + visited: ReadonlySet = 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 => { + const tags: Array = [] + 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, 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 = 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`. + 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> + } + 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 = (definition: Definition): Array => { + try { + const document = isEffectSchema(definition.input) + ? (Schema.toJsonSchemaDocument(definition.input) as { + readonly schema: JsonSchema + readonly definitions?: Readonly> + }) + : { + 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 = (definition: Definition, 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 = (definition: Definition, 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 = (definition: Definition, 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 = (definition: Definition, value: unknown): unknown => + definition.output !== undefined && isEffectSchema(definition.output) + ? Schema.decodeUnknownSync(definition.output)(value) + : value diff --git a/packages/codemode/src/tool.ts b/packages/codemode/src/tool.ts new file mode 100644 index 0000000000..6c6863f99d --- /dev/null +++ b/packages/codemode/src/tool.ts @@ -0,0 +1,96 @@ +import { Effect, Schema } from "effect" + +/** + * JSON Schema subset accepted for render-only tool schemas. + * + * A JSON-Schema-described side of a tool is used to generate the model-visible TypeScript + * signature only - CodeMode performs no validation against it. This is the natural shape for + * adapter-provided tools (e.g. MCP definitions) whose schemas arrive as JSON Schema documents. + */ +export type JsonSchema = { + readonly type?: string | ReadonlyArray + readonly enum?: ReadonlyArray + readonly const?: unknown + readonly anyOf?: ReadonlyArray + readonly oneOf?: ReadonlyArray + readonly allOf?: ReadonlyArray + readonly properties?: Readonly> + readonly required?: ReadonlyArray + readonly items?: JsonSchema + readonly additionalProperties?: boolean | JsonSchema + readonly description?: string + readonly default?: unknown + readonly format?: string + readonly deprecated?: boolean + readonly minItems?: number + readonly maxItems?: number + readonly $ref?: string + readonly $defs?: Readonly> + readonly definitions?: Readonly> +} + +/** Either a validating Effect Schema or a render-only JSON Schema document. */ +export type SchemaType = Schema.Decoder | JsonSchema + +/** Schema-backed tool definition consumed by a CodeMode tool tree. */ +export type Definition = { + readonly _tag: "CodeModeTool" + readonly description: string + readonly input: SchemaType + readonly output: SchemaType | undefined + readonly run: (input: unknown) => Effect.Effect +} + +/** The value `run` receives: the decoded type for Effect Schemas, `unknown` for JSON Schemas. */ +type InputType = S extends Schema.Decoder ? S["Type"] : unknown + +/** The value `run` returns: the encoded type for Effect Schemas, `unknown` otherwise. */ +type ResultType = S extends Schema.Decoder ? S["Encoded"] : unknown + +/** Options for defining one CodeMode tool. */ +export type Options = { + readonly description: string + readonly input: I + readonly output?: O + readonly run: (input: InputType) => Effect.Effect, unknown, R> +} + +export const isDefinition = (value: unknown): value is Definition => + typeof value === "object" && value !== null && "_tag" in value && value._tag === "CodeModeTool" + +/** + * Defines one schema-described tool available to a CodeMode program through `tools.*`. + * + * `input` and `output` each accept a validating Effect Schema or a render-only JSON Schema + * document. Effect Schema input is decoded before `run` is invoked, and `run` returns the + * encoded representation of an Effect Schema `output`, which CodeMode decodes before returning + * it to the program. JSON Schemas only shape the model-visible signature; values pass through + * unvalidated. `output` is optional - without it the signature advertises `unknown` and the + * host result is exposed as-is. The host tool remains responsible for authorization and + * durable side-effect handling. + * + * @example + * ```ts + * const lookup = Tool.make({ + * description: "Look up an order", + * input: Schema.Struct({ id: Schema.String }), + * output: Schema.Struct({ status: Schema.String }), + * run: ({ id }) => Effect.succeed({ status: "open" }), + * }) + * + * const fromJsonSchema = Tool.make({ + * description: "Call an adapter-described tool", + * input: { type: "object", properties: { id: { type: "string" } }, required: ["id"] }, + * run: (input) => callHost(input), + * }) + * ``` + */ +export const make = ( + options: Options, +): Definition => ({ + _tag: "CodeModeTool", + description: options.description, + input: options.input, + output: options.output, + run: (input) => options.run(input as InputType), +}) diff --git a/packages/codemode/src/values.ts b/packages/codemode/src/values.ts new file mode 100644 index 0000000000..07f22adb8a --- /dev/null +++ b/packages/codemode/src/values.ts @@ -0,0 +1,34 @@ +import type { Effect, Fiber } from "effect" + +export class SandboxPromise { + interrupted = false + constructor( + readonly fiber: Fiber.Fiber | undefined, + readonly immediate?: Effect.Effect, + ) {} +} + +export class SandboxDate { + constructor(readonly time: number) {} +} + +export class SandboxRegExp { + readonly regex: RegExp + constructor(pattern: string, flags: string) { + this.regex = new RegExp(pattern, flags) + } +} + +export class SandboxMap { + readonly map = new Map() +} + +export class SandboxSet { + readonly set = new Set() +} + +export const isSandboxValue = (value: unknown): value is SandboxDate | SandboxRegExp | SandboxMap | SandboxSet => + value instanceof SandboxDate || + value instanceof SandboxRegExp || + value instanceof SandboxMap || + value instanceof SandboxSet diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts new file mode 100644 index 0000000000..087678c778 --- /dev/null +++ b/packages/codemode/test/codemode.test.ts @@ -0,0 +1,1095 @@ +import { describe, expect, test } from "bun:test" +import { Cause, Effect, Schema } from "effect" +import { CodeMode, Tool, toolError } from "../src/index.js" + +const run = (tool: Tool.Definition) => + Effect.runPromise(CodeMode.make({ tools: { host: { call: tool } } }).execute("return await tools.host.call({})")) + +class UnsafeHostError extends Schema.TaggedErrorClass()("UnsafeHostError", { + reason: Schema.String, +}) {} + +describe("CodeMode host failure boundary", () => { + test("preserves explicit safe tool failures", async () => { + const result = await run( + Tool.make({ + description: "Fail safely", + input: Schema.Struct({}), + output: Schema.String, + run: () => Effect.fail(toolError("Authorized request was refused")), + }), + ) + + expect(result.ok ? undefined : result.error).toStrictEqual({ + kind: "ToolFailure", + message: "Authorized request was refused", + }) + }) + + test("sanitizes unknown host failures and defects", async () => { + for (const failure of [ + Effect.fail(new UnsafeHostError({ reason: "Authorization: Bearer typed-secret" })), + Effect.die(new Error("postgres://user:defect-secret@example.invalid")), + ]) { + const result = await run( + Tool.make({ + description: "Fail internally", + input: Schema.Struct({}), + output: Schema.String, + run: () => failure, + }), + ) + + expect(result.ok ? undefined : result.error).toStrictEqual({ + kind: "ToolFailure", + message: "Tool execution failed", + }) + expect(JSON.stringify(result)).not.toMatch(/typed-secret|defect-secret|Authorization: Bearer/) + } + }) + + test("sanitizes invalid host output", async () => { + const secret = "invalid-output-secret" + const result = await run( + Tool.make({ + description: "Return invalid output", + input: Schema.Struct({}), + output: Schema.Struct({ safe: Schema.String }), + run: () => Effect.succeed({ safe: 1, secret } as unknown as { readonly safe: string }), + }), + ) + + expect(result.ok ? undefined : result.error).toStrictEqual({ + kind: "InvalidToolOutput", + message: "Invalid output from tool 'host.call'.", + }) + expect(JSON.stringify(result)).not.toMatch(/invalid-output-secret/) + }) + + test("sanitizes host output that throws while being copied", async () => { + const result = await run( + Tool.make({ + description: "Return hostile output", + input: Schema.Struct({}), + output: Schema.Unknown, + run: () => + Effect.succeed( + new Proxy( + {}, + { + ownKeys: () => { + throw new Error("host-output-secret") + }, + }, + ), + ), + }), + ) + + expect(result.ok ? undefined : result.error).toStrictEqual({ + kind: "InvalidToolOutput", + message: "Invalid output from tool 'host.call'.", + }) + expect(JSON.stringify(result)).not.toMatch(/host-output-secret/) + }) + + test("caught tool failures are Error values in-program", async () => { + const result = await Effect.runPromise( + CodeMode.make({ + tools: { + host: { + call: Tool.make({ + description: "Refuse", + input: Schema.Struct({}), + output: Schema.String, + run: () => Effect.fail(toolError("Refused")), + }), + }, + }, + }).execute(` + try { + await tools.host.call({}) + return "no" + } catch (e) { + return { isError: e instanceof Error, message: e.message } + } + `), + ) + + expect(result.ok).toBe(true) + if (result.ok) expect(result.value).toStrictEqual({ isError: true, message: "Refused" }) + }) + + test("propagates host interruption instead of returning a diagnostic", async () => { + const exit = await Effect.runPromiseExit( + CodeMode.make({ + tools: { + host: { + call: Tool.make({ + description: "Interrupt", + input: Schema.Struct({}), + output: Schema.String, + run: () => Effect.interrupt, + }), + }, + }, + }).execute("return await tools.host.call({})"), + ) + + expect(exit._tag).toBe("Failure") + if (exit._tag === "Failure") { + expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true) + } + }) +}) + +describe("CodeMode tool-call observation", () => { + test("reports the tools actually invoked with decoded input", async () => { + const calls: Array = [] + const lookup = Tool.make({ + description: "Look up a value", + input: Schema.Struct({ query: Schema.String }), + output: Schema.String, + run: ({ query }) => Effect.succeed(query), + }) + + const result = await Effect.runPromise( + CodeMode.make({ + tools: { context: { lookup } }, + onToolCallStart: (call) => Effect.sync(() => calls.push(call)), + }).execute(` + if (false) await tools.context.lookup({ query: "not called" }) + return await tools.context.lookup({ query: "deployment failure" }) + `), + ) + + expect(result.ok).toBe(true) + expect(calls).toStrictEqual([{ index: 0, name: "context.lookup", input: { query: "deployment failure" } }]) + }) + + test("observes settled calls with outcome and duration", async () => { + const events: Array<{ phase: string; index: number; name: string; outcome?: string; message?: string }> = [] + const lookup = Tool.make({ + description: "Look up a value", + input: Schema.Struct({ query: Schema.String }), + output: Schema.String, + run: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query)), + }) + + const runtime = CodeMode.make({ + tools: { context: { lookup } }, + onToolCallStart: (call) => + Effect.sync(() => { + events.push({ phase: "start", index: call.index, name: call.name }) + }), + onToolCallEnd: (call) => + Effect.sync(() => { + expect(call.durationMs).toBeGreaterThanOrEqual(0) + events.push({ + phase: "end", + index: call.index, + name: call.name, + outcome: call.outcome, + ...(call.message === undefined ? {} : { message: call.message }), + }) + }), + }) + + const success = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "ok" })`)) + expect(success.ok).toBe(true) + const failure = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "boom" })`)) + expect(failure.ok).toBe(false) + + expect(events).toStrictEqual([ + { phase: "start", index: 0, name: "context.lookup" }, + { phase: "end", index: 0, name: "context.lookup", outcome: "success" }, + { phase: "start", index: 0, name: "context.lookup" }, + { phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "Lookup refused" }, + ]) + }) +}) + +describe("CodeMode console capture", () => { + test("captures console output as bounded result logs", async () => { + const result = await Effect.runPromise( + CodeMode.execute({ + code: ` + const returned = console.log("Thread info:", { name: "Demo", count: 2 }) + console.warn("careful") + return returned + `, + }), + ) + + expect(result).toStrictEqual({ + ok: true, + value: null, + logs: ['Thread info: {"name":"Demo","count":2}', "[warn] careful"], + toolCalls: [], + }) + expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result) + }) + + test("keeps logs captured before failures", async () => { + const result = await Effect.runPromise( + CodeMode.execute({ + code: ` + console.log("before failure") + throw new Error("boom") + `, + }), + ) + + expect(result.ok ? undefined : result.logs).toStrictEqual(["before failure"]) + expect(result.ok ? undefined : result.error.message).toBe("Uncaught: boom") + }) + + test("prints NaN and Infinity literally instead of the JSON null", async () => { + const result = await Effect.runPromise( + CodeMode.execute({ + code: ` + console.log(NaN) + console.log(Infinity, -Infinity) + console.log({ ratio: NaN, bounds: [Infinity] }) + return null + `, + }), + ) + + expect(result.ok).toBe(true) + expect(result.logs).toStrictEqual(["NaN", "Infinity -Infinity", '{"ratio":NaN,"bounds":[Infinity]}']) + }) + + test("renders sandbox values nested inside logged containers", async () => { + const result = await Effect.runPromise( + CodeMode.execute({ + code: ` + console.log({ m: new Map([["a", 1]]), when: new Date(0), r: /ab/g, s: new Set([1, 2]) }) + console.log([new Date(0)]) + return null + `, + }), + ) + + expect(result.ok).toBe(true) + expect(result.logs).toStrictEqual([ + '{"m":Map(1) [["a",1]],"when":1970-01-01T00:00:00.000Z,"r":/ab/g,"s":Set(2) [1,2]}', + "[1970-01-01T00:00:00.000Z]", + ]) + }) + + test("console formatting is total: cycles and opaque references render as markers", async () => { + const result = await Effect.runPromise( + CodeMode.execute({ + code: ` + const m = new Map() + m.set("self", m) + console.log({ box: m }) + console.log({ fn: (x) => x, ok: 1 }) + return null + `, + }), + ) + + expect(result.ok).toBe(true) + expect(result.logs).toStrictEqual(['{"box":Map(1) [["self",[Circular]]]}', '{"fn":[CodeMode reference],"ok":1}']) + }) + + test("console.table renders sandbox value cells", async () => { + const result = await Effect.runPromise( + CodeMode.execute({ + code: ` + console.table([{ when: new Date(0), n: NaN }]) + return null + `, + }), + ) + + expect(result.ok).toBe(true) + expect(result.logs).toStrictEqual(["(index)\twhen\tn\n0\t1970-01-01T00:00:00.000Z\tNaN"]) + }) + + test("captures console.dir and console.table output", async () => { + const result = await Effect.runPromise( + CodeMode.execute({ + code: ` + console.dir({ nested: { ok: true } }) + console.table([ + { name: "Kit", count: 1, hidden: "x" }, + { name: "Olive", count: 2, hidden: "y" } + ], ["name", "count"]) + return "done" + `, + }), + ) + + expect(result).toStrictEqual({ + ok: true, + value: "done", + logs: ['{"nested":{"ok":true}}', "(index)\tname\tcount\n0\tKit\t1\n1\tOlive\t2"], + toolCalls: [], + }) + }) +}) + +describe("CodeMode output budget", () => { + test("absent maxOutputBytes means no truncation at all", async () => { + const result = await Effect.runPromise( + CodeMode.execute({ + code: `console.log("z".repeat(50_000)); return "x".repeat(100_000)`, + }), + ) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.truncated).toBeUndefined() + expect(result.value).toBe("x".repeat(100_000)) + expect(result.logs).toStrictEqual(["z".repeat(50_000)]) + }) + + test("truncates an oversized result value with a marker instead of failing", async () => { + const limits: CodeMode.ExecutionLimits = { maxOutputBytes: 40 } + const result = await Effect.runPromise( + CodeMode.execute({ + code: `return { data: "${"x".repeat(200)}" }`, + limits, + }), + ) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.truncated).toBe(true) + expect(typeof result.value).toBe("string") + expect(result.value).toMatch( + /^\{"data":"x+ \[result truncated: \d+ bytes exceeds the 40-byte output limit; return a smaller value\]$/, + ) + 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: CodeMode.ExecutionLimits = { maxOutputBytes: 40 } + const result = await Effect.runPromise( + CodeMode.execute({ + code: ` + console.log("first line") + console.log("${"y".repeat(200)}") + return "ok" + `, + limits, + }), + ) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.value).toBe("ok") + expect(result.truncated).toBe(true) + expect(result.logs).toStrictEqual(["first line", "[logs truncated: showing 1 of 2 lines]"]) + }) + + test("does not mark results within the budget", async () => { + const result = await Effect.runPromise( + CodeMode.execute({ + code: ` + console.log("fits") + return { fits: true } + `, + }), + ) + expect(result).toStrictEqual({ + ok: true, + value: { fits: true }, + logs: ["fits"], + toolCalls: [], + }) + }) +}) + +describe("CodeMode schema flexibility", () => { + test("accepts render-only JSON Schema input and omitted output", async () => { + const observed: Array = [] + const call = Tool.make({ + description: "Call an adapter-described tool", + input: { + type: "object", + properties: { id: { type: "string" }, count: { type: "number" } }, + required: ["id"], + }, + run: (input) => + Effect.sync(() => { + observed.push(input) + return { echoed: input } + }), + }) + const runtime = CodeMode.make({ tools: { adapter: { call } } }) + + expect(runtime.catalog()).toStrictEqual([ + { + path: "adapter.call", + description: "Call an adapter-described tool", + signature: "tools.adapter.call(input: { id: string; count?: number }): Promise", + }, + ]) + + // JSON Schema is render-only: mistyped input passes through unvalidated. + const result = await Effect.runPromise(runtime.execute(`return await tools.adapter.call({ id: 42 })`)) + expect(result.ok).toBe(true) + if (result.ok) expect(result.value).toStrictEqual({ echoed: { id: 42 } }) + expect(observed).toStrictEqual([{ id: 42 }]) + }) + + test("renders JSON Schema outputs and $defs references", async () => { + const lookup = Tool.make({ + description: "Look up a user", + input: { type: "object", properties: { login: { type: "string" } }, required: ["login"] }, + output: { + $ref: "#/$defs/User", + $defs: { + User: { + type: "object", + properties: { login: { type: "string" }, id: { type: "number" } }, + required: ["login", "id"], + }, + }, + }, + run: () => Effect.succeed({ login: "kit", id: 7 }), + }) + const runtime = CodeMode.make({ tools: { users: { lookup } } }) + + expect(runtime.catalog()).toStrictEqual([ + { + path: "users.lookup", + description: "Look up a user", + signature: "tools.users.lookup(input: { login: string }): Promise<{ login: string; id: number }>", + }, + ]) + + const result = await Effect.runPromise(runtime.execute(`return await tools.users.lookup({ login: "kit" })`)) + expect(result.ok).toBe(true) + if (result.ok) expect(result.value).toStrictEqual({ login: "kit", id: 7 }) + }) + + test("Effect Schema output without an input transform still renders unknown when omitted", async () => { + const ping = Tool.make({ + description: "Ping", + input: Schema.Struct({ host: Schema.String }), + run: () => Effect.succeed("pong"), + }) + const runtime = CodeMode.make({ tools: { net: { ping } } }) + expect(runtime.catalog()[0]?.signature).toBe("tools.net.ping(input: { host: string }): Promise") + + const result = await Effect.runPromise(runtime.execute(`return await tools.net.ping({ host: "example.test" })`)) + expect(result.ok).toBe(true) + if (result.ok) expect(result.value).toBe("pong") + }) +}) + +describe("CodeMode public contract", () => { + const lookup = Tool.make({ + description: "Look up an order by ID", + input: Schema.Struct({ id: Schema.String }), + output: Schema.Struct({ id: Schema.String, status: Schema.String }), + run: ({ id }) => Effect.succeed({ id, status: "open" }), + }) + const tools = { orders: { lookup } } + const source = `return await tools.orders.lookup({ id: "order_42" })` + + test("keeps one-shot and reusable execution equivalent", async () => { + const runtime = CodeMode.make({ tools }) + const [oneShot, reusable] = await Promise.all([ + Effect.runPromise(CodeMode.execute({ tools, code: source })), + Effect.runPromise(runtime.execute(source)), + ]) + + expect(reusable).toStrictEqual(oneShot) + 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 () => { + const runtime = CodeMode.make({ tools }) + expect(runtime.catalog()).toStrictEqual([ + { + path: "orders.lookup", + description: "Look up an order by ID", + signature: "tools.orders.lookup(input: { id: string }): Promise<{ id: string; status: string }>", + }, + ]) + expect(runtime.instructions()).toContain("Available tools (COMPLETE list") + expect(runtime.instructions()).toContain("- orders (1 tool)") + expect(runtime.instructions()).toContain( + " - tools.orders.lookup(input: { id: string }): Promise<{ id: string; status: string }> // Look up an order by ID", + ) + // A fully inlined catalog does not advertise search in the instructions... + expect(runtime.instructions()).not.toMatch(/\$codemode/) + + // ...but the search tool stays registered, so a speculative call still works. Search + // results carry the pretty multiline signature; the inline catalog stays compact. + const result = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: "order" })`)) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value).toStrictEqual({ + items: [ + { + path: "tools.orders.lookup", + description: "Look up an order by ID", + signature: "tools.orders.lookup(input: {\n id: string\n}): Promise<{\n id: string\n status: string\n}>", + }, + ], + total: 1, + }) + } + }) + + test("renders bracket notation for tool names that are not JavaScript identifiers", async () => { + const resolveLibrary = Tool.make({ + description: "Resolve a library ID", + input: Schema.Struct({ libraryName: Schema.String }), + output: Schema.String, + run: ({ libraryName }) => Effect.succeed(`/resolved/${libraryName}`), + }) + const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } }) + + expect(runtime.catalog()).toStrictEqual([ + { + path: "context7.resolve-library-id", + description: "Resolve a library ID", + signature: 'tools.context7["resolve-library-id"](input: { libraryName: string }): Promise', + }, + ]) + expect(runtime.instructions()).toContain( + 'tools.context7["resolve-library-id"](input: { libraryName: string }): Promise', + ) + + const search = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: "resolve library id" })`), + ) + expect(search.ok).toBe(true) + if (search.ok) { + expect(search.value).toStrictEqual({ + items: [ + { + path: 'tools.context7["resolve-library-id"]', + description: "Resolve a library ID", + signature: 'tools.context7["resolve-library-id"](input: {\n libraryName: string\n}): Promise', + }, + ], + total: 1, + }) + } + + const call = await Effect.runPromise( + runtime.execute(`return await tools.context7["resolve-library-id"]({ libraryName: "TypeScript" })`), + ) + expect(call.ok).toBe(true) + if (call.ok) expect(call.value).toBe("/resolved/TypeScript") + + const exact = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: 'tools.context7["resolve-library-id"]' })`), + ) + expect(exact.ok).toBe(true) + if (exact.ok) expect((exact.value as { total: number }).total).toBe(1) + }) + + test("instructions use markdown sections with placeholder-only call forms", () => { + const runtime = CodeMode.make({ tools }) + const instructions = runtime.instructions() + // Sections in order: workflow at the top, catalog at the bottom. + expect(instructions).toContain("## Workflow") + expect(instructions).toContain("## Rules") + expect(instructions).toContain("## Syntax") + expect(instructions.indexOf("## Workflow")).toBeLessThan(instructions.indexOf("## Rules")) + expect(instructions.indexOf("## Rules")).toBeLessThan(instructions.indexOf("## Syntax")) + expect(instructions.indexOf("## Syntax")).toBeLessThan(instructions.indexOf("\n## Available tools (COMPLETE list")) + // The workflow carries the result-shape guidance; Rules only add content beyond it. + expect(instructions).toContain( + '`const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string', + ) + expect(instructions).toContain("Return only the fields you need") + expect(instructions).toContain("raw payloads get truncated and waste context") + 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`") + // 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 { : data. }`") + expect(instructions).not.toContain("total_count") + expect(instructions).not.toContain("list_issues") + expect(instructions).not.toContain("tools.orders.lookup({") + // COMPLETE: step 1 picks from the inlined list; search is not advertised. + expect(instructions).toContain("1. Pick a tool from the list under `## Available tools`") + expect(instructions).not.toContain("Browse one namespace") + + const partial = CodeMode.make({ tools, discovery: { maxInlineCatalogTokens: 0 } }).instructions() + // PARTIAL: the workflow starts with search (with query-style guidance that is clearly + // a query string, never a tool name) and the browse-namespace rule appears. + expect(partial).toContain( + '1. If the exact signature is not listed below, first search: `const { items } = await tools.$codemode.search({ query: "" })`.', + ) + expect(partial).toContain( + "Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`", + ) + expect(partial).toContain( + '- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "" })`.', + ) + expect(partial).not.toContain("total_count") + expect(partial).not.toContain("tools.orders.lookup({") + }) + + test("the syntax section names what is unusual or missing, not an allowlist", () => { + const instructions = CodeMode.make({ tools }).instructions() + // Models already know JavaScript; the section leads with that. + expect(instructions).toContain("Standard modern JavaScript works") + expect(instructions).toContain("TypeScript type annotations are allowed and stripped before execution") + // The not-supported list is derived from (and verified against) the interpreter. + expect(instructions).toContain("Not supported") + for (const missing of ["classes", "generators", "for await...of", ".then/.catch/.finally"]) { + expect(instructions).toContain(missing) + } + // Implemented by the DSL-expansion pass, so no longer listed as missing. + expect(instructions).not.toContain("instanceof Error") + expect(instructions).not.toContain("splice") + // The data-boundary note survives. + expect(instructions).toContain( + "Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.", + ) + }) + + test("zero tools keep minimal sections and the no-tools notice", () => { + const runtime = CodeMode.make({}) + const instructions = runtime.instructions() + expect(instructions).toContain("No tools are currently available.") + expect(instructions).toContain("## Syntax") + expect(instructions).toContain("## Available tools") + expect(instructions).not.toContain("## Workflow") + expect(instructions).not.toContain("## Rules") + expect(instructions).not.toMatch(/\$codemode/) + }) + + test("uses one ranked search returning complete definitions for large catalogs", async () => { + const upload = Tool.make({ + description: "Upload one readable local file to the current Discord thread", + input: Schema.Struct({ path: Schema.String }), + output: Schema.Struct({ sent: Schema.Boolean }), + run: () => Effect.succeed({ sent: true }), + }) + const generate = Tool.make({ + description: "Generate an image and upload it to the current Discord thread", + input: Schema.Struct({ prompt: Schema.String }), + output: Schema.Struct({ sent: Schema.Boolean }), + run: () => Effect.succeed({ sent: true }), + }) + const runtime = CodeMode.make({ + tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } }, + discovery: { maxInlineCatalogTokens: 0 }, + }) + expect(runtime.instructions()).toContain( + "Available tools (PARTIAL - 0 of 3 shown; find the rest with tools.$codemode.search)", + ) + expect(runtime.instructions()).toContain("- thread (2 tools, none shown)") + expect(runtime.instructions()).toContain("- orders (1 tool, none shown)") + expect(runtime.instructions()).toMatch(/\$codemode\.search/) + expect(runtime.instructions()).not.toMatch(/tools\.thread\.uploadFile\(input/) + + const result = await Effect.runPromise( + runtime.execute(` + return await tools.$codemode.search({ + query: "send message attachment upload file to current Discord thread", + limit: 2 + }) + `), + ) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.value).toStrictEqual({ + items: [ + { + path: "tools.thread.uploadFile", + description: "Upload one readable local file to the current Discord thread", + signature: "tools.thread.uploadFile(input: {\n path: string\n}): Promise<{\n sent: boolean\n}>", + }, + { + path: "tools.thread.generateImage", + description: "Generate an image and upload it to the current Discord thread", + signature: "tools.thread.generateImage(input: {\n prompt: string\n}): Promise<{\n sent: boolean\n}>", + }, + ], + total: 2, + }) + expect(result.toolCalls).toStrictEqual([{ name: "$codemode.search" }]) + + const variants = await Effect.runPromise( + runtime.execute(` + return await Promise.all([ + tools.$codemode.search({ query: "file" }), + tools.$codemode.search({ query: "image" }) + ]) + `), + ) + expect(variants.ok).toBe(true) + if (variants.ok) { + expect((variants.value as Array<{ items: Array<{ path: string }> }>)[0]?.items[0]?.path).toBe( + "tools.thread.uploadFile", + ) + expect((variants.value as Array<{ items: Array<{ path: string }> }>)[1]?.items[0]?.path).toBe( + "tools.thread.generateImage", + ) + } + + const removed = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.describe({ path: "thread.uploadFile" })`), + ) + expect(removed.ok).toBe(false) + if (!removed.ok) expect(removed.error.kind).toBe("UnknownTool") + }) + + test("search defaults to 10 results and resolves exact tool paths", async () => { + const tool = (index: number) => + Tool.make({ + description: `Numbered tool ${index}`, + input: Schema.Struct({ id: Schema.String }), + output: Schema.String, + run: () => Effect.succeed("ok"), + }) + const runtime = CodeMode.make({ + tools: { + many: Object.fromEntries(Array.from({ length: 14 }, (_, index) => [`tool${index}`, tool(index)])), + }, + }) + + const browse = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({})`)) + expect(browse.ok).toBe(true) + if (browse.ok) { + const value = browse.value as { items: Array<{ path: string }>; total: number } + expect(value.items).toHaveLength(10) + expect(value.total).toBe(14) + } + + for (const query of ["many.tool13", "tools.many.tool13"]) { + const exact = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`), + ) + expect(exact.ok).toBe(true) + if (exact.ok) { + expect(exact.value).toStrictEqual({ + items: [ + { + path: "tools.many.tool13", + description: "Numbered tool 13", + signature: "tools.many.tool13(input: {\n id: string\n}): Promise", + }, + ], + total: 1, + }) + } + } + }) + + test("scopes search to one namespace and browses it alphabetically", async () => { + const simple = (description: string) => + Tool.make({ + description, + input: Schema.Struct({ id: Schema.String }), + output: Schema.String, + run: () => Effect.succeed("ok"), + }) + const runtime = CodeMode.make({ + tools: { + github: { list_issues: simple("List issues"), create_issue: simple("Create an issue") }, + linear: { list_issues: simple("List Linear issues") }, + }, + }) + + // Empty query + namespace browses just that namespace, alphabetical by path. + const browse = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: "", namespace: "github" })`), + ) + expect(browse.ok).toBe(true) + if (browse.ok) { + const value = browse.value as { items: Array<{ path: string }>; total: number } + expect(value.total).toBe(2) + expect(value.items.map((item) => item.path)).toStrictEqual([ + "tools.github.create_issue", + "tools.github.list_issues", + ]) + } + + // A query + namespace ranks within that namespace only. + const scoped = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: "linear" })`), + ) + expect(scoped.ok).toBe(true) + if (scoped.ok) { + const value = scoped.value as { items: Array<{ path: string }>; total: number } + expect(value.total).toBe(1) + expect(value.items[0]?.path).toBe("tools.linear.list_issues") + } + + const invalid = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: 7 })`), + ) + expect(invalid.ok).toBe(false) + if (!invalid.ok) expect(invalid.error.kind).toBe("InvalidToolInput") + }) + + test("matches input parameter names and partial-word substrings", async () => { + const upload = Tool.make({ + description: "Send a document to the workspace", + input: { + type: "object", + properties: { attachment: { type: "string", description: "Local path of the payload to send" } }, + required: ["attachment"], + }, + run: () => Effect.succeed("ok"), + }) + const other = Tool.make({ + description: "Rename the workspace", + input: Schema.Struct({ name: Schema.String }), + output: Schema.String, + run: () => Effect.succeed("ok"), + }) + const runtime = CodeMode.make({ tools: { files: { upload, other } } }) + + // "attachment" appears in neither path nor description - only in the input schema's + // property names, which the searchable text includes. + const byParameter = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: "attachment" })`), + ) + expect(byParameter.ok).toBe(true) + if (byParameter.ok) { + const value = byParameter.value as { items: Array<{ path: string }>; total: number } + expect(value.total).toBe(1) + expect(value.items[0]?.path).toBe("tools.files.upload") + } + + // Substring matching: a partial word ("docum") still hits the description. + const bySubstring = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: "docum" })`), + ) + expect(bySubstring.ok).toBe(true) + if (bySubstring.ok) { + const value = bySubstring.value as { items: Array<{ path: string }>; total: number } + expect(value.total).toBe(1) + expect(value.items[0]?.path).toBe("tools.files.upload") + } + }) + + test("a plural query term matches singular-only tool text", async () => { + const simple = (description: string) => + Tool.make({ + description, + input: Schema.Struct({ id: Schema.String }), + output: Schema.String, + run: () => Effect.succeed("ok"), + }) + const runtime = CodeMode.make({ + tools: { + // Neither path nor description contains "issues" - only the singular "issue". + tracker: { fetch_all: simple("Fetch every open issue in the project") }, + github: { list_issues: simple("List issues") }, + misc: { rename: simple("Rename the workspace") }, + }, + }) + + // "issues" still finds the singular-only tool (term OR singular(term) per field)... + const plural = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: "tracker" })`), + ) + expect(plural.ok).toBe(true) + if (plural.ok) { + const value = plural.value as { items: Array<{ path: string }>; total: number } + expect(value.total).toBe(1) + expect(value.items[0]?.path).toBe("tools.tracker.fetch_all") + } + + // ...while a true "issues" path match still outranks the singular-only description match. + const ranked = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: "issues" })`)) + expect(ranked.ok).toBe(true) + if (ranked.ok) { + const value = ranked.value as { items: Array<{ path: string }>; total: number } + expect(value.total).toBe(2) + expect(value.items.map((item) => item.path)).toStrictEqual([ + "tools.github.list_issues", + "tools.tracker.fetch_all", + ]) + } + }) + + test("empty query lists everything alphabetically by path", async () => { + const simple = (description: string) => + Tool.make({ + description, + input: Schema.Struct({}), + output: Schema.String, + run: () => Effect.succeed("ok"), + }) + // Deliberately declared out of alphabetical order. + const runtime = CodeMode.make({ + tools: { + zeta: { last: simple("Last") }, + alpha: { beta: simple("Middle"), aardvark: simple("First") }, + }, + }) + const browse = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({})`)) + expect(browse.ok).toBe(true) + if (browse.ok) { + const value = browse.value as { items: Array<{ path: string }>; total: number } + expect(value.items.map((item) => item.path)).toStrictEqual([ + "tools.alpha.aardvark", + "tools.alpha.beta", + "tools.zeta.last", + ]) + } + }) + + test("inlines round-robin across namespaces so one expensive namespace cannot starve the rest", () => { + const cheap = Tool.make({ + description: "Cheap", + input: Schema.Struct({ q: Schema.String }), + output: Schema.String, + run: () => Effect.succeed("ok"), + }) + const expensive = Tool.make({ + description: + "An expensive tool whose description alone consumes far more than the remaining inline catalog byte budget for this runtime", + input: Schema.Struct({ + someRatherLongParameterName: Schema.String, + anotherEvenLongerParameterName: Schema.Number, + }), + output: Schema.String, + run: () => Effect.succeed("ok"), + }) + // Round 1 places alpha.cheap (~17 estimated tokens) and beta.cheap (~17); in round 2 + // alpha.expensive does not fit, which marks only alpha done - it must NOT prevent + // other namespaces from inlining (beta already got its line in the same round). + const runtime = CodeMode.make({ + tools: { alpha: { cheap, expensive }, beta: { cheap } }, + discovery: { maxInlineCatalogTokens: 40 }, + }) + + const instructions = runtime.instructions() + expect(instructions).toContain( + "Available tools (PARTIAL - 2 of 3 shown; find the rest with tools.$codemode.search)", + ) + expect(instructions).toContain("- alpha (2 tools, 1 shown)") + expect(instructions).toContain(" - tools.alpha.cheap(input: { q: string }): Promise // Cheap") + expect(instructions).not.toContain("tools.alpha.expensive(") + // Fully shown namespaces read cleanly (no "shown" annotation). + expect(instructions).toContain("- beta (1 tool)") + expect(instructions).toContain(" - tools.beta.cheap(input: { q: string }): Promise // Cheap") + expect(instructions).toMatch(/\$codemode\.search/) + }) + + test("decodes tool input and output before exposing either side", async () => { + const observed: Array = [] + const transformed = Tool.make({ + description: "Double a number", + input: Schema.Struct({ value: Schema.NumberFromString }), + output: Schema.NumberFromString, + run: ({ value }) => + Effect.sync(() => { + observed.push(value) + return String(value * 2) + }), + }) + const runtime = CodeMode.make({ + tools: { math: { double: transformed } }, + onToolCallStart: (call) => Effect.sync(() => observed.push(call.input)), + }) + + const success = await Effect.runPromise(runtime.execute(`return await tools.math.double({ value: "21" })`)) + expect(success).toStrictEqual({ ok: true, value: 42, toolCalls: [{ name: "math.double" }] }) + expect(observed).toStrictEqual([{ value: 21 }, 21]) + + const invalid = await Effect.runPromise(runtime.execute(`return await tools.math.double({ value: 21 })`)) + expect(invalid.ok).toBe(false) + if (invalid.ok) return + expect(invalid.error.kind).toBe("InvalidToolInput") + expect(observed).toStrictEqual([{ value: 21 }, 21]) + }) + + test("returns JSON-safe data and normalizes undefined to null", async () => { + const result = await Effect.runPromise( + CodeMode.execute({ + code: `return { top: undefined, nested: [1, undefined] }`, + }), + ) + expect(result).toStrictEqual({ + ok: true, + value: { top: null, nested: [1, null] }, + toolCalls: [], + }) + expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result) + }) + + test("rejects invalid configuration and discovery limits", async () => { + expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: 0 } })).toThrow(RangeError) + expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: Number.POSITIVE_INFINITY } })).toThrow( + RangeError, + ) + expect(() => CodeMode.execute({ code: "return 1", limits: { maxToolCalls: -1 } })).toThrow(RangeError) + expect(() => CodeMode.execute({ code: "return 1", limits: { maxOutputBytes: -1 } })).toThrow(RangeError) + + expect(() => CodeMode.make({ tools, discovery: { maxInlineCatalogTokens: -1 } })).toThrow(RangeError) + + const result = await Effect.runPromise( + CodeMode.make({ + tools, + discovery: { maxInlineCatalogTokens: 0 }, + }).execute(`return await tools.$codemode.search({ query: "order", limit: 0.5 })`), + ) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error.kind).toBe("InvalidToolInput") + }) + + test("enforces the tool-call limit as a diagnostic", async () => { + const result = await Effect.runPromise(CodeMode.execute({ tools, code: source, limits: { maxToolCalls: 0 } })) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error.kind).toBe("ToolCallLimitExceeded") + }) + + test("timeoutMs and maxToolCalls have no defaults: absent means unlimited", async () => { + // 150 tool calls would have exceeded the old default cap of 100; with no limits + // provided, there is no cap and no timeout - budgets are host policy. + const counter = Tool.make({ + description: "Count invocations", + input: Schema.Struct({}), + output: Schema.Number, + run: () => Effect.succeed(1), + }) + const result = await Effect.runPromise( + CodeMode.execute({ + tools: { host: { count: counter } }, + code: ` + let total = 0 + for (let i = 0; i < 150; i += 1) total += await tools.host.count({}) + return total + `, + }), + ) + expect(result).toMatchObject({ ok: true, value: 150 }) + if (result.ok) expect(result.toolCalls.length).toBe(150) + }) + + test("the timeout interrupts a busy loop without any operation budget", async () => { + // Regression: timeout interruption must not depend on interpreter-side work accounting. + // The Effect fiber runtime auto-yields between interpreter steps, so a pure `while + // (true) {}` loop is interrupted by `timeoutMs` alone. + const startedAt = Date.now() + const result = await Effect.runPromise(CodeMode.execute({ code: "while (true) {}", limits: { timeoutMs: 200 } })) + const elapsedMs = Date.now() - startedAt + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error.kind).toBe("TimeoutExceeded") + expect(result.error.message).toContain("timed out after 200ms") + } + expect(elapsedMs).toBeLessThan(3_000) + }) + + test("reserves the discovery namespace", () => { + expect(() => CodeMode.make({ tools: { $codemode: { lookup } } })).toThrow(/reserved for CodeMode discovery tools/) + }) +}) diff --git a/packages/codemode/test/enumeration.test.ts b/packages/codemode/test/enumeration.test.ts new file mode 100644 index 0000000000..87c075a792 --- /dev/null +++ b/packages/codemode/test/enumeration.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Schema } from "effect" +import { CodeMode, Tool } from "../src/index.js" + +// Key enumeration: Object.keys and for...in share one surface over plain objects, arrays +// (index strings), and tool references (namespace/tool names from the host tool tree), so a +// model can discover what it may call instead of guessing names from the instructions. The +// motivating transcript: `Object.keys(tools)` failed with the generic plain-objects-only +// message and `for (const key in tools)` was unsupported syntax, forcing blind guesses. + +const echo = (description: string) => + Tool.make({ + description, + input: Schema.Struct({ value: Schema.String }), + output: Schema.String, + run: ({ value }) => Effect.succeed(value), + }) + +const tools = { + github: { list_issues: echo("List issues"), get_issue: echo("Get one issue") }, + memory: { search: echo("Search memory") }, + playwright: { navigate: echo("Navigate somewhere") }, +} + +const run = (code: string) => Effect.runPromise(CodeMode.execute({ tools, code })) +const value = async (code: string) => { + const result = await run(code) + if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) + return result.value +} +const error = async (code: string) => { + const result = await run(code) + if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`) + return result.error +} + +describe("Object.keys over tool references", () => { + test("enumerates top-level namespaces (the transcript program)", async () => { + expect( + await value(` + const namespaces = Object.keys(tools) + return { namespaces, count: namespaces.length } + `), + ).toEqual({ namespaces: ["github", "memory", "playwright"], count: 3 }) + }) + + test("enumerates tool names at a nested namespace", async () => { + expect(await value(`return Object.keys(tools.github)`)).toEqual(["list_issues", "get_issue"]) + }) + + test("a callable tool is a leaf and enumerates as []", async () => { + expect(await value(`return Object.keys(tools.github.list_issues)`)).toEqual([]) + }) + + test("the virtual discovery namespace enumerates its callable surface", async () => { + expect(await value(`return Object.keys(tools.$codemode)`)).toEqual(["search"]) + }) + + test("an unknown namespace is an UnknownTool error pointing at the discovery idioms", async () => { + const failure = await error(`return Object.keys(tools.nonexistent)`) + expect(failure.kind).toBe("UnknownTool") + expect(failure.message).toContain("Unknown tool namespace 'nonexistent'") + expect(failure.suggestions?.join(" ")).toContain("Object.keys(tools)") + }) + + test("Object.values/entries on a tool reference explain the working idioms", async () => { + for (const method of ["values", "entries"] as const) { + const failure = await error(`return Object.${method}(tools)`) + expect(failure.kind).toBe("InvalidDataValue") + expect(failure.message).toContain( + `Object.${method}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or tools.$codemode.search({ query }) for signatures.`, + ) + } + const nested = await error(`return Object.entries(tools.github)`) + expect(nested.message).toContain("Use Object.keys(tools) for names") + }) +}) + +describe("Object.keys over arrays", () => { + test("returns index strings, like JS", async () => { + expect(await value(`return Object.keys(["a", "b", "c"])`)).toEqual(["0", "1", "2"]) + expect(await value(`return Object.keys([])`)).toEqual([]) + }) + + test("objects keep their own enumerable keys", async () => { + expect(await value(`return Object.keys({ a: 1, b: 2 })`)).toEqual(["a", "b"]) + }) + + test("non-object inputs still fail clearly", async () => { + const failure = await error(`return Object.keys("nope")`) + expect(failure.message).toContain("Object.keys expects a data object or array") + }) +}) + +describe("for...in", () => { + test("iterates own enumerable keys of a plain object with break/continue", async () => { + expect( + await value(` + const seen = [] + for (const key in { a: 1, b: 2, c: 3, d: 4 }) { + if (key === "b") continue + if (key === "d") break + seen.push(key) + } + return seen + `), + ).toEqual(["a", "c"]) + }) + + test("iterates index strings over arrays", async () => { + expect( + await value(` + const indexes = [] + for (const i in ["x", "y", "z"]) { + if (i === "2") break + indexes.push(i) + } + return indexes + `), + ).toEqual(["0", "1"]) + }) + + test("supports let declarations and bare identifiers", async () => { + expect( + await value(` + let last = "" + for (let key in { a: 1, b: 2 }) last = key + return last + `), + ).toBe("b") + expect( + await value(` + let key = "before" + for (key in { only: 1 }) {} + return key + `), + ).toBe("only") + }) + + test("enumerates namespaces and tools from the host tool tree", async () => { + expect( + await value(` + const names = [] + for (const ns in tools) { + for (const name in tools[ns]) names.push(ns + "." + name) + } + return names + `), + ).toEqual(["github.list_issues", "github.get_issue", "memory.search", "playwright.navigate"]) + }) + + test("unsupported values fail with a hint at for...of and Object.keys", async () => { + for (const expression of [`"text"`, "new Map([[1, 2]])", "new Set([1])", "42", "null"]) { + const failure = await error(`for (const key in ${expression}) {}; return "no"`) + expect(failure.message).toContain("for...in requires a plain object, array, or tools reference") + expect(failure.message).toContain("Use for...of for arrays/strings/Maps/Sets, or Object.keys(value)") + } + }) +}) diff --git a/packages/codemode/test/fixtures/openapi-happy-path.json b/packages/codemode/test/fixtures/openapi-happy-path.json new file mode 100644 index 0000000000..dc3cbe5f8f --- /dev/null +++ b/packages/codemode/test/fixtures/openapi-happy-path.json @@ -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" + } + } + } +} diff --git a/packages/codemode/test/fixtures/opencode-v2-openapi.json b/packages/codemode/test/fixtures/opencode-v2-openapi.json new file mode 100644 index 0000000000..4d1d3af708 --- /dev/null +++ b/packages/codemode/test/fixtures/opencode-v2-openapi.json @@ -0,0 +1,26962 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "opencode HttpApi", + "version": "0.0.1", + "description": "Experimental HttpApi surface for selected instance routes." + }, + "paths": { + "/api/health": { + "get": { + "tags": [ + "server.health" + ], + "operationId": "v2.health.get", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "healthy": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "healthy" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Check whether the API server is ready to accept requests.", + "summary": "Check server health" + } + }, + "/api/location": { + "get": { + "tags": [ + "server.location" + ], + "operationId": "v2.location.get", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Location.Info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Location.Info" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Resolve the requested location or the server default location.", + "summary": "Get location" + } + }, + "/api/agent": { + "get": { + "tags": [ + "server.agent" + ], + "operationId": "v2.agent.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AgentV2.Info" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve currently registered agents.", + "summary": "List agents" + } + }, + "/api/plugin": { + "get": { + "tags": [ + "plugins" + ], + "operationId": "v2.plugin.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Plugin.Info" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve currently loaded plugins.", + "summary": "List plugins" + } + }, + "/api/session": { + "get": { + "tags": [ + "sessions" + ], + "operationId": "v2.session.list", + "parameters": [ + { + "name": "workspace", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^wrk" + } + ] + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "limit", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Maximum number of sessions to return. Defaults to the newest 50 sessions." + }, + "required": false + }, + { + "name": "order", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "asc", + "desc" + ] + }, + { + "type": "null" + } + ], + "description": "Session order for the first page. Use desc for newest first or asc for oldest first." + }, + "required": false + }, + { + "name": "search", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "parentID", + "in": "query", + "schema": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + { + "type": "string", + "enum": [ + "null" + ] + } + ], + "description": "Filter by parent session. Use null to return only root sessions." + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "directory", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "project", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "subpath", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "cursor", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string", + "description": "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response." + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "SessionsResponse", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionsResponse" + } + } + } + }, + "400": { + "description": "InvalidCursorError | InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidCursorError" + }, + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.", + "summary": "List sessions" + }, + "post": { + "tags": [ + "sessions" + ], + "operationId": "v2.session.create", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SessionV2.Info" + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Create a session at the requested location.", + "summary": "Create session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + { + "type": "null" + } + ] + }, + "agent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "model": { + "anyOf": [ + { + "$ref": "#/components/schemas/Model.Ref" + }, + { + "type": "null" + } + ] + }, + "location": { + "anyOf": [ + { + "$ref": "#/components/schemas/Location.Ref" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/active": { + "get": { + "tags": [ + "sessions" + ], + "operationId": "v2.session.active", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "patternProperties": { + "^ses": { + "$ref": "#/components/schemas/SessionActive" + } + } + }, + "watermarks": { + "$ref": "#/components/schemas/SessionWatermarks" + } + }, + "required": [ + "data", + "watermarks" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive. Watermarks are the durable log positions read alongside the activity snapshot; activity itself is process state, so the pairing is advisory rather than transactional.", + "summary": "List active sessions" + } + }, + "/api/session/{sessionID}": { + "get": { + "tags": [ + "sessions" + ], + "operationId": "v2.session.get", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SessionV2.Info" + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve a session by ID.", + "summary": "Get session" + } + }, + "/api/session/{sessionID}/fork": { + "post": { + "tags": [ + "sessions" + ], + "operationId": "v2.session.fork", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SessionV2.Info" + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | MessageNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/MessageNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Create a child session by copying projected history from the parent. When messageID is supplied, copy messages before that boundary.", + "summary": "Fork session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/agent": { + "post": { + "tags": [ + "sessions" + ], + "operationId": "v2.session.switchAgent", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Switch the agent used by subsequent provider turns.", + "summary": "Switch session agent", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "agent": { + "type": "string" + } + }, + "required": [ + "agent" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/model": { + "post": { + "tags": [ + "sessions" + ], + "operationId": "v2.session.switchModel", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Switch the model used by subsequent provider turns.", + "summary": "Switch session model", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "model": { + "$ref": "#/components/schemas/Model.Ref" + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/rename": { + "post": { + "tags": [ + "sessions" + ], + "operationId": "v2.session.rename", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Update the session title.", + "summary": "Rename session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + } + }, + "required": [ + "title" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/prompt": { + "post": { + "tags": [ + "sessions" + ], + "operationId": "v2.session.prompt", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SessionInput.Admitted" + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "409": { + "description": "ConflictError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConflictError" + } + } + } + } + }, + "description": "Durably admit one session input and schedule agent-loop execution unless resume is false.", + "summary": "Send message", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + { + "type": "null" + } + ] + }, + "prompt": { + "$ref": "#/components/schemas/PromptInput" + }, + "delivery": { + "anyOf": [ + { + "type": "string", + "enum": [ + "steer", + "queue" + ] + }, + { + "type": "null" + } + ] + }, + "resume": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "prompt" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/command": { + "post": { + "tags": [ + "sessions" + ], + "operationId": "v2.session.command", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SessionInput.Admitted" + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | CommandNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/CommandNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "409": { + "description": "ConflictError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConflictError" + } + } + } + }, + "500": { + "description": "CommandEvaluationError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandEvaluationError" + } + } + } + } + }, + "description": "Resolve a slash command into prompt input, admit it durably, and schedule execution unless resume is false.", + "summary": "Run command", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + { + "type": "null" + } + ] + }, + "command": { + "type": "string" + }, + "arguments": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "agent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "model": { + "anyOf": [ + { + "$ref": "#/components/schemas/Model.Ref" + }, + { + "type": "null" + } + ] + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptInput.FileAttachment" + } + }, + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prompt.AgentAttachment" + } + }, + "delivery": { + "anyOf": [ + { + "type": "string", + "enum": [ + "steer", + "queue" + ] + }, + { + "type": "null" + } + ] + }, + "resume": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "command" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/skill": { + "post": { + "tags": [ + "sessions" + ], + "operationId": "v2.session.skill", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | SkillNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SkillNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Activate a skill for a session by appending a skill message and resuming execution.", + "summary": "Activate skill", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + { + "type": "null" + } + ] + }, + "skill": { + "type": "string" + }, + "resume": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "skill" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/synthetic": { + "post": { + "tags": [ + "sessions" + ], + "operationId": "v2.session.synthetic", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Append a synthetic message to a session and resume execution.", + "summary": "Add synthetic message", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "metadata": { + "type": "object" + } + }, + "required": [ + "text" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/compact": { + "post": { + "tags": [ + "sessions" + ], + "operationId": "v2.session.compact", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "409": { + "description": "SessionBusyError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionBusyError" + } + } + } + }, + "500": { + "description": "UnknownError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnknownError" + } + } + } + }, + "503": { + "description": "ServiceUnavailableError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceUnavailableError" + } + } + } + } + }, + "description": "Compact a session conversation.", + "summary": "Compact session" + } + }, + "/api/session/{sessionID}/wait": { + "post": { + "tags": [ + "sessions" + ], + "operationId": "v2.session.wait", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "503": { + "description": "ServiceUnavailableError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceUnavailableError" + } + } + } + } + }, + "description": "Wait for a session agent loop to become idle.", + "summary": "Wait for session" + } + }, + "/api/session/{sessionID}/revert/stage": { + "post": { + "tags": [ + "sessions" + ], + "operationId": "v2.session.revert.stage", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Revert.State" + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "MessageNotFoundError | SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/MessageNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "409": { + "description": "SessionBusyError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionBusyError" + } + } + } + }, + "500": { + "description": "UnknownError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnknownError" + } + } + } + } + }, + "description": "Stage or move a reversible session boundary and optionally apply its file changes.", + "summary": "Stage session revert", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "files": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "messageID" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/revert/clear": { + "post": { + "tags": [ + "sessions" + ], + "operationId": "v2.session.revert.clear", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "409": { + "description": "SessionBusyError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionBusyError" + } + } + } + }, + "500": { + "description": "UnknownError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnknownError" + } + } + } + } + }, + "summary": "Clear staged revert" + } + }, + "/api/session/{sessionID}/revert/commit": { + "post": { + "tags": [ + "sessions" + ], + "operationId": "v2.session.revert.commit", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "409": { + "description": "SessionBusyError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionBusyError" + } + } + } + } + }, + "summary": "Commit staged revert" + } + }, + "/api/session/{sessionID}/context": { + "get": { + "tags": [ + "sessions" + ], + "operationId": "v2.session.context", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Session.Message" + } + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "500": { + "description": "UnknownError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnknownError" + } + } + } + } + }, + "description": "Retrieve the active context messages for a session (all messages after the last compaction).", + "summary": "Get session context" + } + }, + "/api/session/{sessionID}/context-entry": { + "get": { + "tags": [ + "sessions" + ], + "operationId": "v2.session.context.entry.list", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionContextEntry.Info" + } + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "List API-managed context entries attached to the session's system context.", + "summary": "List context entries" + } + }, + "/api/session/{sessionID}/context-entry/{key}": { + "put": { + "tags": [ + "sessions" + ], + "operationId": "v2.session.context.entry.put", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + }, + { + "name": "key", + "in": "path", + "schema": { + "$ref": "#/components/schemas/SessionContextEntry.Key" + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Attach or replace one durable context entry. The value is rendered into the session's system context; changes announce as updates at the next turn boundary.", + "summary": "Put context entry", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + } + }, + "required": true + } + }, + "delete": { + "tags": [ + "sessions" + ], + "operationId": "v2.session.context.entry.remove", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + }, + { + "name": "key", + "in": "path", + "schema": { + "$ref": "#/components/schemas/SessionContextEntry.Key" + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Remove one context entry; the removal is announced to the model at the next turn boundary.", + "summary": "Remove context entry" + } + }, + "/api/session/{sessionID}/log": { + "get": { + "tags": [ + "sessions" + ], + "operationId": "v2.session.log", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + }, + { + "name": "after", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "follow", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "true", + "false" + ] + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "text/event-stream": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "event": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/SessionLogItemStream" + } + }, + "required": [ + "id", + "event", + "data" + ], + "additionalProperties": false + }, + "x-effect-stream": { + "encoding": "sse", + "causeSchema": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Fail" + ] + }, + "error": { + "not": {} + } + }, + "required": [ + "_tag", + "error" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Die" + ] + }, + "defect": {} + }, + "required": [ + "_tag", + "defect" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Interrupt" + ] + }, + "fiberId": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "fiberId" + ], + "additionalProperties": false + } + ] + } + }, + "errorSchema": { + "not": {} + }, + "failureEvent": "effect/httpapi/stream/failure" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Durable, ordered, gap-free read of public session events after an exclusive aggregate sequence. Emits a synced marker once replay reaches the captured watermark, then completes; with follow=true it continues with live events instead. The only event API that promises reliability: attach after a snapshot watermark to compose fetch and stream without a race window.", + "summary": "Read the session log" + } + }, + "/api/session/{sessionID}/interrupt": { + "post": { + "tags": [ + "sessions" + ], + "operationId": "v2.session.interrupt", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op.", + "summary": "Interrupt session execution" + } + }, + "/api/session/{sessionID}/background": { + "post": { + "tags": [ + "sessions" + ], + "operationId": "v2.session.background", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Move active foreground backgroundable tools for this session into background observation. Idle requests are a no-op.", + "summary": "Background blocking session tools" + } + }, + "/api/session/{sessionID}/message/{messageID}": { + "get": { + "tags": [ + "sessions" + ], + "operationId": "v2.session.message", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + }, + { + "name": "messageID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Session.Message" + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | MessageNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/MessageNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve one projected message owned by the Session.", + "summary": "Get session message" + } + }, + "/api/session/{sessionID}/message": { + "get": { + "tags": [ + "messages" + ], + "operationId": "v2.session.messages", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + }, + { + "name": "limit", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Maximum number of messages to return. When omitted, the endpoint returns its default page size." + }, + "required": false + }, + { + "name": "order", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "asc", + "desc" + ] + }, + { + "type": "null" + } + ], + "description": "Message order for the first page. Use desc for newest first or asc for oldest first." + }, + "required": false + }, + { + "name": "cursor", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string", + "description": "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order." + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "SessionMessagesResponse", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionMessagesResponse" + } + } + } + }, + "400": { + "description": "InvalidCursorError | InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidCursorError" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "500": { + "description": "UnknownError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnknownError" + } + } + } + } + }, + "description": "Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.", + "summary": "Get session messages" + } + }, + "/api/model": { + "get": { + "tags": [ + "models" + ], + "operationId": "v2.model.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModelV2.Info" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "503": { + "description": "ServiceUnavailableError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceUnavailableError" + } + } + } + } + }, + "description": "Retrieve available models ordered by release date.", + "summary": "List models" + } + }, + "/api/model/default": { + "get": { + "tags": [ + "models" + ], + "operationId": "v2.model.default", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/ModelV2.Info" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "503": { + "description": "ServiceUnavailableError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceUnavailableError" + } + } + } + } + }, + "description": "Retrieve the model used when a session has no explicit model selection.", + "summary": "Get default model" + } + }, + "/api/generate": { + "post": { + "tags": [ + "generate" + ], + "operationId": "v2.generate.text", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "GenerateTextResponse", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerateTextResponse" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "503": { + "description": "ServiceUnavailableError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceUnavailableError" + } + } + } + } + }, + "description": "Run one stateless model generation at the requested location and return the assistant text. Uses the location's default model when none is specified.", + "summary": "Generate text", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "prompt": { + "type": "string" + }, + "model": { + "anyOf": [ + { + "$ref": "#/components/schemas/Model.Ref" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "prompt" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/provider": { + "get": { + "tags": [ + "providers" + ], + "operationId": "v2.provider.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProviderV2.Info" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "503": { + "description": "ServiceUnavailableError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceUnavailableError" + } + } + } + } + }, + "description": "Retrieve active AI providers so clients can show provider availability and configuration.", + "summary": "List providers" + } + }, + "/api/provider/{providerID}": { + "get": { + "tags": [ + "providers" + ], + "operationId": "v2.provider.get", + "parameters": [ + { + "name": "providerID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/ProviderV2.Info" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "ProviderNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderNotFoundError" + } + } + } + }, + "503": { + "description": "ServiceUnavailableError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceUnavailableError" + } + } + } + } + }, + "description": "Retrieve a single AI provider so clients can inspect its availability and endpoint settings.", + "summary": "Get provider" + } + }, + "/api/integration": { + "get": { + "tags": [ + "integrations" + ], + "operationId": "v2.integration.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Integration.Info" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve available integrations and their authentication methods.", + "summary": "List integrations" + } + }, + "/api/integration/{integrationID}": { + "get": { + "tags": [ + "integrations" + ], + "operationId": "v2.integration.get", + "parameters": [ + { + "name": "integrationID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/Integration.Info" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve one integration and its authentication methods.", + "summary": "Get integration" + } + }, + "/api/integration/{integrationID}/connect/key": { + "post": { + "tags": [ + "integrations" + ], + "operationId": "v2.integration.connect.key", + "parameters": [ + { + "name": "integrationID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Run a key authentication method and store the resulting credential.", + "summary": "Connect with key", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "key" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/integration/{integrationID}/connect/oauth": { + "post": { + "tags": [ + "integrations" + ], + "operationId": "v2.integration.connect.oauth", + "parameters": [ + { + "name": "integrationID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Integration.Attempt" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Start an OAuth attempt and return the authorization details.", + "summary": "Begin OAuth connection", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "methodID": { + "type": "string" + }, + "inputs": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "methodID", + "inputs" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/integration/attempt/{attemptID}": { + "get": { + "tags": [ + "integrations" + ], + "operationId": "v2.integration.attempt.status", + "parameters": [ + { + "name": "attemptID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Integration.AttemptStatus" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Poll the current status of an OAuth attempt.", + "summary": "Get OAuth attempt status" + }, + "delete": { + "tags": [ + "integrations" + ], + "operationId": "v2.integration.attempt.cancel", + "parameters": [ + { + "name": "attemptID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Cancel an OAuth attempt and release its resources.", + "summary": "Cancel OAuth connection" + } + }, + "/api/integration/attempt/{attemptID}/complete": { + "post": { + "tags": [ + "integrations" + ], + "operationId": "v2.integration.attempt.complete", + "parameters": [ + { + "name": "attemptID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Complete a code-based OAuth attempt and store the resulting credential.", + "summary": "Complete OAuth connection", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/mcp": { + "get": { + "tags": [ + "mcp" + ], + "operationId": "v2.mcp.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Mcp.Server" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve configured MCP servers and their connection status.", + "summary": "List MCP servers" + } + }, + "/api/credential/{credentialID}": { + "patch": { + "tags": [ + "server.credential" + ], + "operationId": "v2.credential.update", + "parameters": [ + { + "name": "credentialID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Update a stored credential label.", + "summary": "Update credential", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "label": { + "type": "string" + } + }, + "required": [ + "label" + ], + "additionalProperties": false + } + } + }, + "required": true + } + }, + "delete": { + "tags": [ + "server.credential" + ], + "operationId": "v2.credential.remove", + "parameters": [ + { + "name": "credentialID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Remove a stored integration credential.", + "summary": "Remove credential" + } + }, + "/api/project/current": { + "get": { + "tags": [ + "projects" + ], + "operationId": "v2.project.current", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Project.Current", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project.Current" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Resolve the project for the requested location.", + "summary": "Get current project" + } + }, + "/api/project/{projectID}/directories": { + "get": { + "tags": [ + "projects" + ], + "operationId": "v2.project.directories", + "parameters": [ + { + "name": "projectID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Project.Directories", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project.Directories" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "List known local absolute directories for a project.", + "summary": "List project directories" + } + }, + "/api/form/request": { + "get": { + "tags": [ + "forms" + ], + "operationId": "v2.form.request.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Form.FormInfo" + }, + { + "$ref": "#/components/schemas/Form.UrlInfo" + } + ] + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve pending forms for a location.", + "summary": "List pending form requests" + } + }, + "/api/session/{sessionID}/form": { + "get": { + "tags": [ + "forms" + ], + "operationId": "v2.session.form.list", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Form.FormInfo" + }, + { + "$ref": "#/components/schemas/Form.UrlInfo" + } + ] + } + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve pending forms for a session.", + "summary": "List session forms" + }, + "post": { + "tags": [ + "forms" + ], + "operationId": "v2.session.form.create", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/Form.FormInfo" + }, + { + "$ref": "#/components/schemas/Form.UrlInfo" + } + ] + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "409": { + "description": "ConflictError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConflictError" + } + } + } + } + }, + "description": "Create a form for a session.", + "summary": "Create session form", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Form.CreatePayload" + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/form/{formID}": { + "get": { + "tags": [ + "forms" + ], + "operationId": "v2.session.form.get", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "formID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/Form.FormInfo" + }, + { + "$ref": "#/components/schemas/Form.UrlInfo" + } + ] + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | FormNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/FormNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve a form for a session.", + "summary": "Get session form" + } + }, + "/api/session/{sessionID}/form/{formID}/state": { + "get": { + "tags": [ + "forms" + ], + "operationId": "v2.session.form.state", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "formID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Form.State" + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | FormNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/FormNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve the current state for a form.", + "summary": "Get form state" + } + }, + "/api/session/{sessionID}/form/{formID}/reply": { + "post": { + "tags": [ + "forms" + ], + "operationId": "v2.session.form.reply", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "formID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "FormInvalidAnswerError | InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/FormInvalidAnswerError" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | FormNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/FormNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "409": { + "description": "FormAlreadySettledError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FormAlreadySettledError" + } + } + } + } + }, + "description": "Submit an answer to a pending form.", + "summary": "Reply to form", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Form.Reply" + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/form/{formID}/cancel": { + "post": { + "tags": [ + "forms" + ], + "operationId": "v2.session.form.cancel", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "formID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | FormNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/FormNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "409": { + "description": "FormAlreadySettledError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FormAlreadySettledError" + } + } + } + } + }, + "description": "Cancel a pending form.", + "summary": "Cancel form" + } + }, + "/api/permission/request": { + "get": { + "tags": [ + "permissions" + ], + "operationId": "v2.permission.request.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionV2.Request" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve pending permission requests for a location.", + "summary": "List pending permission requests" + } + }, + "/api/permission/saved": { + "get": { + "tags": [ + "permissions" + ], + "operationId": "v2.permission.saved.list", + "parameters": [ + { + "name": "projectID", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionSaved.Info" + } + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve saved permissions, optionally filtered by project.", + "summary": "List saved permissions" + } + }, + "/api/permission/saved/{id}": { + "delete": { + "tags": [ + "permissions" + ], + "operationId": "v2.permission.saved.remove", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Remove a saved permission by ID.", + "summary": "Remove saved permission" + } + }, + "/api/session/{sessionID}/permission": { + "post": { + "tags": [ + "permissions" + ], + "operationId": "v2.session.permission.create", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^per" + } + ] + }, + "effect": { + "$ref": "#/components/schemas/PermissionV2.Effect" + } + }, + "required": [ + "id", + "effect" + ], + "additionalProperties": false + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Evaluate and, when approval is required, create a permission request for a session.", + "summary": "Create permission request", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^per" + } + ] + }, + { + "type": "null" + } + ] + }, + "action": { + "type": "string" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + } + }, + "save": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object" + }, + "source": { + "$ref": "#/components/schemas/PermissionV2.Source" + }, + "agent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "action", + "resources" + ], + "additionalProperties": false + } + } + }, + "required": true + } + }, + "get": { + "tags": [ + "permissions" + ], + "operationId": "v2.session.permission.list", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionV2.Request" + } + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve pending permission requests owned by a session.", + "summary": "List session permission requests" + } + }, + "/api/session/{sessionID}/permission/{requestID}": { + "get": { + "tags": [ + "permissions" + ], + "operationId": "v2.session.permission.get", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + }, + { + "name": "requestID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^per" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/PermissionV2.Request" + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | PermissionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/PermissionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve a pending permission request owned by a session.", + "summary": "Get permission request" + } + }, + "/api/session/{sessionID}/permission/{requestID}/reply": { + "post": { + "tags": [ + "permissions" + ], + "operationId": "v2.session.permission.reply", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + }, + { + "name": "requestID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^per" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | PermissionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/PermissionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Respond to a pending permission request owned by a session.", + "summary": "Reply to pending permission request", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "reply": { + "$ref": "#/components/schemas/PermissionV2.Reply" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "reply" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/fs/read/*": { + "get": { + "tags": [ + "filesystem" + ], + "operationId": "v2.fs.read", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Serve one file relative to the requested location.", + "summary": "Read file" + } + }, + "/api/fs/list": { + "get": { + "tags": [ + "filesystem" + ], + "operationId": "v2.fs.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + }, + { + "name": "path", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileSystem.Entry" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "List direct children of one directory relative to the requested location.", + "summary": "List directory" + } + }, + "/api/fs/find": { + "get": { + "tags": [ + "filesystem" + ], + "operationId": "v2.fs.find", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + }, + { + "name": "query", + "in": "query", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "type", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "file", + "directory" + ] + }, + "required": false + }, + { + "name": "limit", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileSystem.Entry" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Find recursively ranked filesystem entries relative to the requested location.", + "summary": "Find files" + } + }, + "/api/command": { + "get": { + "tags": [ + "commands" + ], + "operationId": "v2.command.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CommandV2.Info" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve currently registered commands.", + "summary": "List commands" + } + }, + "/api/skill": { + "get": { + "tags": [ + "skills" + ], + "operationId": "v2.skill.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SkillV2.Info" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve currently registered skills.", + "summary": "List skills" + } + }, + "/api/event": { + "get": { + "tags": [ + "events" + ], + "operationId": "v2.event.subscribe", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "text/event-stream": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "event": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/V2EventStream" + } + }, + "required": [ + "id", + "event", + "data" + ], + "additionalProperties": false + }, + "x-effect-stream": { + "encoding": "sse", + "causeSchema": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Fail" + ] + }, + "error": { + "not": {} + } + }, + "required": [ + "_tag", + "error" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Die" + ] + }, + "defect": {} + }, + "required": [ + "_tag", + "defect" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Interrupt" + ] + }, + "fiberId": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "fiberId" + ], + "additionalProperties": false + } + ] + } + }, + "errorSchema": { + "not": {} + }, + "failureEvent": "effect/httpapi/stream/failure" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Subscribe to native event payloads for the server. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed. Consumers that need reliability should combine the changes feed with durable session log reads.", + "summary": "Subscribe to events" + } + }, + "/api/event/changes": { + "get": { + "tags": [ + "events" + ], + "operationId": "v2.event.changes", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "text/event-stream": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "event": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/EventLog.ChangeStream" + } + }, + "required": [ + "id", + "event", + "data" + ], + "additionalProperties": false + }, + "x-effect-stream": { + "encoding": "sse", + "causeSchema": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Fail" + ] + }, + "error": { + "not": {} + } + }, + "required": [ + "_tag", + "error" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Die" + ] + }, + "defect": {} + }, + "required": [ + "_tag", + "defect" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Interrupt" + ] + }, + "fiberId": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "fiberId" + ], + "additionalProperties": false + } + ] + } + }, + "errorSchema": { + "not": {} + }, + "failureEvent": "effect/httpapi/stream/failure" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Payload-free hint channel: after an event commits, a subscriber eventually receives a hint for that aggregate with seq at or beyond the event, or a sweep-required marker. Hints coalesce to the latest seq per aggregate under backpressure and the stream never fails from overflow. No consumer may derive correctness from receiving a hint; correctness always comes from durable log reads plus the consumer's own checkpoint. A sweep-required marker is emitted first on every (re)subscribe and whenever hint retention is exceeded: treat every aggregate as potentially dirty and recover via bounded sweep plus log reads.", + "summary": "Subscribe to change hints" + } + }, + "/api/pty": { + "get": { + "tags": [ + "pty" + ], + "operationId": "v2.pty.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pty" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "List PTY sessions for a location, including exited sessions retained until removal.", + "summary": "List PTY sessions" + }, + "post": { + "tags": [ + "pty" + ], + "operationId": "v2.pty.create", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Create a pseudo-terminal session for a location.", + "summary": "Create PTY session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "cwd": { + "type": "string" + }, + "title": { + "type": "string" + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/pty/{ptyID}": { + "get": { + "tags": [ + "pty" + ], + "operationId": "v2.pty.get", + "parameters": [ + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^pty" + } + ] + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "PtyNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PtyNotFoundError" + } + } + } + } + }, + "description": "Get one PTY session, including its exit code once exited.", + "summary": "Get PTY session" + }, + "put": { + "tags": [ + "pty" + ], + "operationId": "v2.pty.update", + "parameters": [ + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^pty" + } + ] + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "PtyNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PtyNotFoundError" + } + } + } + } + }, + "description": "Update the title or viewport size of one PTY session.", + "summary": "Update PTY session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "size": { + "type": "object", + "properties": { + "rows": { + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] + }, + "cols": { + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] + } + }, + "required": [ + "rows", + "cols" + ], + "additionalProperties": false + } + }, + "additionalProperties": false + } + } + }, + "required": true + } + }, + "delete": { + "tags": [ + "pty" + ], + "operationId": "v2.pty.remove", + "parameters": [ + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^pty" + } + ] + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "PtyNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PtyNotFoundError" + } + } + } + } + }, + "description": "Terminate and remove one PTY session.", + "summary": "Remove PTY session" + } + }, + "/api/pty/{ptyID}/connect-token": { + "post": { + "tags": [ + "pty" + ], + "operationId": "v2.pty.connectToken", + "parameters": [ + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^pty" + } + ] + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/PtyTicket.ConnectToken" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "403": { + "description": "ForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenError" + } + } + } + }, + "404": { + "description": "PtyNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PtyNotFoundError" + } + } + } + } + }, + "description": "Create a short-lived single-use ticket for opening a PTY WebSocket connection.", + "summary": "Create PTY WebSocket token" + } + }, + "/api/pty/{ptyID}/connect": { + "get": { + "tags": [ + "pty" + ], + "operationId": "v2.pty.connect", + "x-websocket": true, + "parameters": [ + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^pty" + } + ] + }, + "required": true + }, + { + "in": "query", + "name": "location[directory]", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "location[workspace]", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "cursor", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "ticket", + "schema": { + "type": "string" + } + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "403": { + "description": "ForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenError" + } + } + } + }, + "404": { + "description": "PtyNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PtyNotFoundError" + } + } + } + } + }, + "description": "Establish a WebSocket connection streaming PTY output and accepting terminal input.", + "summary": "Connect to PTY session" + } + }, + "/api/shell": { + "get": { + "tags": [ + "shell" + ], + "operationId": "v2.shell.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Shell1" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "List currently running shell commands for a location. Exited commands are not included.", + "summary": "List running shell commands" + }, + "post": { + "tags": [ + "shell" + ], + "operationId": "v2.shell.create", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Shell1" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Spawn one non-interactive shell command for a location. Combined stdout/stderr is captured to a file pageable via output.", + "summary": "Run shell command", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "timeout": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "metadata": { + "type": "object" + } + }, + "required": [ + "command" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/shell/{id}": { + "get": { + "tags": [ + "shell" + ], + "operationId": "v2.shell.get", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Shell1" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "ShellNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShellNotFoundError" + } + } + } + } + }, + "description": "Get one shell command, including its status and exit code once exited.", + "summary": "Get shell command" + }, + "delete": { + "tags": [ + "shell" + ], + "operationId": "v2.shell.remove", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "ShellNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShellNotFoundError" + } + } + } + } + }, + "description": "Terminate and remove one shell command and its retained output.", + "summary": "Remove shell command" + } + }, + "/api/shell/{id}/output": { + "get": { + "tags": [ + "shell" + ], + "operationId": "v2.shell.output", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^[+-]?\\d*\\.?\\d+(?:[Ee][+-]?\\d+)?$" + } + ] + }, + "required": false + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^[+-]?\\d*\\.?\\d+(?:[Ee][+-]?\\d+)?$" + } + ] + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "object", + "properties": { + "output": { + "type": "string" + }, + "cursor": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "size": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "output", + "cursor", + "size", + "truncated" + ], + "additionalProperties": false + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "ShellNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShellNotFoundError" + } + } + } + } + }, + "description": "Page through captured combined output by absolute byte cursor.", + "summary": "Read shell output" + } + }, + "/api/question/request": { + "get": { + "tags": [ + "session questions" + ], + "operationId": "v2.question.request.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2.Request" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve pending question requests for a location.", + "summary": "List pending question requests" + } + }, + "/api/session/{sessionID}/question": { + "get": { + "tags": [ + "session questions" + ], + "operationId": "v2.session.question.list", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2.Request" + } + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve pending question requests owned by a session.", + "summary": "List session question requests" + } + }, + "/api/session/{sessionID}/question/{requestID}/reply": { + "post": { + "tags": [ + "session questions" + ], + "operationId": "v2.session.question.reply", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + }, + { + "name": "requestID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^que" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | QuestionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/QuestionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Answer a pending question request owned by a session.", + "summary": "Reply to pending question request", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QuestionV2.Reply" + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/question/{requestID}/reject": { + "post": { + "tags": [ + "session questions" + ], + "operationId": "v2.session.question.reject", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + }, + { + "name": "requestID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^que" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | QuestionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/QuestionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Reject a pending question request owned by a session.", + "summary": "Reject pending question request" + } + }, + "/api/reference": { + "get": { + "tags": [ + "reference" + ], + "operationId": "v2.reference.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Reference.Info" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "List references available in the requested location.", + "summary": "List references" + } + }, + "/experimental/project/{projectID}/copy": { + "post": { + "tags": [ + "projectCopy" + ], + "operationId": "v2.projectCopy.create", + "parameters": [ + { + "name": "projectID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "ProjectCopy.Copy", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectCopy.Copy" + } + } + } + }, + "400": { + "description": "ProjectCopyError | InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectCopyError" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "strategy": { + "type": "string" + }, + "directory": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "strategy", + "directory" + ], + "additionalProperties": false + } + } + }, + "required": true + } + }, + "delete": { + "tags": [ + "projectCopy" + ], + "operationId": "v2.projectCopy.remove", + "parameters": [ + { + "name": "projectID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "ProjectCopyError | InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectCopyError" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "force": { + "type": "boolean" + } + }, + "required": [ + "directory", + "force" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/experimental/project/{projectID}/copy/refresh": { + "post": { + "tags": [ + "projectCopy" + ], + "operationId": "v2.projectCopy.refresh", + "parameters": [ + { + "name": "projectID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "ProjectCopyError | InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectCopyError" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + } + } + }, + "/api/vcs/status": { + "get": { + "tags": [ + "vcs" + ], + "operationId": "v2.vcs.status", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Vcs.FileStatus" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "List uncommitted working-copy changes relative to the requested location.", + "summary": "VCS status" + } + }, + "/api/vcs/diff": { + "get": { + "tags": [ + "vcs" + ], + "operationId": "v2.vcs.diff", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + }, + { + "name": "mode", + "in": "query", + "schema": { + "$ref": "#/components/schemas/Vcs.Mode" + }, + "required": true + }, + { + "name": "context", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Diff the working copy against HEAD (mode git) or the default-branch merge base (mode branch) for the requested location.", + "summary": "VCS diff" + } + } + }, + "components": { + "schemas": { + "UnauthorizedError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "UnauthorizedError" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "InvalidRequestError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "InvalidRequestError" + ] + }, + "message": { + "type": "string" + }, + "kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "field": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "Location.Info": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "workspaceID": { + "type": "string", + "allOf": [ + { + "pattern": "^wrk" + } + ] + }, + "project": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "directory": { + "type": "string" + } + }, + "required": [ + "id", + "directory" + ], + "additionalProperties": false + } + }, + "required": [ + "directory", + "project" + ], + "additionalProperties": false + }, + "Model.Ref": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": [ + "id", + "providerID" + ], + "additionalProperties": false + }, + "Provider.Settings": { + "type": "object" + }, + "Provider.Request": { + "type": "object", + "properties": { + "settings": { + "$ref": "#/components/schemas/Provider.Settings" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + } + }, + "required": [ + "settings", + "headers", + "body" + ], + "additionalProperties": false + }, + "Agent.Color": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^#[0-9a-fA-F]{6}$" + } + ] + }, + { + "type": "string", + "enum": [ + "primary", + "secondary", + "accent", + "success", + "warning", + "error", + "info" + ] + } + ] + }, + "PermissionV2.Effect": { + "type": "string", + "enum": [ + "allow", + "deny", + "ask" + ] + }, + "PermissionV2.Rule": { + "type": "object", + "properties": { + "action": { + "type": "string" + }, + "resource": { + "type": "string" + }, + "effect": { + "$ref": "#/components/schemas/PermissionV2.Effect" + } + }, + "required": [ + "action", + "resource", + "effect" + ], + "additionalProperties": false + }, + "PermissionV2.Ruleset": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionV2.Rule" + } + }, + "AgentV2.Info": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + }, + "request": { + "$ref": "#/components/schemas/Provider.Request" + }, + "system": { + "type": "string" + }, + "description": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "subagent", + "primary", + "all" + ] + }, + "hidden": { + "type": "boolean" + }, + "color": { + "$ref": "#/components/schemas/Agent.Color" + }, + "steps": { + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] + }, + "permissions": { + "$ref": "#/components/schemas/PermissionV2.Ruleset" + } + }, + "required": [ + "id", + "request", + "mode", + "hidden", + "permissions" + ], + "additionalProperties": false + }, + "Plugin.Info": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "Location.Ref": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "workspaceID": { + "type": "string", + "allOf": [ + { + "pattern": "^wrk" + } + ] + } + }, + "required": [ + "directory" + ], + "additionalProperties": false + }, + "File.Diff": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "added", + "modified", + "deleted" + ] + }, + "additions": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "deletions": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "patch": { + "type": "string" + } + }, + "required": [ + "path", + "status", + "additions", + "deletions", + "patch" + ], + "additionalProperties": false + }, + "Revert.State": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "partID": { + "type": "string" + }, + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/File.Diff" + } + } + }, + "required": [ + "messageID" + ], + "additionalProperties": false + }, + "SessionV2.Info": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "parentID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "projectID": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ], + "additionalProperties": false + } + }, + "required": [ + "input", + "output", + "reasoning", + "cache" + ], + "additionalProperties": false + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "updated": { + "type": "number" + }, + "archived": { + "type": "number" + } + }, + "required": [ + "created", + "updated" + ], + "additionalProperties": false + }, + "title": { + "type": "string" + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "subpath": { + "type": "string" + }, + "revert": { + "$ref": "#/components/schemas/Revert.State" + } + }, + "required": [ + "id", + "projectID", + "cost", + "tokens", + "time", + "title", + "location" + ], + "additionalProperties": false + }, + "SessionWatermarks": { + "type": "object", + "patternProperties": { + "^ses": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "description": "Durable log seq each session's snapshot was computed at. Attach a live log read after the watermark to compose fetch and stream gap-free; apply a snapshot only where its watermark is at or beyond already-applied events. Sessions without durable events are absent." + }, + "SessionsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionV2.Info" + } + }, + "watermarks": { + "$ref": "#/components/schemas/SessionWatermarks" + }, + "cursor": { + "type": "object", + "properties": { + "previous": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "next": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "required": [ + "data", + "watermarks", + "cursor" + ], + "additionalProperties": false + }, + "InvalidCursorError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "InvalidCursorError" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "InvalidRequestError1": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "InvalidRequestError" + ] + }, + "message": { + "type": "string" + }, + "kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "field": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "SessionActive": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "running" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "SessionNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "SessionNotFoundError" + ] + }, + "sessionID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "sessionID", + "message" + ], + "additionalProperties": false + }, + "MessageNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "MessageNotFoundError" + ] + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "sessionID", + "messageID", + "message" + ], + "additionalProperties": false + }, + "Prompt.Source": { + "type": "object", + "properties": { + "start": { + "type": "number" + }, + "end": { + "type": "number" + }, + "text": { + "type": "string" + } + }, + "required": [ + "start", + "end", + "text" + ], + "additionalProperties": false + }, + "PromptInput.FileAttachment": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/Prompt.Source" + } + }, + "required": [ + "uri" + ], + "additionalProperties": false + }, + "Prompt.AgentAttachment": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/Prompt.Source" + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + "PromptInput": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptInput.FileAttachment" + } + }, + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prompt.AgentAttachment" + } + } + }, + "required": [ + "text" + ], + "additionalProperties": false + }, + "Prompt.FileAttachment": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/Prompt.Source" + } + }, + "required": [ + "uri", + "mime" + ], + "additionalProperties": false + }, + "Prompt": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prompt.FileAttachment" + } + }, + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prompt.AgentAttachment" + } + } + }, + "required": [ + "text" + ], + "additionalProperties": false + }, + "SessionInput.Admitted": { + "type": "object", + "properties": { + "admittedSeq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "delivery": { + "type": "string", + "enum": [ + "steer", + "queue" + ] + }, + "timeCreated": { + "type": "number" + }, + "promotedSeq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "admittedSeq", + "id", + "sessionID", + "prompt", + "delivery", + "timeCreated" + ], + "additionalProperties": false + }, + "ConflictError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ConflictError" + ] + }, + "message": { + "type": "string" + }, + "resource": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "CommandNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "CommandNotFoundError" + ] + }, + "command": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "command", + "message" + ], + "additionalProperties": false + }, + "CommandEvaluationError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "CommandEvaluationError" + ] + }, + "command": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "command", + "message" + ], + "additionalProperties": false + }, + "SkillNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "SkillNotFoundError" + ] + }, + "skill": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "skill", + "message" + ], + "additionalProperties": false + }, + "SessionBusyError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "SessionBusyError" + ] + }, + "sessionID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "sessionID", + "message" + ], + "additionalProperties": false + }, + "ServiceUnavailableError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ServiceUnavailableError" + ] + }, + "message": { + "type": "string" + }, + "service": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "UnknownError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "UnknownError" + ] + }, + "message": { + "type": "string" + }, + "ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "Session.Message.AgentSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": [ + "agent-switched" + ] + }, + "agent": { + "type": "string" + } + }, + "required": [ + "id", + "time", + "type", + "agent" + ], + "additionalProperties": false + }, + "Session.Message.ModelSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": [ + "model-switched" + ] + }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + } + }, + "required": [ + "id", + "time", + "type", + "model" + ], + "additionalProperties": false + }, + "Session.Message.User": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "text": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prompt.FileAttachment" + } + }, + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prompt.AgentAttachment" + } + }, + "type": { + "type": "string", + "enum": [ + "user" + ] + } + }, + "required": [ + "id", + "time", + "text", + "type" + ], + "additionalProperties": false + }, + "Session.Message.Synthetic": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "text": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "synthetic" + ] + } + }, + "required": [ + "id", + "time", + "sessionID", + "text", + "type" + ], + "additionalProperties": false + }, + "Session.Message.System": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": [ + "system" + ] + }, + "text": { + "type": "string" + } + }, + "required": [ + "id", + "time", + "type", + "text" + ], + "additionalProperties": false + }, + "Session.Message.Skill": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": [ + "skill" + ] + }, + "name": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "id", + "time", + "type", + "name", + "text" + ], + "additionalProperties": false + }, + "Session.Message.Shell": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "completed": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": [ + "shell" + ] + }, + "callID": { + "type": "string" + }, + "command": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "required": [ + "id", + "time", + "type", + "callID", + "command", + "output" + ], + "additionalProperties": false + }, + "Session.Message.Assistant.Text": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "id": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "type", + "id", + "text" + ], + "additionalProperties": false + }, + "LLM.ProviderMetadata": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "Session.Message.Assistant.Reasoning": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "reasoning" + ] + }, + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "providerMetadata": { + "$ref": "#/components/schemas/LLM.ProviderMetadata" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "completed": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + } + }, + "required": [ + "type", + "id", + "text" + ], + "additionalProperties": false + }, + "Session.Message.ToolState.Pending": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "pending" + ] + }, + "input": { + "type": "string" + } + }, + "required": [ + "status", + "input" + ], + "additionalProperties": false + }, + "Tool.TextContent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string" + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + "Tool.FileContent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "file" + ] + }, + "uri": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "type", + "uri", + "mime" + ], + "additionalProperties": false + }, + "LLM.ToolContent": { + "anyOf": [ + { + "$ref": "#/components/schemas/Tool.TextContent" + }, + { + "$ref": "#/components/schemas/Tool.FileContent" + } + ] + }, + "Session.Message.ToolState.Running": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "running" + ] + }, + "input": { + "type": "object" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LLM.ToolContent" + } + } + }, + "required": [ + "status", + "input", + "structured", + "content" + ], + "additionalProperties": false + }, + "Session.Message.ToolState.Completed": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "completed" + ] + }, + "input": { + "type": "object" + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prompt.FileAttachment" + } + }, + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LLM.ToolContent" + } + }, + "outputPaths": { + "type": "array", + "items": { + "type": "string" + } + }, + "structured": { + "type": "object" + }, + "result": {} + }, + "required": [ + "status", + "input", + "content", + "structured" + ], + "additionalProperties": false + }, + "Session.Error.Unknown": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "unknown" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "type", + "message" + ], + "additionalProperties": false + }, + "Session.Message.ToolState.Error": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "error" + ] + }, + "input": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LLM.ToolContent" + } + }, + "structured": { + "type": "object" + }, + "error": { + "$ref": "#/components/schemas/Session.Error.Unknown" + }, + "result": {} + }, + "required": [ + "status", + "input", + "content", + "structured", + "error" + ], + "additionalProperties": false + }, + "Session.Message.Assistant.Tool": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "tool" + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "$ref": "#/components/schemas/LLM.ProviderMetadata" + }, + "resultMetadata": { + "$ref": "#/components/schemas/LLM.ProviderMetadata" + } + }, + "required": [ + "executed" + ], + "additionalProperties": false + }, + "state": { + "anyOf": [ + { + "$ref": "#/components/schemas/Session.Message.ToolState.Pending" + }, + { + "$ref": "#/components/schemas/Session.Message.ToolState.Running" + }, + { + "$ref": "#/components/schemas/Session.Message.ToolState.Completed" + }, + { + "$ref": "#/components/schemas/Session.Message.ToolState.Error" + } + ] + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "ran": { + "type": "number" + }, + "completed": { + "type": "number" + }, + "pruned": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + } + }, + "required": [ + "type", + "id", + "name", + "state", + "time" + ], + "additionalProperties": false + }, + "Session.Message.Assistant": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "completed": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": [ + "assistant" + ] + }, + "agent": { + "type": "string" + }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Session.Message.Assistant.Text" + }, + { + "$ref": "#/components/schemas/Session.Message.Assistant.Reasoning" + }, + { + "$ref": "#/components/schemas/Session.Message.Assistant.Tool" + } + ] + } + }, + "snapshot": { + "type": "object", + "properties": { + "start": { + "type": "string" + }, + "end": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "finish": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ], + "additionalProperties": false + } + }, + "required": [ + "input", + "output", + "reasoning", + "cache" + ], + "additionalProperties": false + }, + "error": { + "$ref": "#/components/schemas/Session.Error.Unknown" + } + }, + "required": [ + "id", + "time", + "type", + "agent", + "model", + "content" + ], + "additionalProperties": false + }, + "Session.Message.Compaction": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "compaction" + ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + }, + "summary": { + "type": "string" + }, + "recent": { + "type": "string" + }, + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + } + }, + "required": [ + "type", + "reason", + "summary", + "recent", + "id", + "time" + ], + "additionalProperties": false + }, + "Session.Message": { + "anyOf": [ + { + "$ref": "#/components/schemas/Session.Message.AgentSwitched" + }, + { + "$ref": "#/components/schemas/Session.Message.ModelSwitched" + }, + { + "$ref": "#/components/schemas/Session.Message.User" + }, + { + "$ref": "#/components/schemas/Session.Message.Synthetic" + }, + { + "$ref": "#/components/schemas/Session.Message.System" + }, + { + "$ref": "#/components/schemas/Session.Message.Skill" + }, + { + "$ref": "#/components/schemas/Session.Message.Shell" + }, + { + "$ref": "#/components/schemas/Session.Message.Assistant" + }, + { + "$ref": "#/components/schemas/Session.Message.Compaction" + } + ] + }, + "SessionContextEntry.Key": { + "type": "string", + "allOf": [ + { + "pattern": "^[a-z0-9][a-z0-9._-]*$", + "description": "Context entry key (lowercase alphanumerics plus . _ -)" + } + ] + }, + "SessionContextEntry.Info": { + "type": "object", + "properties": { + "key": { + "$ref": "#/components/schemas/SessionContextEntry.Key" + }, + "value": {} + }, + "required": [ + "key", + "value" + ], + "additionalProperties": false + }, + "session.next.agent.switched": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.agent.switched" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "agent": { + "type": "string" + } + }, + "required": [ + "timestamp", + "sessionID", + "messageID", + "agent" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.next.model.switched": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.model.switched" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + } + }, + "required": [ + "timestamp", + "sessionID", + "messageID", + "model" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.next.moved": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.moved" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "subdirectory": { + "type": "string" + } + }, + "required": [ + "timestamp", + "sessionID", + "location" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.next.renamed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.renamed" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "title": { + "type": "string" + } + }, + "required": [ + "timestamp", + "sessionID", + "title" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.next.forked": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.forked" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "parentID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "timestamp", + "sessionID", + "parentID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.next.prompted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.prompted" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "delivery": { + "type": "string", + "enum": [ + "steer", + "queue" + ] + } + }, + "required": [ + "timestamp", + "sessionID", + "messageID", + "prompt", + "delivery" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.next.prompt.admitted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.prompt.admitted" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "delivery": { + "type": "string", + "enum": [ + "steer", + "queue" + ] + } + }, + "required": [ + "timestamp", + "sessionID", + "messageID", + "prompt", + "delivery" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.next.context.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.context.updated" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "text": { + "type": "string" + } + }, + "required": [ + "timestamp", + "sessionID", + "messageID", + "text" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.next.synthetic": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.synthetic" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "text": { + "type": "string" + }, + "description": { + "type": "string" + }, + "metadata": { + "type": "object" + } + }, + "required": [ + "timestamp", + "sessionID", + "messageID", + "text" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.next.skill.activated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.skill.activated" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "name": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "timestamp", + "sessionID", + "messageID", + "name", + "text" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.next.shell.started": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.shell.started" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "callID": { + "type": "string" + }, + "command": { + "type": "string" + } + }, + "required": [ + "timestamp", + "sessionID", + "messageID", + "callID", + "command" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.next.shell.ended": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.shell.ended" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "callID": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "required": [ + "timestamp", + "sessionID", + "callID", + "output" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.next.step.started": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.step.started" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "agent": { + "type": "string" + }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + }, + "snapshot": { + "type": "string" + } + }, + "required": [ + "timestamp", + "sessionID", + "assistantMessageID", + "agent", + "model" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.next.step.ended": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.step.ended" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "finish": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ], + "additionalProperties": false + } + }, + "required": [ + "input", + "output", + "reasoning", + "cache" + ], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "timestamp", + "sessionID", + "assistantMessageID", + "finish", + "cost", + "tokens" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.next.step.failed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.step.failed" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "error": { + "$ref": "#/components/schemas/Session.Error.Unknown" + } + }, + "required": [ + "timestamp", + "sessionID", + "assistantMessageID", + "error" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.next.text.started": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.text.started" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "textID": { + "type": "string" + } + }, + "required": [ + "timestamp", + "sessionID", + "assistantMessageID", + "textID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.next.text.ended": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.text.ended" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "textID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "timestamp", + "sessionID", + "assistantMessageID", + "textID", + "text" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.next.tool.input.started": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.tool.input.started" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "callID": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "timestamp", + "sessionID", + "assistantMessageID", + "callID", + "name" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.next.tool.input.ended": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.tool.input.ended" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "callID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "timestamp", + "sessionID", + "assistantMessageID", + "callID", + "text" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "LLM.ProviderMetadata3": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "session.next.tool.called": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.tool.called" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "input": { + "type": "object" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "$ref": "#/components/schemas/LLM.ProviderMetadata3" + } + }, + "required": [ + "executed" + ], + "additionalProperties": false + } + }, + "required": [ + "timestamp", + "sessionID", + "assistantMessageID", + "callID", + "tool", + "input", + "provider" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.next.tool.progress": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.tool.progress" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LLM.ToolContent" + } + } + }, + "required": [ + "timestamp", + "sessionID", + "assistantMessageID", + "callID", + "structured", + "content" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "LLM.ProviderMetadata4": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "session.next.tool.success": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.tool.success" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LLM.ToolContent" + } + }, + "outputPaths": { + "type": "array", + "items": { + "type": "string" + } + }, + "result": {}, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "$ref": "#/components/schemas/LLM.ProviderMetadata4" + } + }, + "required": [ + "executed" + ], + "additionalProperties": false + } + }, + "required": [ + "timestamp", + "sessionID", + "assistantMessageID", + "callID", + "structured", + "content", + "provider" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "LLM.ProviderMetadata5": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "session.next.tool.failed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.tool.failed" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "callID": { + "type": "string" + }, + "error": { + "$ref": "#/components/schemas/Session.Error.Unknown" + }, + "result": {}, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "$ref": "#/components/schemas/LLM.ProviderMetadata5" + } + }, + "required": [ + "executed" + ], + "additionalProperties": false + } + }, + "required": [ + "timestamp", + "sessionID", + "assistantMessageID", + "callID", + "error", + "provider" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "LLM.ProviderMetadata6": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "session.next.reasoning.started": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.reasoning.started" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "reasoningID": { + "type": "string" + }, + "providerMetadata": { + "$ref": "#/components/schemas/LLM.ProviderMetadata6" + } + }, + "required": [ + "timestamp", + "sessionID", + "assistantMessageID", + "reasoningID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "LLM.ProviderMetadata7": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "session.next.reasoning.ended": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.reasoning.ended" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "reasoningID": { + "type": "string" + }, + "text": { + "type": "string" + }, + "providerMetadata": { + "$ref": "#/components/schemas/LLM.ProviderMetadata7" + } + }, + "required": [ + "timestamp", + "sessionID", + "assistantMessageID", + "reasoningID", + "text" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.next.retry_error": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "statusCode": { + "type": "number" + }, + "isRetryable": { + "type": "boolean" + }, + "responseHeaders": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "responseBody": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "message", + "isRetryable" + ], + "additionalProperties": false + }, + "session.next.retried": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.retried" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "attempt": { + "type": "number" + }, + "error": { + "$ref": "#/components/schemas/session.next.retry_error" + } + }, + "required": [ + "timestamp", + "sessionID", + "attempt", + "error" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.next.compaction.started": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.compaction.started" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + } + }, + "required": [ + "timestamp", + "sessionID", + "messageID", + "reason" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.next.compaction.ended": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.compaction.ended" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + }, + "text": { + "type": "string" + }, + "recent": { + "type": "string" + } + }, + "required": [ + "timestamp", + "sessionID", + "messageID", + "reason", + "text", + "recent" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.next.revert.staged": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.revert.staged" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "revert": { + "$ref": "#/components/schemas/Revert.State" + } + }, + "required": [ + "timestamp", + "sessionID", + "revert" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.next.revert.cleared": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.revert.cleared" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + } + }, + "required": [ + "timestamp", + "sessionID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.next.revert.committed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.revert.committed" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "timestamp", + "sessionID", + "messageID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "SessionDurableEvent": { + "oneOf": [ + { + "$ref": "#/components/schemas/session.next.agent.switched" + }, + { + "$ref": "#/components/schemas/session.next.model.switched" + }, + { + "$ref": "#/components/schemas/session.next.moved" + }, + { + "$ref": "#/components/schemas/session.next.renamed" + }, + { + "$ref": "#/components/schemas/session.next.forked" + }, + { + "$ref": "#/components/schemas/session.next.prompted" + }, + { + "$ref": "#/components/schemas/session.next.prompt.admitted" + }, + { + "$ref": "#/components/schemas/session.next.context.updated" + }, + { + "$ref": "#/components/schemas/session.next.synthetic" + }, + { + "$ref": "#/components/schemas/session.next.skill.activated" + }, + { + "$ref": "#/components/schemas/session.next.shell.started" + }, + { + "$ref": "#/components/schemas/session.next.shell.ended" + }, + { + "$ref": "#/components/schemas/session.next.step.started" + }, + { + "$ref": "#/components/schemas/session.next.step.ended" + }, + { + "$ref": "#/components/schemas/session.next.step.failed" + }, + { + "$ref": "#/components/schemas/session.next.text.started" + }, + { + "$ref": "#/components/schemas/session.next.text.ended" + }, + { + "$ref": "#/components/schemas/session.next.tool.input.started" + }, + { + "$ref": "#/components/schemas/session.next.tool.input.ended" + }, + { + "$ref": "#/components/schemas/session.next.tool.called" + }, + { + "$ref": "#/components/schemas/session.next.tool.progress" + }, + { + "$ref": "#/components/schemas/session.next.tool.success" + }, + { + "$ref": "#/components/schemas/session.next.tool.failed" + }, + { + "$ref": "#/components/schemas/session.next.reasoning.started" + }, + { + "$ref": "#/components/schemas/session.next.reasoning.ended" + }, + { + "$ref": "#/components/schemas/session.next.retried" + }, + { + "$ref": "#/components/schemas/session.next.compaction.started" + }, + { + "$ref": "#/components/schemas/session.next.compaction.ended" + }, + { + "$ref": "#/components/schemas/session.next.revert.staged" + }, + { + "$ref": "#/components/schemas/session.next.revert.cleared" + }, + { + "$ref": "#/components/schemas/session.next.revert.committed" + } + ] + }, + "EventLog.Synced": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "log.synced" + ] + }, + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "type", + "aggregateID" + ], + "additionalProperties": false, + "description": "Marker emitted once when a log read reaches its captured watermark. The reader holds every event committed at or below seq." + }, + "SessionLogItem": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionDurableEvent" + }, + { + "$ref": "#/components/schemas/EventLog.Synced" + } + ] + }, + "SessionLogItemStream": { + "type": "string", + "contentSchema": { + "$ref": "#/components/schemas/SessionLogItem" + }, + "contentMediaType": "application/json" + }, + "SessionMessagesResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Session.Message" + } + }, + "watermark": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "cursor": { + "type": "object", + "properties": { + "previous": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "next": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "required": [ + "data", + "cursor" + ], + "additionalProperties": false + }, + "Model.Api": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "aisdk" + ] + }, + "package": { + "type": "string" + }, + "url": { + "type": "string" + }, + "settings": { + "type": "object" + } + }, + "required": [ + "id", + "type", + "package" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "native" + ] + }, + "url": { + "type": "string" + }, + "settings": { + "type": "object" + } + }, + "required": [ + "id", + "type", + "settings" + ], + "additionalProperties": false + } + ] + }, + "Model.Capabilities": { + "type": "object", + "properties": { + "tools": { + "type": "boolean" + }, + "input": { + "type": "array", + "items": { + "type": "string" + } + }, + "output": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "tools", + "input", + "output" + ], + "additionalProperties": false + }, + "Model.Cost": { + "type": "object", + "properties": { + "tier": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "context" + ] + }, + "size": { + "type": "integer" + } + }, + "required": [ + "type", + "size" + ], + "additionalProperties": false + }, + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ], + "additionalProperties": false + } + }, + "required": [ + "input", + "output", + "cache" + ], + "additionalProperties": false + }, + "ModelV2.Info": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "family": { + "type": "string" + }, + "name": { + "type": "string" + }, + "api": { + "$ref": "#/components/schemas/Model.Api" + }, + "capabilities": { + "$ref": "#/components/schemas/Model.Capabilities" + }, + "request": { + "type": "object", + "properties": { + "settings": { + "$ref": "#/components/schemas/Provider.Settings" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + }, + "variant": { + "type": "string" + } + }, + "required": [ + "settings", + "headers", + "body" + ], + "additionalProperties": false + }, + "variants": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "settings": { + "$ref": "#/components/schemas/Provider.Settings" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + } + }, + "required": [ + "id", + "settings", + "headers", + "body" + ], + "additionalProperties": false + } + }, + "time": { + "type": "object", + "properties": { + "released": { + "type": "number" + } + }, + "required": [ + "released" + ], + "additionalProperties": false + }, + "cost": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Model.Cost" + } + }, + "status": { + "type": "string", + "enum": [ + "alpha", + "beta", + "deprecated", + "active" + ] + }, + "enabled": { + "type": "boolean" + }, + "limit": { + "type": "object", + "properties": { + "context": { + "type": "integer" + }, + "input": { + "type": "integer" + }, + "output": { + "type": "integer" + } + }, + "required": [ + "context", + "output" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "providerID", + "name", + "api", + "capabilities", + "request", + "variants", + "time", + "cost", + "status", + "enabled", + "limit" + ], + "additionalProperties": false + }, + "GenerateTextResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ], + "additionalProperties": false + } + }, + "required": [ + "data" + ], + "additionalProperties": false + }, + "Provider.AISDK": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "aisdk" + ] + }, + "package": { + "type": "string" + }, + "url": { + "type": "string" + }, + "settings": { + "type": "object" + } + }, + "required": [ + "type", + "package" + ], + "additionalProperties": false + }, + "Provider.Native": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "native" + ] + }, + "url": { + "type": "string" + }, + "settings": { + "type": "object" + } + }, + "required": [ + "type", + "settings" + ], + "additionalProperties": false + }, + "Provider.Api": { + "anyOf": [ + { + "$ref": "#/components/schemas/Provider.AISDK" + }, + { + "$ref": "#/components/schemas/Provider.Native" + } + ] + }, + "ProviderV2.Info": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "integrationID": { + "type": "string" + }, + "name": { + "type": "string" + }, + "disabled": { + "type": "boolean" + }, + "api": { + "$ref": "#/components/schemas/Provider.Api" + }, + "request": { + "$ref": "#/components/schemas/Provider.Request" + } + }, + "required": [ + "id", + "name", + "api", + "request" + ], + "additionalProperties": false + }, + "ProviderNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ProviderNotFoundError" + ] + }, + "providerID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "providerID", + "message" + ], + "additionalProperties": false + }, + "Integration.When": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "op": { + "type": "string", + "enum": [ + "eq", + "neq" + ] + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "op", + "value" + ], + "additionalProperties": false + }, + "Integration.TextPrompt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "key": { + "type": "string" + }, + "message": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "when": { + "$ref": "#/components/schemas/Integration.When" + } + }, + "required": [ + "type", + "key", + "message" + ], + "additionalProperties": false + }, + "Integration.SelectPrompt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "select" + ] + }, + "key": { + "type": "string" + }, + "message": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + }, + "hint": { + "type": "string" + } + }, + "required": [ + "label", + "value" + ], + "additionalProperties": false + } + }, + "when": { + "$ref": "#/components/schemas/Integration.When" + } + }, + "required": [ + "type", + "key", + "message", + "options" + ], + "additionalProperties": false + }, + "Integration.OAuthMethod": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "oauth" + ] + }, + "label": { + "type": "string" + }, + "prompts": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Integration.TextPrompt" + }, + { + "$ref": "#/components/schemas/Integration.SelectPrompt" + } + ] + } + } + }, + "required": [ + "id", + "type", + "label" + ], + "additionalProperties": false + }, + "Integration.KeyMethod": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "key" + ] + }, + "label": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "Integration.EnvMethod": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "env" + ] + }, + "names": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "names" + ], + "additionalProperties": false + }, + "Integration.Method": { + "anyOf": [ + { + "$ref": "#/components/schemas/Integration.OAuthMethod" + }, + { + "$ref": "#/components/schemas/Integration.KeyMethod" + }, + { + "$ref": "#/components/schemas/Integration.EnvMethod" + } + ] + }, + "Connection.CredentialInfo": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "credential" + ] + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + } + }, + "required": [ + "type", + "id", + "label" + ], + "additionalProperties": false + }, + "Connection.EnvInfo": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "env" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "type", + "name" + ], + "additionalProperties": false + }, + "Connection.Info": { + "anyOf": [ + { + "$ref": "#/components/schemas/Connection.CredentialInfo" + }, + { + "$ref": "#/components/schemas/Connection.EnvInfo" + } + ] + }, + "Integration.Info": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "methods": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Integration.Method" + } + }, + "connections": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Connection.Info" + } + } + }, + "required": [ + "id", + "name", + "methods", + "connections" + ], + "additionalProperties": false + }, + "Integration.Attempt": { + "type": "object", + "properties": { + "attemptID": { + "type": "string" + }, + "url": { + "type": "string" + }, + "instructions": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "auto", + "code" + ] + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "expires": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "created", + "expires" + ], + "additionalProperties": false + } + }, + "required": [ + "attemptID", + "url", + "instructions", + "mode", + "time" + ], + "additionalProperties": false + }, + "Integration.AttemptStatus": { + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "pending" + ] + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "expires": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "created", + "expires" + ], + "additionalProperties": false + } + }, + "required": [ + "status", + "time" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "complete" + ] + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "expires": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "created", + "expires" + ], + "additionalProperties": false + } + }, + "required": [ + "status", + "time" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "failed" + ] + }, + "message": { + "type": "string" + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "expires": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "created", + "expires" + ], + "additionalProperties": false + } + }, + "required": [ + "status", + "message", + "time" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "expired" + ] + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "expires": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "created", + "expires" + ], + "additionalProperties": false + } + }, + "required": [ + "status", + "time" + ], + "additionalProperties": false + } + ] + }, + "Mcp.Status.Connected": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "connected" + ] + } + }, + "required": [ + "status" + ], + "additionalProperties": false + }, + "Mcp.Status.Disconnected": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "disconnected" + ] + } + }, + "required": [ + "status" + ], + "additionalProperties": false + }, + "Mcp.Status.Disabled": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "disabled" + ] + } + }, + "required": [ + "status" + ], + "additionalProperties": false + }, + "Mcp.Status.Failed": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "failed" + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "status", + "error" + ], + "additionalProperties": false + }, + "Mcp.Status.NeedsAuth": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "needs_auth" + ] + } + }, + "required": [ + "status" + ], + "additionalProperties": false + }, + "Mcp.Status.NeedsClientRegistration": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "needs_client_registration" + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "status", + "error" + ], + "additionalProperties": false + }, + "Mcp.Server": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "status": { + "anyOf": [ + { + "$ref": "#/components/schemas/Mcp.Status.Connected" + }, + { + "$ref": "#/components/schemas/Mcp.Status.Disconnected" + }, + { + "$ref": "#/components/schemas/Mcp.Status.Disabled" + }, + { + "$ref": "#/components/schemas/Mcp.Status.Failed" + }, + { + "$ref": "#/components/schemas/Mcp.Status.NeedsAuth" + }, + { + "$ref": "#/components/schemas/Mcp.Status.NeedsClientRegistration" + } + ] + }, + "integrationID": { + "type": "string" + } + }, + "required": [ + "name", + "status" + ], + "additionalProperties": false + }, + "Project.Current": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "directory": { + "type": "string" + } + }, + "required": [ + "id", + "directory" + ], + "additionalProperties": false + }, + "Project.Directory": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "strategy": { + "type": "string" + } + }, + "required": [ + "directory" + ], + "additionalProperties": false + }, + "Project.Directories": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Project.Directory" + } + }, + "Form.Metadata": { + "type": "object" + }, + "Form.When": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "op": { + "type": "string", + "enum": [ + "eq", + "neq" + ] + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + { + "type": "boolean" + } + ] + } + }, + "required": [ + "key", + "op", + "value" + ], + "additionalProperties": false + }, + "Form.Option": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "value", + "label" + ], + "additionalProperties": false + }, + "Form.StringField": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "when": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.When" + } + }, + "type": { + "type": "string", + "enum": [ + "string" + ] + }, + "format": { + "type": "string", + "enum": [ + "email", + "uri", + "date", + "date-time" + ] + }, + "minLength": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "maxLength": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "pattern": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "default": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.Option" + } + }, + "custom": { + "type": "boolean" + } + }, + "required": [ + "key", + "type" + ], + "additionalProperties": false + }, + "Form.NumberField": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "when": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.When" + } + }, + "type": { + "type": "string", + "enum": [ + "number" + ] + }, + "minimum": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "maximum": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "default": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "key", + "type" + ], + "additionalProperties": false + }, + "Form.IntegerField": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "when": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.When" + } + }, + "type": { + "type": "string", + "enum": [ + "integer" + ] + }, + "minimum": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "maximum": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "default": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "key", + "type" + ], + "additionalProperties": false + }, + "Form.BooleanField": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "when": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.When" + } + }, + "type": { + "type": "string", + "enum": [ + "boolean" + ] + }, + "default": { + "type": "boolean" + } + }, + "required": [ + "key", + "type" + ], + "additionalProperties": false + }, + "Form.MultiselectField": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "when": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.When" + } + }, + "type": { + "type": "string", + "enum": [ + "multiselect" + ] + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.Option" + } + }, + "minItems": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "maxItems": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "custom": { + "type": "boolean" + }, + "default": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "key", + "type", + "options" + ], + "additionalProperties": false + }, + "Form.FormInfo": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + "sessionID": { + "type": "string" + }, + "title": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Form.Metadata" + }, + "mode": { + "type": "string", + "enum": [ + "form" + ] + }, + "fields": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Form.StringField" + }, + { + "$ref": "#/components/schemas/Form.NumberField" + }, + { + "$ref": "#/components/schemas/Form.IntegerField" + }, + { + "$ref": "#/components/schemas/Form.BooleanField" + }, + { + "$ref": "#/components/schemas/Form.MultiselectField" + } + ] + } + } + }, + "required": [ + "id", + "sessionID", + "mode", + "fields" + ], + "additionalProperties": false + }, + "Form.UrlInfo": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + "sessionID": { + "type": "string" + }, + "title": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Form.Metadata" + }, + "mode": { + "type": "string", + "enum": [ + "url" + ] + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "sessionID", + "mode", + "url" + ], + "additionalProperties": false + }, + "Form.CreatePayload": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + { + "type": "null" + } + ] + }, + "title": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Form.Metadata" + }, + "mode": { + "type": "string", + "enum": [ + "form", + "url" + ] + }, + "fields": { + "anyOf": [ + { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Form.StringField" + }, + { + "$ref": "#/components/schemas/Form.NumberField" + }, + { + "$ref": "#/components/schemas/Form.IntegerField" + }, + { + "$ref": "#/components/schemas/Form.BooleanField" + }, + { + "$ref": "#/components/schemas/Form.MultiselectField" + } + ] + } + }, + { + "type": "null" + } + ] + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "mode" + ], + "additionalProperties": false + }, + "FormNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "FormNotFoundError" + ] + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "id", + "message" + ], + "additionalProperties": false + }, + "Form.Value": { + "anyOf": [ + { + "type": "string" + }, + { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "Form.Answer": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Form.Value" + } + }, + "Form.State": { + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "pending" + ] + } + }, + "required": [ + "status" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "answered" + ] + }, + "answer": { + "$ref": "#/components/schemas/Form.Answer" + } + }, + "required": [ + "status", + "answer" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "cancelled" + ] + } + }, + "required": [ + "status" + ], + "additionalProperties": false + } + ] + }, + "Form.Reply": { + "type": "object", + "properties": { + "answer": { + "$ref": "#/components/schemas/Form.Answer" + } + }, + "required": [ + "answer" + ], + "additionalProperties": false + }, + "FormAlreadySettledError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "FormAlreadySettledError" + ] + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "id", + "message" + ], + "additionalProperties": false + }, + "FormInvalidAnswerError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "FormInvalidAnswerError" + ] + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "id", + "message" + ], + "additionalProperties": false + }, + "PermissionV2.Source": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "tool" + ] + }, + "messageID": { + "type": "string" + }, + "callID": { + "type": "string" + } + }, + "required": [ + "type", + "messageID", + "callID" + ], + "additionalProperties": false + } + ] + }, + "PermissionV2.Request": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^per" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "action": { + "type": "string" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + } + }, + "save": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object" + }, + "source": { + "$ref": "#/components/schemas/PermissionV2.Source" + } + }, + "required": [ + "id", + "sessionID", + "action", + "resources" + ], + "additionalProperties": false + }, + "PermissionSaved.Info": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "projectID": { + "type": "string" + }, + "action": { + "type": "string" + }, + "resource": { + "type": "string" + } + }, + "required": [ + "id", + "projectID", + "action", + "resource" + ], + "additionalProperties": false + }, + "PermissionNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "PermissionNotFoundError" + ] + }, + "requestID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "requestID", + "message" + ], + "additionalProperties": false + }, + "PermissionV2.Reply": { + "type": "string", + "enum": [ + "once", + "always", + "reject" + ] + }, + "FileSystem.Entry": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "file", + "directory" + ] + } + }, + "required": [ + "path", + "type" + ], + "additionalProperties": false + }, + "CommandV2.Info": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "template": { + "type": "string" + }, + "description": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + }, + "subtask": { + "type": "boolean" + } + }, + "required": [ + "name", + "template" + ], + "additionalProperties": false + }, + "SkillV2.Info": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "slash": { + "type": "boolean" + }, + "autoinvoke": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "content": { + "type": "string" + } + }, + "required": [ + "name", + "location", + "content" + ], + "additionalProperties": false + }, + "models-dev.refreshed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "models-dev.refreshed" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "integration.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "integration.updated" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "integration.connection.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "integration.connection.updated" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "integrationID": { + "type": "string" + } + }, + "required": [ + "integrationID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "catalog.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "catalog.updated" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "agent.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "agent.updated" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "SnapshotFileDiff": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "patch": { + "type": "string" + }, + "additions": { + "type": "number" + }, + "deletions": { + "type": "number" + }, + "status": { + "type": "string", + "enum": [ + "added", + "deleted", + "modified" + ] + } + }, + "required": [ + "additions", + "deletions" + ], + "additionalProperties": false + }, + "PermissionAction": { + "type": "string", + "enum": [ + "allow", + "deny", + "ask" + ] + }, + "PermissionRule": { + "type": "object", + "properties": { + "permission": { + "type": "string" + }, + "pattern": { + "type": "string" + }, + "action": { + "$ref": "#/components/schemas/PermissionAction" + } + }, + "required": [ + "permission", + "pattern", + "action" + ], + "additionalProperties": false + }, + "PermissionRuleset": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionRule" + } + }, + "Session": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "slug": { + "type": "string" + }, + "projectID": { + "type": "string" + }, + "workspaceID": { + "type": "string", + "allOf": [ + { + "pattern": "^wrk" + } + ] + }, + "directory": { + "type": "string" + }, + "path": { + "type": "string" + }, + "parentID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "summary": { + "type": "object", + "properties": { + "additions": { + "type": "number" + }, + "deletions": { + "type": "number" + }, + "files": { + "type": "number" + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": [ + "additions", + "deletions", + "files" + ], + "additionalProperties": false + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ], + "additionalProperties": false + } + }, + "required": [ + "input", + "output", + "reasoning", + "cache" + ], + "additionalProperties": false + }, + "share": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": [ + "url" + ], + "additionalProperties": false + }, + "title": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": [ + "id", + "providerID" + ], + "additionalProperties": false + }, + "version": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "updated": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "compacting": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "archived": { + "type": "number" + } + }, + "required": [ + "created", + "updated" + ], + "additionalProperties": false + }, + "permission": { + "$ref": "#/components/schemas/PermissionRuleset" + }, + "revert": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "partID": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" + } + }, + "required": [ + "messageID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "slug", + "projectID", + "directory", + "title", + "version", + "time" + ], + "additionalProperties": false + }, + "session.created": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.created" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": [ + "sessionID", + "info" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.updated" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": [ + "sessionID", + "info" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.deleted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.deleted" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": [ + "sessionID", + "info" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "JSONSchema": { + "type": "object" + }, + "OutputFormat": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "json_schema" + ] + }, + "schema": { + "$ref": "#/components/schemas/JSONSchema" + }, + "retryCount": { + "anyOf": [ + { + "anyOf": [ + { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "schema" + ], + "additionalProperties": false + } + ] + }, + "UserMessage": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "role": { + "type": "string", + "enum": [ + "user" + ] + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "format": { + "anyOf": [ + { + "$ref": "#/components/schemas/OutputFormat" + }, + { + "type": "null" + } + ] + }, + "summary": { + "anyOf": [ + { + "type": "object", + "properties": { + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "body": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": [ + "diffs" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + }, + "variant": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "providerID", + "modelID" + ], + "additionalProperties": false + }, + "system": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "tools": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "sessionID", + "role", + "time", + "agent", + "model" + ], + "additionalProperties": false + }, + "ProviderAuthError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "ProviderAuthError" + ] + }, + "data": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "providerID", + "message" + ], + "additionalProperties": false + } + }, + "required": [ + "name", + "data" + ], + "additionalProperties": false + }, + "UnknownError1": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "UnknownError" + ] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "message" + ], + "additionalProperties": false + } + }, + "required": [ + "name", + "data" + ], + "additionalProperties": false + }, + "MessageOutputLengthError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "MessageOutputLengthError" + ] + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": [ + "name", + "data" + ], + "additionalProperties": false + }, + "MessageAbortedError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "MessageAbortedError" + ] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "additionalProperties": false + } + }, + "required": [ + "name", + "data" + ], + "additionalProperties": false + }, + "StructuredOutputError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "StructuredOutputError" + ] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "retries": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "message", + "retries" + ], + "additionalProperties": false + } + }, + "required": [ + "name", + "data" + ], + "additionalProperties": false + }, + "ContextOverflowError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "ContextOverflowError" + ] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "responseBody": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "message" + ], + "additionalProperties": false + } + }, + "required": [ + "name", + "data" + ], + "additionalProperties": false + }, + "ContentFilterError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "ContentFilterError" + ] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "additionalProperties": false + } + }, + "required": [ + "name", + "data" + ], + "additionalProperties": false + }, + "APIError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "APIError" + ] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "statusCode": { + "anyOf": [ + { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + { + "type": "null" + } + ] + }, + "isRetryable": { + "type": "boolean" + }, + "responseHeaders": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "responseBody": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "metadata": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "message", + "isRetryable" + ], + "additionalProperties": false + } + }, + "required": [ + "name", + "data" + ], + "additionalProperties": false + }, + "AssistantMessage": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "role": { + "type": "string", + "enum": [ + "assistant" + ] + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "completed": { + "anyOf": [ + { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "error": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/components/schemas/ProviderAuthError" + }, + { + "$ref": "#/components/schemas/UnknownError1" + }, + { + "$ref": "#/components/schemas/MessageOutputLengthError" + }, + { + "$ref": "#/components/schemas/MessageAbortedError" + }, + { + "$ref": "#/components/schemas/StructuredOutputError" + }, + { + "$ref": "#/components/schemas/ContextOverflowError" + }, + { + "$ref": "#/components/schemas/ContentFilterError" + }, + { + "$ref": "#/components/schemas/APIError" + } + ] + }, + { + "type": "null" + } + ] + }, + "parentID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "modelID": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "path": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "root": { + "type": "string" + } + }, + "required": [ + "cwd", + "root" + ], + "additionalProperties": false + }, + "summary": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "total": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ], + "additionalProperties": false + } + }, + "required": [ + "input", + "output", + "reasoning", + "cache" + ], + "additionalProperties": false + }, + "structured": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + }, + "variant": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "finish": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "sessionID", + "role", + "time", + "parentID", + "modelID", + "providerID", + "mode", + "agent", + "path", + "cost", + "tokens" + ], + "additionalProperties": false + }, + "Message": { + "anyOf": [ + { + "$ref": "#/components/schemas/UserMessage" + }, + { + "$ref": "#/components/schemas/AssistantMessage" + } + ] + }, + "message.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "message.updated" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "info": { + "$ref": "#/components/schemas/Message" + } + }, + "required": [ + "sessionID", + "info" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "message.removed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "message.removed" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + } + }, + "required": [ + "sessionID", + "messageID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "TextPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string" + }, + "synthetic": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ignored": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "time": { + "anyOf": [ + { + "type": "object", + "properties": { + "start": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "end": { + "anyOf": [ + { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "start" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "metadata": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "text" + ], + "additionalProperties": false + }, + "SubtaskPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "subtask" + ] + }, + "prompt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "anyOf": [ + { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": [ + "providerID", + "modelID" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "prompt", + "description", + "agent" + ], + "additionalProperties": false + }, + "ReasoningPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "reasoning" + ] + }, + "text": { + "type": "string" + }, + "metadata": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "end": { + "anyOf": [ + { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "start" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "text", + "time" + ], + "additionalProperties": false + }, + "FilePartSourceText": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "start": { + "type": "number" + }, + "end": { + "type": "number" + } + }, + "required": [ + "value", + "start", + "end" + ], + "additionalProperties": false + }, + "FileSource": { + "type": "object", + "properties": { + "text": { + "$ref": "#/components/schemas/FilePartSourceText" + }, + "type": { + "type": "string", + "enum": [ + "file" + ] + }, + "path": { + "type": "string" + } + }, + "required": [ + "text", + "type", + "path" + ], + "additionalProperties": false + }, + "Range": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "character": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "character": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + }, + "SymbolSource": { + "type": "object", + "properties": { + "text": { + "$ref": "#/components/schemas/FilePartSourceText" + }, + "type": { + "type": "string", + "enum": [ + "symbol" + ] + }, + "path": { + "type": "string" + }, + "range": { + "$ref": "#/components/schemas/Range" + }, + "name": { + "type": "string" + }, + "kind": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "text", + "type", + "path", + "range", + "name", + "kind" + ], + "additionalProperties": false + }, + "ResourceSource": { + "type": "object", + "properties": { + "text": { + "$ref": "#/components/schemas/FilePartSourceText" + }, + "type": { + "type": "string", + "enum": [ + "resource" + ] + }, + "clientName": { + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "required": [ + "text", + "type", + "clientName", + "uri" + ], + "additionalProperties": false + }, + "FilePartSource": { + "anyOf": [ + { + "$ref": "#/components/schemas/FileSource" + }, + { + "$ref": "#/components/schemas/SymbolSource" + }, + { + "$ref": "#/components/schemas/ResourceSource" + } + ] + }, + "FilePart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "file" + ] + }, + "mime": { + "type": "string" + }, + "filename": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "url": { + "type": "string" + }, + "source": { + "anyOf": [ + { + "$ref": "#/components/schemas/FilePartSource" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "mime", + "url" + ], + "additionalProperties": false + }, + "ToolStatePending": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "pending" + ] + }, + "input": { + "type": "object" + }, + "raw": { + "type": "string" + } + }, + "required": [ + "status", + "input", + "raw" + ], + "additionalProperties": false + }, + "ToolStateRunning": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "running" + ] + }, + "input": { + "type": "object" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "metadata": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "start" + ], + "additionalProperties": false + } + }, + "required": [ + "status", + "input", + "time" + ], + "additionalProperties": false + }, + "ToolStateCompleted": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "completed" + ] + }, + "input": { + "type": "object" + }, + "output": { + "type": "string" + }, + "title": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "end": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "compacted": { + "anyOf": [ + { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + }, + "attachments": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/components/schemas/FilePart" + } + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "status", + "input", + "output", + "title", + "metadata", + "time" + ], + "additionalProperties": false + }, + "ToolStateError": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "error" + ] + }, + "input": { + "type": "object" + }, + "error": { + "type": "string" + }, + "metadata": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "end": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + } + }, + "required": [ + "status", + "input", + "error", + "time" + ], + "additionalProperties": false + }, + "ToolState": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolStatePending" + }, + { + "$ref": "#/components/schemas/ToolStateRunning" + }, + { + "$ref": "#/components/schemas/ToolStateCompleted" + }, + { + "$ref": "#/components/schemas/ToolStateError" + } + ] + }, + "ToolPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "tool" + ] + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/ToolState" + }, + "metadata": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "callID", + "tool", + "state" + ], + "additionalProperties": false + }, + "StepStartPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "step-start" + ] + }, + "snapshot": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type" + ], + "additionalProperties": false + }, + "StepFinishPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "step-finish" + ] + }, + "reason": { + "type": "string" + }, + "snapshot": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "total": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ], + "additionalProperties": false + } + }, + "required": [ + "input", + "output", + "reasoning", + "cache" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "reason", + "cost", + "tokens" + ], + "additionalProperties": false + }, + "SnapshotPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "snapshot" + ] + }, + "snapshot": { + "type": "string" + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "snapshot" + ], + "additionalProperties": false + }, + "PatchPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "patch" + ] + }, + "hash": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "hash", + "files" + ], + "additionalProperties": false + }, + "AgentPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "agent" + ] + }, + "name": { + "type": "string" + }, + "source": { + "anyOf": [ + { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "start": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "end": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "value", + "start", + "end" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "name" + ], + "additionalProperties": false + }, + "RetryPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "retry" + ] + }, + "attempt": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "error": { + "$ref": "#/components/schemas/APIError" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "created" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "attempt", + "error", + "time" + ], + "additionalProperties": false + }, + "CompactionPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "compaction" + ] + }, + "auto": { + "type": "boolean" + }, + "overflow": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "tail_start_id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "auto" + ], + "additionalProperties": false + }, + "Part": { + "anyOf": [ + { + "$ref": "#/components/schemas/TextPart" + }, + { + "$ref": "#/components/schemas/SubtaskPart" + }, + { + "$ref": "#/components/schemas/ReasoningPart" + }, + { + "$ref": "#/components/schemas/FilePart" + }, + { + "$ref": "#/components/schemas/ToolPart" + }, + { + "$ref": "#/components/schemas/StepStartPart" + }, + { + "$ref": "#/components/schemas/StepFinishPart" + }, + { + "$ref": "#/components/schemas/SnapshotPart" + }, + { + "$ref": "#/components/schemas/PatchPart" + }, + { + "$ref": "#/components/schemas/AgentPart" + }, + { + "$ref": "#/components/schemas/RetryPart" + }, + { + "$ref": "#/components/schemas/CompactionPart" + } + ] + }, + "message.part.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "message.part.updated" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "part": { + "$ref": "#/components/schemas/Part" + }, + "time": { + "type": "number" + } + }, + "required": [ + "sessionID", + "part", + "time" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "message.part.removed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "message.part.removed" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "partID": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + } + }, + "required": [ + "sessionID", + "messageID", + "partID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.next.execution.settled": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.execution.settled" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "outcome": { + "type": "string", + "enum": [ + "success", + "failure", + "interrupted" + ] + }, + "error": { + "$ref": "#/components/schemas/Session.Error.Unknown" + } + }, + "required": [ + "timestamp", + "sessionID", + "outcome" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.next.text.delta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.text.delta" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "textID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": [ + "timestamp", + "sessionID", + "assistantMessageID", + "textID", + "delta" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.next.reasoning.delta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.reasoning.delta" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "reasoningID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": [ + "timestamp", + "sessionID", + "assistantMessageID", + "reasoningID", + "delta" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.next.tool.input.delta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.tool.input.delta" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "callID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": [ + "timestamp", + "sessionID", + "assistantMessageID", + "callID", + "delta" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.next.compaction.delta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.next.compaction.delta" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "text": { + "type": "string" + } + }, + "required": [ + "timestamp", + "sessionID", + "messageID", + "text" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "file.edited": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "file.edited" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": [ + "file" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "reference.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "reference.updated" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "permission.v2.asked": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "permission.v2.asked" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^per" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "action": { + "type": "string" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + } + }, + "save": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object" + }, + "source": { + "$ref": "#/components/schemas/PermissionV2.Source" + } + }, + "required": [ + "id", + "sessionID", + "action", + "resources" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "permission.v2.replied": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "permission.v2.replied" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "requestID": { + "type": "string", + "allOf": [ + { + "pattern": "^per" + } + ] + }, + "reply": { + "$ref": "#/components/schemas/PermissionV2.Reply" + } + }, + "required": [ + "sessionID", + "requestID", + "reply" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "plugin.added": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "plugin.added" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "project.directories.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "project.directories.updated" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "projectID": { + "type": "string" + } + }, + "required": [ + "projectID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "command.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "command.updated" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "skill.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "skill.updated" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "file.watcher.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "file.watcher.updated" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "type": "string", + "enum": [ + "add", + "change", + "unlink" + ] + } + }, + "required": [ + "file", + "event" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "Pty": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^pty" + } + ] + }, + "title": { + "type": "string" + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "cwd": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "running", + "exited" + ] + }, + "pid": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "exitCode": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "id", + "title", + "command", + "args", + "cwd", + "status", + "pid" + ], + "additionalProperties": false + }, + "pty.created": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "pty.created" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": [ + "info" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "pty.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "pty.updated" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": [ + "info" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "pty.exited": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "pty.exited" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^pty" + } + ] + }, + "exitCode": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "id", + "exitCode" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "pty.deleted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "pty.deleted" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^pty" + } + ] + } + }, + "required": [ + "id" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "Shell": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "running", + "exited", + "timeout", + "killed" + ] + }, + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "shell": { + "type": "string" + }, + "file": { + "type": "string" + }, + "pid": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "exit": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "started": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + "completed": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + } + }, + "required": [ + "started" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "status", + "command", + "cwd", + "shell", + "file", + "metadata", + "time" + ], + "additionalProperties": false + }, + "shell.created": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "shell.created" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Shell" + } + }, + "required": [ + "info" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "shell.exited": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "shell.exited" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "exit": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + "status": { + "type": "string", + "enum": [ + "running", + "exited", + "timeout", + "killed" + ] + } + }, + "required": [ + "id", + "status" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "shell.deleted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "shell.deleted" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + } + }, + "required": [ + "id" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "QuestionV2.Option": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "Display text (1-5 words, concise)" + }, + "description": { + "type": "string", + "description": "Explanation of choice" + } + }, + "required": [ + "label", + "description" + ], + "additionalProperties": false + }, + "QuestionV2.Info": { + "type": "object", + "properties": { + "question": { + "type": "string", + "description": "Complete question" + }, + "header": { + "type": "string", + "description": "Very short label (max 30 chars)" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2.Option" + }, + "description": "Available choices" + }, + "multiple": { + "type": "boolean" + }, + "custom": { + "type": "boolean" + } + }, + "required": [ + "question", + "header", + "options" + ], + "additionalProperties": false + }, + "QuestionV2.Tool": { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "callID": { + "type": "string" + } + }, + "required": [ + "messageID", + "callID" + ], + "additionalProperties": false + }, + "question.v2.asked": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "question.v2.asked" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^que" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2.Info" + }, + "description": "Questions to ask" + }, + "tool": { + "$ref": "#/components/schemas/QuestionV2.Tool" + } + }, + "required": [ + "id", + "sessionID", + "questions" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "QuestionV2.Answer": { + "type": "array", + "items": { + "type": "string" + } + }, + "question.v2.replied": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "question.v2.replied" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "requestID": { + "type": "string", + "allOf": [ + { + "pattern": "^que" + } + ] + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2.Answer" + } + } + }, + "required": [ + "sessionID", + "requestID", + "answers" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "question.v2.rejected": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "question.v2.rejected" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "requestID": { + "type": "string", + "allOf": [ + { + "pattern": "^que" + } + ] + } + }, + "required": [ + "sessionID", + "requestID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "Form.Metadata1": { + "type": "object" + }, + "Form.When1": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "op": { + "type": "string", + "enum": [ + "eq", + "neq" + ] + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "boolean" + } + ] + } + }, + "required": [ + "key", + "op", + "value" + ], + "additionalProperties": false + }, + "Form.StringField1": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "when": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.When1" + } + }, + "type": { + "type": "string", + "enum": [ + "string" + ] + }, + "format": { + "type": "string", + "enum": [ + "email", + "uri", + "date", + "date-time" + ] + }, + "minLength": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "maxLength": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "pattern": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "default": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.Option" + } + }, + "custom": { + "type": "boolean" + } + }, + "required": [ + "key", + "type" + ], + "additionalProperties": false + }, + "Form.NumberField1": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "when": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.When1" + } + }, + "type": { + "type": "string", + "enum": [ + "number" + ] + }, + "minimum": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + "maximum": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + "default": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + } + }, + "required": [ + "key", + "type" + ], + "additionalProperties": false + }, + "Form.IntegerField1": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "when": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.When1" + } + }, + "type": { + "type": "string", + "enum": [ + "integer" + ] + }, + "minimum": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + "maximum": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + "default": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + } + }, + "required": [ + "key", + "type" + ], + "additionalProperties": false + }, + "Form.BooleanField1": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "when": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.When1" + } + }, + "type": { + "type": "string", + "enum": [ + "boolean" + ] + }, + "default": { + "type": "boolean" + } + }, + "required": [ + "key", + "type" + ], + "additionalProperties": false + }, + "Form.MultiselectField1": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "when": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.When1" + } + }, + "type": { + "type": "string", + "enum": [ + "multiselect" + ] + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.Option" + } + }, + "minItems": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "maxItems": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "custom": { + "type": "boolean" + }, + "default": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "key", + "type", + "options" + ], + "additionalProperties": false + }, + "Form.FormInfo1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + "sessionID": { + "type": "string" + }, + "title": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Form.Metadata1" + }, + "mode": { + "type": "string", + "enum": [ + "form" + ] + }, + "fields": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Form.StringField1" + }, + { + "$ref": "#/components/schemas/Form.NumberField1" + }, + { + "$ref": "#/components/schemas/Form.IntegerField1" + }, + { + "$ref": "#/components/schemas/Form.BooleanField1" + }, + { + "$ref": "#/components/schemas/Form.MultiselectField1" + } + ] + } + } + }, + "required": [ + "id", + "sessionID", + "mode", + "fields" + ], + "additionalProperties": false + }, + "Form.UrlInfo1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + "sessionID": { + "type": "string" + }, + "title": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Form.Metadata1" + }, + "mode": { + "type": "string", + "enum": [ + "url" + ] + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "sessionID", + "mode", + "url" + ], + "additionalProperties": false + }, + "form.created": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "form.created" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "form": { + "anyOf": [ + { + "$ref": "#/components/schemas/Form.FormInfo1" + }, + { + "$ref": "#/components/schemas/Form.UrlInfo1" + } + ] + } + }, + "required": [ + "form" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "Form.Value1": { + "anyOf": [ + { + "type": "string" + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "Form.Answer1": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Form.Value1" + } + }, + "form.replied": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "form.replied" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + "sessionID": { + "type": "string" + }, + "answer": { + "$ref": "#/components/schemas/Form.Answer1" + } + }, + "required": [ + "id", + "sessionID", + "answer" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "form.cancelled": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "form.cancelled" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + "sessionID": { + "type": "string" + } + }, + "required": [ + "id", + "sessionID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "Todo": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "Brief description of the task" + }, + "status": { + "type": "string", + "description": "Current status of the task: pending, in_progress, completed, cancelled" + }, + "priority": { + "type": "string", + "description": "Priority level of the task: high, medium, low" + } + }, + "required": [ + "content", + "status", + "priority" + ], + "additionalProperties": false + }, + "todo.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "todo.updated" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "todos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "required": [ + "sessionID", + "todos" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "SessionStatus": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "idle" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "retry" + ] + }, + "attempt": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "message": { + "type": "string" + }, + "action": { + "type": "object", + "properties": { + "reason": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "label": { + "type": "string" + }, + "link": { + "type": "string" + } + }, + "required": [ + "reason", + "provider", + "title", + "message", + "label" + ], + "additionalProperties": false + }, + "next": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "type", + "attempt", + "message", + "next" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "busy" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, + "session.status": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.status" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "status": { + "$ref": "#/components/schemas/SessionStatus" + } + }, + "required": [ + "sessionID", + "status" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.idle": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.idle" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "tui.prompt.append": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "tui.prompt.append" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "tui.command.execute": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "tui.command.execute" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "command": { + "anyOf": [ + { + "type": "string", + "enum": [ + "session.list", + "session.new", + "session.share", + "session.interrupt", + "session.background", + "session.compact", + "session.page.up", + "session.page.down", + "session.line.up", + "session.line.down", + "session.half.page.up", + "session.half.page.down", + "session.first", + "session.last", + "prompt.clear", + "prompt.submit", + "agent.cycle" + ] + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "command" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "tui.toast.show": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "tui.toast.show" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": [ + "info", + "success", + "warning", + "error" + ] + }, + "duration": { + "anyOf": [ + { + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "message", + "variant" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "tui.session.select": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "tui.session.select" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses", + "description": "Session ID to navigate to" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "installation.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "installation.updated" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": [ + "version" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "installation.update-available": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "installation.update-available" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": [ + "version" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "vcs.branch.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "vcs.branch.updated" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "branch": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "mcp.status.changed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "mcp.status.changed" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "server": { + "type": "string" + } + }, + "required": [ + "server" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "permission.asked": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "permission.asked" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^per" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "permission": { + "type": "string" + }, + "patterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object" + }, + "always": { + "type": "array", + "items": { + "type": "string" + } + }, + "tool": { + "anyOf": [ + { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "callID": { + "type": "string" + } + }, + "required": [ + "messageID", + "callID" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "sessionID", + "permission", + "patterns", + "metadata", + "always" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "permission.replied": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "permission.replied" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "requestID": { + "type": "string", + "allOf": [ + { + "pattern": "^per" + } + ] + }, + "reply": { + "type": "string", + "enum": [ + "once", + "always", + "reject" + ] + } + }, + "required": [ + "sessionID", + "requestID", + "reply" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "QuestionOption": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "Display text (1-5 words, concise)" + }, + "description": { + "type": "string", + "description": "Explanation of choice" + } + }, + "required": [ + "label", + "description" + ], + "additionalProperties": false + }, + "QuestionInfo": { + "type": "object", + "properties": { + "question": { + "type": "string", + "description": "Complete question" + }, + "header": { + "type": "string", + "description": "Very short label (max 30 chars)" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionOption" + }, + "description": "Available choices" + }, + "multiple": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Allow selecting multiple choices" + }, + "custom": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Allow typing a custom answer (default: true)" + } + }, + "required": [ + "question", + "header", + "options" + ], + "additionalProperties": false + }, + "QuestionTool": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "callID": { + "type": "string" + } + }, + "required": [ + "messageID", + "callID" + ], + "additionalProperties": false + }, + "question.asked": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "question.asked" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^que" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionInfo" + }, + "description": "Questions to ask" + }, + "tool": { + "anyOf": [ + { + "$ref": "#/components/schemas/QuestionTool" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "sessionID", + "questions" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "QuestionAnswer": { + "type": "array", + "items": { + "type": "string" + } + }, + "question.replied": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "question.replied" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "requestID": { + "type": "string", + "allOf": [ + { + "pattern": "^que" + } + ] + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionAnswer" + } + } + }, + "required": [ + "sessionID", + "requestID", + "answers" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "question.rejected": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "question.rejected" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "requestID": { + "type": "string", + "allOf": [ + { + "pattern": "^que" + } + ] + } + }, + "required": [ + "sessionID", + "requestID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "session.error": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.error" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + { + "type": "null" + } + ] + }, + "error": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/components/schemas/ProviderAuthError" + }, + { + "$ref": "#/components/schemas/UnknownError1" + }, + { + "$ref": "#/components/schemas/MessageOutputLengthError" + }, + { + "$ref": "#/components/schemas/MessageAbortedError" + }, + { + "$ref": "#/components/schemas/StructuredOutputError" + }, + { + "$ref": "#/components/schemas/ContextOverflowError" + }, + { + "$ref": "#/components/schemas/ContentFilterError" + }, + { + "$ref": "#/components/schemas/APIError" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "V2Event.server.connected": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + }, + "durable": { + "anyOf": [ + { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "location": { + "anyOf": [ + { + "$ref": "#/components/schemas/Location.Ref" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "server.connected" + ] + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "V2Event": { + "anyOf": [ + { + "$ref": "#/components/schemas/models-dev.refreshed" + }, + { + "$ref": "#/components/schemas/integration.updated" + }, + { + "$ref": "#/components/schemas/integration.connection.updated" + }, + { + "$ref": "#/components/schemas/catalog.updated" + }, + { + "$ref": "#/components/schemas/agent.updated" + }, + { + "$ref": "#/components/schemas/session.created" + }, + { + "$ref": "#/components/schemas/session.updated" + }, + { + "$ref": "#/components/schemas/session.deleted" + }, + { + "$ref": "#/components/schemas/message.updated" + }, + { + "$ref": "#/components/schemas/message.removed" + }, + { + "$ref": "#/components/schemas/message.part.updated" + }, + { + "$ref": "#/components/schemas/message.part.removed" + }, + { + "$ref": "#/components/schemas/session.next.agent.switched" + }, + { + "$ref": "#/components/schemas/session.next.model.switched" + }, + { + "$ref": "#/components/schemas/session.next.moved" + }, + { + "$ref": "#/components/schemas/session.next.renamed" + }, + { + "$ref": "#/components/schemas/session.next.forked" + }, + { + "$ref": "#/components/schemas/session.next.prompted" + }, + { + "$ref": "#/components/schemas/session.next.prompt.admitted" + }, + { + "$ref": "#/components/schemas/session.next.execution.settled" + }, + { + "$ref": "#/components/schemas/session.next.context.updated" + }, + { + "$ref": "#/components/schemas/session.next.synthetic" + }, + { + "$ref": "#/components/schemas/session.next.skill.activated" + }, + { + "$ref": "#/components/schemas/session.next.shell.started" + }, + { + "$ref": "#/components/schemas/session.next.shell.ended" + }, + { + "$ref": "#/components/schemas/session.next.step.started" + }, + { + "$ref": "#/components/schemas/session.next.step.ended" + }, + { + "$ref": "#/components/schemas/session.next.step.failed" + }, + { + "$ref": "#/components/schemas/session.next.text.started" + }, + { + "$ref": "#/components/schemas/session.next.text.delta" + }, + { + "$ref": "#/components/schemas/session.next.text.ended" + }, + { + "$ref": "#/components/schemas/session.next.reasoning.started" + }, + { + "$ref": "#/components/schemas/session.next.reasoning.delta" + }, + { + "$ref": "#/components/schemas/session.next.reasoning.ended" + }, + { + "$ref": "#/components/schemas/session.next.tool.input.started" + }, + { + "$ref": "#/components/schemas/session.next.tool.input.delta" + }, + { + "$ref": "#/components/schemas/session.next.tool.input.ended" + }, + { + "$ref": "#/components/schemas/session.next.tool.called" + }, + { + "$ref": "#/components/schemas/session.next.tool.progress" + }, + { + "$ref": "#/components/schemas/session.next.tool.success" + }, + { + "$ref": "#/components/schemas/session.next.tool.failed" + }, + { + "$ref": "#/components/schemas/session.next.retried" + }, + { + "$ref": "#/components/schemas/session.next.compaction.started" + }, + { + "$ref": "#/components/schemas/session.next.compaction.delta" + }, + { + "$ref": "#/components/schemas/session.next.compaction.ended" + }, + { + "$ref": "#/components/schemas/session.next.revert.staged" + }, + { + "$ref": "#/components/schemas/session.next.revert.cleared" + }, + { + "$ref": "#/components/schemas/session.next.revert.committed" + }, + { + "$ref": "#/components/schemas/file.edited" + }, + { + "$ref": "#/components/schemas/reference.updated" + }, + { + "$ref": "#/components/schemas/permission.v2.asked" + }, + { + "$ref": "#/components/schemas/permission.v2.replied" + }, + { + "$ref": "#/components/schemas/plugin.added" + }, + { + "$ref": "#/components/schemas/project.directories.updated" + }, + { + "$ref": "#/components/schemas/command.updated" + }, + { + "$ref": "#/components/schemas/skill.updated" + }, + { + "$ref": "#/components/schemas/file.watcher.updated" + }, + { + "$ref": "#/components/schemas/pty.created" + }, + { + "$ref": "#/components/schemas/pty.updated" + }, + { + "$ref": "#/components/schemas/pty.exited" + }, + { + "$ref": "#/components/schemas/pty.deleted" + }, + { + "$ref": "#/components/schemas/shell.created" + }, + { + "$ref": "#/components/schemas/shell.exited" + }, + { + "$ref": "#/components/schemas/shell.deleted" + }, + { + "$ref": "#/components/schemas/question.v2.asked" + }, + { + "$ref": "#/components/schemas/question.v2.replied" + }, + { + "$ref": "#/components/schemas/question.v2.rejected" + }, + { + "$ref": "#/components/schemas/form.created" + }, + { + "$ref": "#/components/schemas/form.replied" + }, + { + "$ref": "#/components/schemas/form.cancelled" + }, + { + "$ref": "#/components/schemas/todo.updated" + }, + { + "$ref": "#/components/schemas/session.status" + }, + { + "$ref": "#/components/schemas/session.idle" + }, + { + "$ref": "#/components/schemas/tui.prompt.append" + }, + { + "$ref": "#/components/schemas/tui.command.execute" + }, + { + "$ref": "#/components/schemas/tui.toast.show" + }, + { + "$ref": "#/components/schemas/tui.session.select" + }, + { + "$ref": "#/components/schemas/installation.updated" + }, + { + "$ref": "#/components/schemas/installation.update-available" + }, + { + "$ref": "#/components/schemas/vcs.branch.updated" + }, + { + "$ref": "#/components/schemas/mcp.status.changed" + }, + { + "$ref": "#/components/schemas/permission.asked" + }, + { + "$ref": "#/components/schemas/permission.replied" + }, + { + "$ref": "#/components/schemas/question.asked" + }, + { + "$ref": "#/components/schemas/question.replied" + }, + { + "$ref": "#/components/schemas/question.rejected" + }, + { + "$ref": "#/components/schemas/session.error" + }, + { + "$ref": "#/components/schemas/V2Event.server.connected" + } + ] + }, + "V2EventStream": { + "type": "string", + "contentSchema": { + "$ref": "#/components/schemas/V2Event" + }, + "contentMediaType": "application/json" + }, + "EventLog.Hint": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "log.hint" + ] + }, + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "type", + "aggregateID", + "seq" + ], + "additionalProperties": false, + "description": "Payload-free change hint: the aggregate's durable log advanced to at least seq. Hints coalesce under backpressure (latest per aggregate) and are never a delivery guarantee." + }, + "EventLog.SweepRequired": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "log.sweep_required" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false, + "description": "Hints may have been lost; treat every aggregate as potentially dirty and recover via bounded sweep plus durable log reads. Emitted first on every (re)subscribe." + }, + "EventLog.Change": { + "anyOf": [ + { + "$ref": "#/components/schemas/EventLog.Hint" + }, + { + "$ref": "#/components/schemas/EventLog.SweepRequired" + } + ] + }, + "EventLog.ChangeStream": { + "type": "string", + "contentSchema": { + "$ref": "#/components/schemas/EventLog.Change" + }, + "contentMediaType": "application/json" + }, + "PtyNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "PtyNotFoundError" + ] + }, + "ptyID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "ptyID", + "message" + ], + "additionalProperties": false + }, + "PtyTicket.ConnectToken": { + "type": "object", + "properties": { + "ticket": { + "type": "string" + }, + "expires_in": { + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] + } + }, + "required": [ + "ticket", + "expires_in" + ], + "additionalProperties": false + }, + "ForbiddenError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ForbiddenError" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "Shell1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "running", + "exited", + "timeout", + "killed" + ] + }, + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "shell": { + "type": "string" + }, + "file": { + "type": "string" + }, + "pid": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "exit": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "started": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "completed": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "started" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "status", + "command", + "cwd", + "shell", + "file", + "metadata", + "time" + ], + "additionalProperties": false + }, + "ShellNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ShellNotFoundError" + ] + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "id", + "message" + ], + "additionalProperties": false + }, + "QuestionV2.Request": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^que" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2.Info" + }, + "description": "Questions to ask" + }, + "tool": { + "$ref": "#/components/schemas/QuestionV2.Tool" + } + }, + "required": [ + "id", + "sessionID", + "questions" + ], + "additionalProperties": false + }, + "QuestionV2.Reply": { + "type": "object", + "properties": { + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2.Answer" + }, + "description": "User answers in order of questions (each answer is an array of selected labels)" + } + }, + "required": [ + "answers" + ], + "additionalProperties": false + }, + "QuestionNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "QuestionNotFoundError" + ] + }, + "requestID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "requestID", + "message" + ], + "additionalProperties": false + }, + "Reference.LocalSource": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "local" + ] + }, + "path": { + "type": "string" + }, + "description": { + "type": "string" + }, + "hidden": { + "type": "boolean" + } + }, + "required": [ + "type", + "path" + ], + "additionalProperties": false + }, + "Reference.GitSource": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "git" + ] + }, + "repository": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "description": { + "type": "string" + }, + "hidden": { + "type": "boolean" + } + }, + "required": [ + "type", + "repository" + ], + "additionalProperties": false + }, + "Reference.Source": { + "anyOf": [ + { + "$ref": "#/components/schemas/Reference.LocalSource" + }, + { + "$ref": "#/components/schemas/Reference.GitSource" + } + ] + }, + "Reference.Info": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "description": { + "type": "string" + }, + "hidden": { + "type": "boolean" + }, + "source": { + "$ref": "#/components/schemas/Reference.Source" + } + }, + "required": [ + "name", + "path", + "source" + ], + "additionalProperties": false + }, + "ProjectCopy.Copy": { + "type": "object", + "properties": { + "directory": { + "type": "string" + } + }, + "required": [ + "directory" + ], + "additionalProperties": false + }, + "ProjectCopyError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "ProjectCopyError" + ] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "forceRequired": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "message" + ], + "additionalProperties": false + } + }, + "required": [ + "name", + "data" + ], + "additionalProperties": false + }, + "Vcs.FileStatus": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "additions": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "deletions": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "status": { + "type": "string", + "enum": [ + "added", + "deleted", + "modified" + ] + } + }, + "required": [ + "file", + "additions", + "deletions", + "status" + ], + "additionalProperties": false + }, + "Vcs.Mode": { + "type": "string", + "enum": [ + "working", + "branch" + ] + } + }, + "securitySchemes": {} + }, + "security": [], + "tags": [ + { + "name": "server.health" + }, + { + "name": "server.location" + }, + { + "name": "server.agent" + }, + { + "name": "plugins", + "description": "Experimental plugin routes." + }, + { + "name": "sessions", + "description": "Experimental session routes." + }, + { + "name": "messages", + "description": "Experimental message routes." + }, + { + "name": "models", + "description": "Experimental model routes." + }, + { + "name": "generate", + "description": "Experimental one-shot generation routes." + }, + { + "name": "providers", + "description": "Experimental provider routes." + }, + { + "name": "integrations", + "description": "Integration discovery and authentication routes." + }, + { + "name": "mcp", + "description": "MCP server status routes." + }, + { + "name": "server.credential" + }, + { + "name": "projects", + "description": "Location-scoped project routes." + }, + { + "name": "forms", + "description": "Session form routes." + }, + { + "name": "permissions", + "description": "Experimental permission routes." + }, + { + "name": "filesystem", + "description": "Experimental location-scoped filesystem routes." + }, + { + "name": "commands", + "description": "Experimental command routes." + }, + { + "name": "skills", + "description": "Experimental skill routes." + }, + { + "name": "events", + "description": "Experimental event stream routes." + }, + { + "name": "pty", + "description": "Experimental location-scoped PTY routes." + }, + { + "name": "shell", + "description": "Experimental location-scoped shell command routes." + }, + { + "name": "session questions", + "description": "Experimental session question routes." + }, + { + "name": "reference", + "description": "Location-scoped project references." + }, + { + "name": "projectCopy", + "description": "Project copy management routes." + }, + { + "name": "vcs", + "description": "Location-scoped version control routes." + } + ] +} diff --git a/packages/codemode/test/openapi.test.ts b/packages/codemode/test/openapi.test.ts new file mode 100644 index 0000000000..a50460e8da --- /dev/null +++ b/packages/codemode/test/openapi.test.ts @@ -0,0 +1,958 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Layer, Option } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { CodeMode, OpenAPI, Tool } from "../src/index.js" +import { inputTypeScript, outputTypeScript } from "../src/tool-schema.js" + +const baseUrl = "http://localhost:4096" +type Document = OpenAPI.Document + +type Recorded = { + readonly method: string + readonly url: string + readonly headers: Record + readonly body: unknown +} + +const opencodeSpec = async (): Promise => { + return Bun.file(new URL("./fixtures/opencode-v2-openapi.json", import.meta.url)).json() as Promise +} + +const happyPathSpec = async (): Promise => { + return Bun.file(new URL("./fixtures/openapi-happy-path.json", import.meta.url)).json() as Promise +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +const toolAt = (tools: unknown, name: string) => + name.split(".").reduce((current, segment) => (isRecord(current) ? current[segment] : undefined), tools) + +const recordingClient = (respond: (request: HttpClientRequest.HttpClientRequest) => Response) => { + const requests: Array = [] + const layer = Layer.succeed(HttpClient.HttpClient)( + HttpClient.make((request) => + Effect.gen(function* () { + const body = + request.body._tag === "Uint8Array" ? JSON.parse(new TextDecoder().decode(request.body.body)) : undefined + const url = Option.map(HttpClientRequest.toUrl(request), (resolved) => resolved.toString()) + requests.push({ + method: request.method, + url: Option.getOrElse(url, () => request.url), + headers: { ...request.headers }, + body, + }) + return HttpClientResponse.fromWeb(request, respond(request)) + }), + ), + ) + return { requests, layer } +} + +const json = (value: unknown, status = 200) => + new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }) + +const singleOperation = (operation: Record, method = "get"): Document => ({ + openapi: "3.1.0", + paths: { "/test": { [method]: { operationId: "test", responses: { 200: { description: "Success" } }, ...operation } } }, +}) + +describe("OpenAPI.fromSpec", () => { + test("covers a representative API from generation through execution", async () => { + const resolutions: Array = [] + const client = recordingClient((request) => { + const url = Option.getOrElse(HttpClientRequest.toUrl(request), () => new URL(request.url)) + if (request.method === "POST") { + return new Response( + JSON.stringify({ id: "user-2", name: "Grace", email: "grace@example.test", role: "admin" }), + { status: 201, headers: { "content-type": "application/vnd.example+json" } }, + ) + } + if (request.method === "DELETE") return new Response(null, { status: 204 }) + if (url.pathname === "/search") { + return new Response("2 matches", { headers: { "content-type": "text/plain" } }) + } + return json({ id: "user-1", name: "Ada", email: "ada@example.test", role: "member" }) + }) + const api = OpenAPI.fromSpec({ + spec: await happyPathSpec(), + baseUrl, + auth: { + resolve: ({ name }) => { + resolutions.push(name) + return Effect.succeed( + name === "BearerAuth" + ? { type: "bearer", token: "bearer-secret" } + : { type: "apiKey", value: "api-secret" }, + ) + }, + }, + }) + const get = toolAt(api.tools, "users.get") + const create = toolAt(api.tools, "users.create") + const search = toolAt(api.tools, "search.run") + const remove = toolAt(api.tools, "users.remove") + + expect(api.skipped).toEqual([]) + if (!Tool.isDefinition(get) || !Tool.isDefinition(create) || !Tool.isDefinition(search) || !Tool.isDefinition(remove)) { + throw new Error("happy-path fixture did not generate every operation") + } + expect(inputTypeScript(get)).toBe( + '{ userId: string; include?: Array; verbose?: boolean; "X-Trace-ID"?: string }', + ) + expect(inputTypeScript(create)).toBe('{ name: string; email: string; role?: "admin" | "member" }') + expect(inputTypeScript(search)).toBe("{ filter?: { query: string; page?: number }; tags?: Array }") + expect(inputTypeScript(remove)).toBe("{ userId: string }") + expect(outputTypeScript(get)).toContain("id: string") + expect(outputTypeScript(create)).toContain('role?: "admin" | "member"') + expect(outputTypeScript(search)).toBe("string") + expect(outputTypeScript(remove)).toBe("null") + + const result = await Effect.runPromise( + CodeMode.make({ tools: { api: api.tools } }) + .execute(` + const user = await tools.api.users.get({ + userId: "user-1", + include: ["profile", "permissions"], + verbose: true, + "X-Trace-ID": "trace-1", + }) + const created = await tools.api.users.create({ + name: "Grace", + email: "grace@example.test", + role: "admin", + }) + const summary = await tools.api.search.run({ + filter: { query: "effect", page: 2 }, + tags: ["typescript", "runtime"], + }) + const removed = await tools.api.users.remove({ userId: "user-1" }) + return { user, created, summary, removed } + `) + .pipe(Effect.provide(client.layer)), + ) + + expect(result).toMatchObject({ + ok: true, + value: { + user: { id: "user-1", name: "Ada" }, + created: { id: "user-2", name: "Grace" }, + summary: "2 matches", + removed: null, + }, + }) + expect(resolutions).toEqual(["BearerAuth", "ApiKey", "BearerAuth"]) + expect(client.requests).toHaveLength(4) + + const getUrl = new URL(client.requests[0]!.url) + expect(getUrl.pathname).toBe("/users/user-1") + expect(getUrl.searchParams.get("include")).toBe("profile,permissions") + expect(getUrl.searchParams.get("verbose")).toBe("true") + expect(client.requests[0]!.headers["x-trace-id"]).toBe("trace-1") + expect(client.requests[0]!.headers.authorization).toBe("Bearer bearer-secret") + + const createUrl = new URL(client.requests[1]!.url) + expect(createUrl.searchParams.get("api_key")).toBe("api-secret") + expect(client.requests[1]!.body).toEqual({ name: "Grace", email: "grace@example.test", role: "admin" }) + + const searchUrl = new URL(client.requests[2]!.url) + expect(searchUrl.searchParams.get("filter[query]")).toBe("effect") + expect(searchUrl.searchParams.get("filter[page]")).toBe("2") + expect(searchUrl.searchParams.getAll("tags")).toEqual(["typescript", "runtime"]) + expect(client.requests[2]!.headers.authorization).toBeUndefined() + expect(new URL(client.requests[3]!.url).pathname).toBe("/users/user-1") + expect(client.requests[3]!.headers.authorization).toBe("Bearer bearer-secret") + }) + + test("converts representative opencode operations into the expected tool shape", async () => { + const spec = await opencodeSpec() + const result = OpenAPI.fromSpec({ spec, baseUrl }) + + expect(result.skipped).toHaveLength(5) + expect(result.skipped).toContainEqual({ + method: "GET", + path: "/api/pty/{ptyID}/connect", + reason: "WebSocket operations are not supported", + }) + expect(result.skipped.filter((item) => item.reason === "SSE operations are not supported")).toHaveLength(3) + expect(result.skipped).toContainEqual({ + method: "GET", + path: "/api/fs/read/*", + reason: "binary responses are not supported", + }) + expect(toolAt(result.tools, "v2.health.get")).not.toBeUndefined() + expect(toolAt(result.tools, "v2.session.get")).not.toBeUndefined() + expect(toolAt(result.tools, "v2.session.create")).not.toBeUndefined() + + const sessionGet = toolAt(result.tools, "v2.session.get") + expect(Tool.isDefinition(sessionGet)).toBe(true) + if (!Tool.isDefinition(sessionGet)) throw new Error("v2.session.get was not generated") + expect(inputTypeScript(sessionGet)).toBe("{ sessionID: string }") + expect(outputTypeScript(sessionGet)).toContain("id: string") + expect(outputTypeScript(sessionGet)).toContain("additions: number") + + const switchAgent = toolAt(result.tools, "v2.session.switchAgent") + expect(Tool.isDefinition(switchAgent)).toBe(true) + if (!Tool.isDefinition(switchAgent)) throw new Error("v2.session.switchAgent was not generated") + expect(inputTypeScript(switchAgent)).toBe("{ sessionID: string; agent: string }") + + const contextEntryPut = toolAt(result.tools, "v2.session.contextEntry.put") + expect(Tool.isDefinition(contextEntryPut)).toBe(true) + if (!Tool.isDefinition(contextEntryPut)) throw new Error("v2.session.contextEntry.put was not generated") + expect(inputTypeScript(contextEntryPut)).toBe("{ sessionID: string; key: string; value: unknown }") + expect(toolAt(result.tools, "v2_session_context_entry_put_2")).toBeUndefined() + expect(toolAt(result.tools, "v2.pty.connect")).toBeUndefined() + expect(toolAt(result.tools, "v2.session.log")).toBeUndefined() + expect(toolAt(result.tools, "v2.event.subscribe")).toBeUndefined() + expect(toolAt(result.tools, "v2.event.changes")).toBeUndefined() + expect(toolAt(result.tools, "v2.fs.read")).toBeUndefined() + expect(toolAt(result.tools, "v2.pty.connectToken")).not.toBeUndefined() + }) + + test("preserves operation path sanitization and collision handling", () => { + const response = { responses: { 200: { description: "Success" } } } + const result = OpenAPI.fromSpec({ + baseUrl, + spec: { + openapi: "3.1.0", + paths: { + "/first": { get: { ...response, operationId: "group.item" } }, + "/second": { get: { ...response, operationId: "group.item" } }, + "/third": { get: { ...response, operationId: "group..other" } }, + }, + }, + }) + + expect(Tool.isDefinition(toolAt(result.tools, "group.item"))).toBe(true) + expect(Tool.isDefinition(toolAt(result.tools, "group_item_2"))).toBe(true) + expect(Tool.isDefinition(toolAt(result.tools, "group.operation.other"))).toBe(true) + }) + + test("synthesizes flat operation IDs from methods and paths", () => { + const response = { responses: { 200: { description: "Success" } } } + const tools = OpenAPI.fromSpec({ + baseUrl, + spec: { + openapi: "3.1.0", + paths: { + "/users": { get: response, post: response }, + "/users/{id}": { get: response, patch: response, delete: response }, + "/organizations/{organizationId}/users/{id}": { get: response }, + }, + }, + }).tools + + for (const path of [ + "getUsers", + "postUsers", + "getUsersById", + "patchUsersById", + "deleteUsersById", + "getOrganizationsByOrganizationidUsersById", + ]) { + expect(Tool.isDefinition(toolAt(tools, path))).toBe(true) + } + }) + + test("lets operation parameters override matching path parameters", () => { + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: { + openapi: "3.1.0", + paths: { + "/test": { + parameters: [{ name: "limit", in: "query", schema: { type: "string" } }], + get: { + operationId: "test", + parameters: [{ name: "limit", in: "query", required: true, schema: { type: "number" } }], + responses: { 200: { description: "Success" } }, + }, + }, + }, + }, + }).tools, + "test", + ) + + if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + expect(inputTypeScript(tool)).toBe("{ limit: number }") + }) + + test("normalizes OpenAPI 3.0 schemas with Effect", () => { + const result = OpenAPI.fromSpec({ + baseUrl, + spec: { + openapi: "3.0.3", + paths: { + "/search": { + get: { + operationId: "search", + parameters: [ + { + in: "query", + name: "value", + schema: { type: "string", nullable: true, minLength: 2 }, + }, + ], + responses: { 200: { description: "Success" } }, + }, + }, + }, + }, + }) + const search = toolAt(result.tools, "search") + + expect(Tool.isDefinition(search)).toBe(true) + if (!Tool.isDefinition(search)) throw new Error("search was not generated") + expect(inputTypeScript(search)).toBe("{ value?: string | null }") + const schema: unknown = search.input + const input = isRecord(schema) ? schema : {} + const properties = isRecord(input.properties) ? input.properties : {} + const value = isRecord(properties.value) ? properties.value : {} + expect(value.minLength).toBe(2) + }) + + test("preserves schema-local definitions alongside component definitions", () => { + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: { + openapi: "3.1.0", + paths: { + "/test": { + get: { + operationId: "test", + responses: { + 200: { + description: "Success", + content: { + "application/json": { + schema: { $ref: "#/$defs/Local", $defs: { Local: { type: "string" } } }, + }, + }, + }, + }, + }, + }, + }, + components: { schemas: { Global: { type: "number" } } }, + }, + }).tools, + "test", + ) + + if (!Tool.isDefinition(tool) || !isRecord(tool.output)) throw new Error("test output was not generated") + expect(tool.output.$defs).toMatchObject({ Local: { type: "string" }, Global: { type: "number" } }) + }) + + test("documents that the opencode fixture is unauthenticated", async () => { + const spec = await opencodeSpec() + const components = isRecord(spec.components) ? spec.components : {} + const result = OpenAPI.fromSpec({ spec, baseUrl }) + + expect(spec.security).toStrictEqual([]) + expect(isRecord(components.securitySchemes) ? Object.keys(components.securitySchemes) : []).toStrictEqual([]) + const health = toolAt(result.tools, "v2.health.get") + const healthInput = isRecord(health) ? health.input : undefined + expect(healthInput).toMatchObject({ type: "object", properties: {} }) + const input = isRecord(healthInput) ? healthInput : {} + expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual([]) + }) + + test("exposes real opencode operations through CodeMode discovery", async () => { + const { layer } = recordingClient(() => json({})) + const runtime = CodeMode.make({ + tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools }, + }) + const result = await Effect.runPromise( + runtime + .execute( + ` + return await tools.$codemode.search({ query: "global health", namespace: "opencode", limit: 1 }) + `, + ) + .pipe(Effect.provide(layer)), + ) + + expect(result).toMatchObject({ ok: true }) + if (!result.ok) return + expect(result.value).toMatchObject({ + items: [ + { + path: "tools.opencode.v2.health.get", + description: "Check whether the API server is ready to accept requests.", + }, + ], + }) + expect(JSON.stringify(result.value)).toContain("healthy: true") + }) + + test("invokes real opencode path parameters and JSON request bodies", async () => { + const { requests, layer } = recordingClient((request) => { + if (request.method === "GET") return json({ id: "ses_123" }) + return json({ id: "ses_456" }) + }) + const runtime = CodeMode.make({ + tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools }, + }) + + const result = await Effect.runPromise( + runtime + .execute( + ` + const existing = await tools.opencode.v2.session.get({ sessionID: "ses_123" }) + const created = await tools.opencode.v2.session.create({ id: "ses_456" }) + return { existing, created } + `, + ) + .pipe(Effect.provide(layer)), + ) + + expect(result).toMatchObject({ ok: true }) + expect(requests).toHaveLength(2) + expect(requests[0]).toMatchObject({ method: "GET", body: undefined }) + expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_123") + expect(requests[1]).toMatchObject({ + method: "POST", + url: "http://localhost:4096/api/session", + body: { id: "ses_456" }, + }) + }) + + test("serializes deep-object query parameters from the opencode fixture", async () => { + const client = recordingClient(() => json({ directory: "/tmp" })) + const location = toolAt(OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools, "v2.location.get") + if (!Tool.isDefinition(location)) throw new Error("v2.location.get was not generated") + + await Effect.runPromise( + location.run({ location: { directory: "/tmp", workspace: "workspace-1" } }).pipe(Effect.provide(client.layer)), + ) + + const url = new URL(client.requests[0]!.url) + expect(url.searchParams.get("location[directory]")).toBe("/tmp") + expect(url.searchParams.get("location[workspace]")).toBe("workspace-1") + }) + + test("serializes supported simple and form parameter shapes", async () => { + const client = recordingClient(() => json({ ok: true })) + const result = OpenAPI.fromSpec({ + baseUrl, + spec: { + openapi: "3.1.0", + paths: { + "/items/{keys}": { + get: { + operationId: "items", + parameters: [ + { name: "keys", in: "path", required: true, schema: { type: "array", items: { type: "string" } } }, + { name: "tags", in: "query", style: "form", explode: false, schema: { type: "array" } }, + { name: "filter", in: "query", style: "form", explode: true, schema: { type: "object" } }, + { name: "nullable", in: "query", required: true, schema: { type: ["string", "null"] } }, + { name: "constructor", in: "query", schema: { type: "string" } }, + { name: "meta", in: "header", style: "simple", explode: true, schema: { type: "object" } }, + ], + responses: { 200: { description: "Success" } }, + }, + }, + }, + }, + }) + const tool = toolAt(result.tools, "items") + if (!Tool.isDefinition(tool)) throw new Error("items was not generated") + + await Effect.runPromise( + tool + .run({ + keys: ["a!", "b*"], + tags: ["x", "y"], + filter: { state: "open", page: 2 }, + nullable: null, + constructor_2: "safe", + meta: { a: "b", c: "d" }, + }) + .pipe(Effect.provide(client.layer)), + ) + + const url = new URL(client.requests[0]!.url) + expect(url.pathname).toBe("/items/a%21,b%2A") + expect(url.searchParams.get("tags")).toBe("x,y") + expect(url.searchParams.get("state")).toBe("open") + expect(url.searchParams.get("page")).toBe("2") + expect(url.searchParams.get("nullable")).toBe("null") + expect(url.searchParams.get("constructor")).toBe("safe") + expect(client.requests[0]!.headers.meta).toBe("a=b,c=d") + await expect( + Effect.runPromise(tool.run({ keys: [undefined] }).pipe(Effect.provide(client.layer))), + ).rejects.toThrow("unsupported nested value") + }) + + test("skips unsupported parameter encodings and malformed security", () => { + const result = OpenAPI.fromSpec({ + baseUrl, + spec: { + openapi: "3.1.0", + security: [{ bearer: [] }], + paths: { + "/cookie": { + get: { + operationId: "cookie", + parameters: [{ name: "session", in: "cookie", schema: { type: "string" } }], + responses: { 200: { description: "Success" } }, + }, + }, + "/reserved": { + get: { + operationId: "reserved", + parameters: [{ name: "query", in: "query", allowReserved: true, schema: { type: "string" } }], + responses: { 200: { description: "Success" } }, + }, + }, + "/invalid-style": { + get: { + operationId: "invalidStyle", + parameters: [{ name: "query", in: "query", style: 42, schema: { type: "string" } }], + responses: { 200: { description: "Success" } }, + }, + }, + "/security": { + get: { operationId: "security", security: null, responses: { 200: { description: "Success" } } }, + }, + }, + }, + }) + + expect(result.tools).toEqual({}) + expect(result.skipped.map((item) => item.reason)).toEqual([ + "cookie parameter 'session' is not supported", + "parameter 'query' uses unsupported allowReserved encoding", + "parameter 'query' has an invalid style", + "security declaration is not an array", + ]) + }) + + test("fails closed on prototype-named missing security schemes", () => { + const result = OpenAPI.fromSpec({ + baseUrl, + spec: singleOperation({ security: [JSON.parse('{"__proto__":[]}')] }), + }) + + expect(result.tools).toEqual({}) + expect(result.skipped[0]?.reason).toBe("security requirement references missing or malformed scheme: __proto__") + }) + + test("resolves bearer authentication without exposing it as input", async () => { + const contexts: Array[0]> = [] + const client = recordingClient(() => json({ ok: true })) + const spec = { + ...singleOperation({ operationId: undefined }), + security: [{ bearer: [] }], + components: { securitySchemes: { bearer: { type: "http", scheme: "bearer" } } }, + } satisfies Document + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec, + auth: { + resolve: (context) => { + contexts.push(context) + return Effect.succeed({ type: "bearer", token: "secret" }) + }, + }, + }).tools, + "getTest", + ) + if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + + await Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer))) + + expect(inputTypeScript(tool)).toBe("{}") + expect(client.requests[0]!.headers.authorization).toBe("Bearer secret") + expect(contexts).toEqual([ + { + name: "bearer", + definition: { type: "http", scheme: "bearer" }, + scopes: [], + operation: { + operationId: undefined, + method: "GET", + path: "/test", + summary: undefined, + description: undefined, + }, + }, + ]) + }) + + test("applies authentication carriers without prototype or collision loss", async () => { + const client = recordingClient(() => json({ ok: true })) + const authenticated = (security: ReadonlyArray>>, schemes: Record) => + OpenAPI.fromSpec({ + baseUrl, + spec: { ...singleOperation({}), security, components: { securitySchemes: schemes } }, + auth: { resolve: () => Effect.succeed({ type: "apiKey", value: "secret" }) }, + }) + const prototype = toolAt( + authenticated([{ key: [] }], { key: { type: "apiKey", in: "query", name: "__proto__" } }).tools, + "test", + ) + if (!Tool.isDefinition(prototype)) throw new Error("prototype auth tool was not generated") + + await Effect.runPromise(prototype.run({}).pipe(Effect.provide(client.layer))) + expect(new URL(client.requests[0]!.url).searchParams.get("__proto__")).toBe("secret") + + const duplicate = toolAt( + authenticated( + [{ first: [], second: [] }], + { + first: { type: "apiKey", in: "header", name: "x-key" }, + second: { type: "apiKey", in: "header", name: "x-key" }, + }, + ).tools, + "test", + ) + if (!Tool.isDefinition(duplicate)) throw new Error("duplicate auth tool was not generated") + await expect(Effect.runPromise(duplicate.run({}).pipe(Effect.provide(client.layer)))).rejects.toThrow( + "multiple credentials", + ) + + const cookie = authenticated([{ key: [] }], { key: { type: "apiKey", in: "cookie", name: "session" } }) + expect(cookie.tools).toEqual({}) + expect(cookie.skipped[0]?.reason).toBe("cookie authentication 'key' is not supported") + + const alternative = OpenAPI.fromSpec({ + baseUrl, + spec: { + ...singleOperation({}), + security: [{ cookie: [] }, { bearer: [] }], + components: { + securitySchemes: { + cookie: { type: "apiKey", in: "cookie", name: "session" }, + bearer: { type: "http", scheme: "bearer" }, + }, + }, + }, + auth: { + resolve: ({ name }) => + Effect.succeed(name === "bearer" ? { type: "bearer", token: "secret" } : undefined), + }, + }) + const alternativeTool = toolAt(alternative.tools, "test") + if (!Tool.isDefinition(alternativeTool)) throw new Error("supported auth alternative was not generated") + await Effect.runPromise(alternativeTool.run({}).pipe(Effect.provide(client.layer))) + expect(client.requests.at(-1)?.headers.authorization).toBe("Bearer secret") + }) + + test("honors server precedence and rejects ambiguous base URLs", async () => { + const client = recordingClient(() => json({ ok: true })) + const spec = { + ...singleOperation({ servers: [{ url: "https://operation.example/v1" }] }), + servers: [{ url: "https://document.example" }], + } satisfies Document + const tool = toolAt(OpenAPI.fromSpec({ spec }).tools, "test") + if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + + await Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer))) + expect(client.requests[0]?.url).toBe("https://operation.example/v1/test") + + const invalid = OpenAPI.fromSpec({ spec, baseUrl: "https://example.com/api?tenant=one" }) + expect(invalid.tools).toEqual({}) + expect(invalid.skipped[0]?.reason).toContain("unsupported query string or fragment") + + const malformed = OpenAPI.fromSpec({ spec, baseUrl: "https:/example.com" }) + expect(malformed.tools).toEqual({}) + expect(malformed.skipped[0]?.reason).toContain("not an absolute HTTP(S) URL") + }) + + test("resolves chained response refs before detecting unsupported transports", () => { + const result = OpenAPI.fromSpec({ + baseUrl, + spec: { + ...singleOperation({ responses: { 200: { $ref: "#/components/responses/First" } } }), + components: { + responses: { + First: { $ref: "#/components/responses/Stream" }, + Stream: { content: { "text/event-stream": { schema: { type: "string" } } } }, + }, + }, + }, + }) + + expect(result.tools).toEqual({}) + expect(result.skipped[0]?.reason).toBe("SSE operations are not supported") + }) + + test("resolves response schemas before detecting binary output", () => { + const result = OpenAPI.fromSpec({ + baseUrl, + spec: { + ...singleOperation({ + responses: { + 200: { + content: { "text/plain": { schema: { $ref: "#/components/schemas/File" } } }, + }, + }, + }), + components: { schemas: { File: { type: "string", format: "binary" } } }, + }, + }) + + expect(result.tools).toEqual({}) + expect(result.skipped[0]?.reason).toBe("binary responses are not supported") + }) + + test("validates composite parameters before resolving auth", async () => { + const resolutions: Array = [] + const client = recordingClient(() => json({ ok: true })) + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: { + ...singleOperation({ + parameters: [{ name: "filter", in: "query", style: "form", explode: true, schema: { type: "object" } }], + }), + security: [{ bearer: [] }], + components: { securitySchemes: { bearer: { type: "http", scheme: "bearer" } } }, + }, + auth: { + resolve: ({ name }) => { + resolutions.push(name) + return Effect.succeed({ type: "bearer", token: "secret" }) + }, + }, + }).tools, + "test", + ) + if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + + await expect( + Effect.runPromise(tool.run({ filter: { value: undefined } }).pipe(Effect.provide(client.layer))), + ).rejects.toThrow("unsupported nested value") + expect(resolutions).toEqual([]) + expect(client.requests).toEqual([]) + }) + + test("preserves JSON media types and rejects unencodable bodies", async () => { + const client = recordingClient(() => json({ ok: true })) + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: singleOperation( + { + requestBody: { + required: true, + content: { "application/merge-patch+json": { schema: { type: "object" } } }, + }, + }, + "post", + ), + }).tools, + "test", + ) + if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + + await Effect.runPromise(tool.run({ body: { name: "updated" } }).pipe(Effect.provide(client.layer))) + expect(client.requests[0]!.headers["content-type"]).toBe("application/merge-patch+json") + const cyclic: Record = {} + cyclic.self = cyclic + await expect(Effect.runPromise(tool.run({ body: cyclic }).pipe(Effect.provide(client.layer)))).rejects.toThrow( + "Invalid JSON body", + ) + }) + + test("rejects oversized and malformed JSON responses", async () => { + const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: singleOperation({}) }).tools, "test") + if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + const oversized = recordingClient( + () => new Response(null, { headers: { "content-length": String(50 * 1024 * 1024 + 1) } }), + ) + const malformed = recordingClient( + () => new Response("{", { headers: { "content-type": "application/json" } }), + ) + const chunked = recordingClient(() => new Response(new Uint8Array(50 * 1024 * 1024 + 1))) + + await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(oversized.layer)))).rejects.toThrow( + "response exceeds 50 MiB", + ) + await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(malformed.layer)))).rejects.toThrow( + "returned malformed JSON", + ) + await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(chunked.layer)))).rejects.toThrow( + "response exceeds 50 MiB", + ) + }) + + test("keeps non-JSON responses raw and unions every success output", async () => { + const spec = singleOperation({ + responses: { + 200: { description: "Text", content: { "text/plain": { schema: { type: "string" } } } }, + 204: { description: "Empty" }, + }, + }) + const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec }).tools, "test") + if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + const client = recordingClient(() => new Response("123", { headers: { "content-type": "text/plain" } })) + + expect(outputTypeScript(tool)).toBe("string | null") + await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer)))).resolves.toBe("123") + }) + + test("fails missing required parameters before auth and network", async () => { + const { requests, layer } = recordingClient(() => json({})) + const runtime = CodeMode.make({ + tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools }, + }) + + const result = await Effect.runPromise( + runtime.execute("return await tools.opencode.v2.session.get({})").pipe(Effect.provide(layer)), + ) + + expect(result).toMatchObject({ ok: false }) + expect(JSON.stringify(result)).toContain("Missing required path parameter 'sessionID'") + expect(requests).toHaveLength(0) + }) + + test("prefixes cross-location collisions and reconstructs the HTTP request", async () => { + const spec = { + openapi: "3.1.0", + info: { title: "collision", version: "1.0.0" }, + paths: { + "/echo": { + post: { + operationId: "echo", + requestBody: { + required: true, + content: { "application/json": { schema: { type: "string" } } }, + }, + responses: { "204": { description: "Echoed" } }, + }, + }, + "/things/{id}": { + post: { + operationId: "things.update", + parameters: [ + { name: "id", in: "path", required: true, schema: { type: "string" } }, + { name: "id", in: "query", required: true, schema: { type: "string" } }, + { name: "path_id", in: "query", schema: { type: "string" } }, + { name: "id", in: "header", required: true, schema: { type: "string" } }, + ], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + additionalProperties: false, + }, + }, + }, + }, + responses: { "204": { description: "Updated" } }, + }, + }, + }, + } satisfies Document + const { requests, layer } = recordingClient(() => new Response(null, { status: 204 })) + const tools = OpenAPI.fromSpec({ spec, baseUrl }).tools + const update = toolAt(tools, "things.update") + const echo = toolAt(tools, "echo") + + expect(Tool.isDefinition(update)).toBe(true) + if (!Tool.isDefinition(update)) throw new Error("things.update was not generated") + expect(inputTypeScript(update)).toBe( + "{ path_id: string; query_id: string; path_id_2?: string; header_id: string; body_id: string }", + ) + expect(Tool.isDefinition(echo)).toBe(true) + if (!Tool.isDefinition(echo)) throw new Error("echo was not generated") + expect(inputTypeScript(echo)).toBe("{ body: string }") + + const runtime = CodeMode.make({ tools }) + const result = await Effect.runPromise( + runtime + .execute( + ` + const updated = await tools.things.update({ path_id: "path", query_id: "query", path_id_2: "literal", header_id: "header", body_id: "body" }) + const echoed = await tools.echo({ body: "hello" }) + return { updated, echoed } + `, + ) + .pipe(Effect.provide(layer)), + ) + + expect(result).toMatchObject({ ok: true }) + expect(requests).toHaveLength(2) + expect(new URL(requests[0]!.url).pathname).toBe("/things/path") + expect(new URL(requests[0]!.url).searchParams.get("id")).toBe("query") + expect(new URL(requests[0]!.url).searchParams.get("path_id")).toBe("literal") + expect(requests[0]!.headers.id).toBe("header") + expect(requests[0]!.body).toStrictEqual({ id: "body" }) + expect(requests[1]!.body).toBe("hello") + }) + + test("keeps bodies nested when flattening would lose schema semantics", () => { + const body = (schema: Record, required = true) => ({ + required, + content: { "application/json": { schema } }, + }) + const spec = { + openapi: "3.1.0", + info: { title: "bodies", version: "1.0.0" }, + paths: Object.fromEntries( + [ + [ + "optional", + body( + { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + additionalProperties: false, + }, + false, + ), + ], + ["dictionary", body({ type: "object", additionalProperties: { type: "string" } })], + [ + "composed", + body({ + type: "object", + allOf: [{ type: "object", properties: { name: { type: "string" } }, required: ["name"] }], + additionalProperties: false, + }), + ], + [ + "nullable", + body({ + type: ["object", "null"], + properties: { name: { type: "string" } }, + additionalProperties: false, + }), + ], + ].map(([name, requestBody]) => [ + `/body/${name}`, + { + post: { + operationId: `body.${name}`, + requestBody, + responses: { "204": { description: "Accepted" } }, + }, + }, + ]), + ), + } satisfies Document + const tools = OpenAPI.fromSpec({ spec, baseUrl }).tools + + for (const name of ["optional", "dictionary", "composed", "nullable"]) { + const tool = toolAt(tools, `body.${name}`) + expect(Tool.isDefinition(tool)).toBe(true) + if (!Tool.isDefinition(tool)) throw new Error(`body.${name} was not generated`) + const input = isRecord(tool.input) ? tool.input : {} + expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual(["body"]) + } + const optional = toolAt(tools, "body.optional") + if (!Tool.isDefinition(optional)) throw new Error("body.optional was not generated") + expect(inputTypeScript(optional)).toBe("{ body?: { name: string } }") + }) +}) diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts new file mode 100644 index 0000000000..dfa8583183 --- /dev/null +++ b/packages/codemode/test/parity.test.ts @@ -0,0 +1,425 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { CodeMode } from "../src/index.js" +import { ToolRuntime } from "../src/tool-runtime.js" + +// Runs a CodeMode program with no host tools and returns the CodeMode.Result. These tests pin the +// JS-parity behaviors for the "99% of ordinary defensive JavaScript just works" goal: cases where +// a strict interpreter would throw but idiomatic JS yields undefined / succeeds. +// +// Note on the result boundary: this package normalizes a bare `undefined` result to `null` when +// it crosses out of the sandbox (results are JSON data), so tests asserting an in-sandbox +// `undefined` read check `=== undefined` inside the program and `null` at the boundary. +const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} })) +const value = async (code: string) => { + const result = await run(code) + if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) + return result.value +} +const error = async (code: string) => { + const result = await run(code) + if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`) + return result.error +} + +describe("H2: string property access reads as undefined (not a throw)", () => { + test("unknown property on a string is undefined", async () => { + expect(await value(`const s = "hi"; return s.login === undefined`)).toBe(true) + expect(await value(`const s = "hi"; return s.login`)).toBeNull() + }) + + test("optional chaining + fallback on a string does not throw", async () => { + expect(await value(`const s = "hi"; return s?.login ?? "fallback"`)).toBe("fallback") + }) + + test("the real MCP pattern: result is a JSON string, defensive read falls through", async () => { + // me.result is a string; me.result?.login is undefined, so we fall back to the raw string. + expect(await value(`const me = { result: '{"login":"x"}' }; return me.result?.login ?? me.result`)).toBe( + '{"login":"x"}', + ) + }) + + test("unknown property on a number is undefined", async () => { + expect(await value(`return (5).foo ?? "n"`)).toBe("n") + }) + + test("supported string methods still work", async () => { + expect(await value(`return "AB".toLowerCase()`)).toBe("ab") + expect(await value(`return "hello".length`)).toBe(5) + }) +}) + +describe("H3: array property access reads as undefined (not a throw)", () => { + test("unknown property on an array is undefined", async () => { + expect(await value(`return [1,2,3].foo === undefined`)).toBe(true) + expect(await value(`return [1,2,3].foo`)).toBeNull() + }) + + test("optional chaining on an array does not throw", async () => { + expect(await value(`return [1,2,3]?.foo ?? "fb"`)).toBe("fb") + }) + + test("unknown property reads stay undefined for methods CodeMode does not implement", async () => { + expect(await value(`return [1,2,3].toSpliced === undefined`)).toBe(true) + }) + + test("supported array methods and indexing still work", async () => { + expect(await value(`return [1,2,3].map(x => x + 1)`)).toEqual([2, 3, 4]) + expect(await value(`return [1,2,3][9] === undefined`)).toBe(true) + expect(await value(`return [1,2,3][9]`)).toBeNull() + }) +}) + +describe("H6: object spread of null/undefined is a no-op", () => { + test("spreading null is a no-op", async () => { + expect(await value(`const o = null; return { ...o, a: 1 }`)).toEqual({ a: 1 }) + }) + + test("spreading an absent argument merges cleanly", async () => { + expect(await value(`function f(opts){ return { ...opts, a: 1 } } return f(undefined)`)).toEqual({ a: 1 }) + }) + + test("spreading a real object still works", async () => { + expect(await value(`const o = { a: 1 }; return { ...o, b: 2 }`)).toEqual({ a: 1, b: 2 }) + }) + + test("spreading an array into an object still errors", async () => { + const err = await error(`return { ...[1,2], a: 1 }`) + expect(err.kind).toBe("InvalidDataValue") + }) +}) + +describe("H4: typeof on an undeclared identifier is 'undefined'", () => { + test("feature-detection guard does not throw", async () => { + expect(await value(`return typeof foo === "undefined" ? "safe" : "no"`)).toBe("safe") + }) + + test("typeof of a declared binding is unaffected", async () => { + expect(await value(`const x = 5; return typeof x`)).toBe("number") + expect(await value(`const s = "a"; return typeof s`)).toBe("string") + }) + + test("referencing an undeclared identifier outside typeof still throws", async () => { + const err = await error(`return foo + 1`) + expect(err.message).toContain("foo") + }) +}) + +describe("H1: NaN/Infinity flow as intermediates and normalize to null at the boundary", () => { + test("guards run instead of the program crashing on a transient NaN", async () => { + expect(await value(`return parseInt("abc") || 0`)).toBe(0) + expect(await value(`const x = Number("abc"); return Number.isNaN(x) ? 0 : x`)).toBe(0) + expect(await value(`const o = {}; o.count = (o.count || 0) + 1; return o.count`)).toBe(1) + // average of an empty list, guarded - the classic divide-by-zero that used to throw pre-guard + expect(await value(`const a = []; return a.length ? a.reduce((s,x)=>s+x,0)/a.length : 0`)).toBe(0) + }) + + test("a non-finite value becomes null when it leaves the sandbox", async () => { + expect(await value(`return 5/0`)).toBeNull() + expect(await value(`return 0/0`)).toBeNull() + expect(await value(`return Math.max()`)).toBeNull() + // nested, too - normalization walks the returned structure + expect(await value(`return { a: Number("x"), b: 2, c: [1/0] }`)).toEqual({ a: null, b: 2, c: [null] }) + }) + + test("NaN and Infinity are usable identifiers and inspectable in-sandbox", async () => { + expect(await value(`return Number.isNaN(NaN)`)).toBe(true) + expect(await value(`return Infinity > 1e9`)).toBe(true) + expect(await value(`return Number.isFinite(1/0)`)).toBe(false) + expect(await value(`return [3,1,2].reduce((a,b)=>Math.max(a,b), -Infinity)`)).toBe(3) + // JSON.stringify inside the sandbox matches JS: non-finite serializes to null + expect(await value(`return JSON.stringify({ x: Number("z") })`)).toBe('{"x":null}') + }) + + test("copyOut normalizes non-finite numbers to null (the shared return + tool-arg boundary)", () => { + // Tool-call arguments funnel through copyOut too, so this one function pins both boundaries. + expect(ToolRuntime.copyOut(NaN)).toBeNull() + expect(ToolRuntime.copyOut(Infinity)).toBeNull() + expect(ToolRuntime.copyOut(-Infinity)).toBeNull() + expect(ToolRuntime.copyOut(42)).toBe(42) + expect(ToolRuntime.copyOut({ a: NaN, b: [Infinity, 1] })).toEqual({ a: null, b: [null, 1] }) + }) +}) + +describe("Error values and instanceof", () => { + test("new Error carries name/message and is instanceof Error", async () => { + expect(await value(`const e = new Error("boom"); return [e instanceof Error, e.name, e.message]`)).toEqual([ + true, + "Error", + "boom", + ]) + }) + + test("Error without new behaves like new Error", async () => { + expect(await value(`const e = Error("plain"); return [e instanceof Error, e.name, e.message]`)).toEqual([ + true, + "Error", + "plain", + ]) + expect(await value(`const e = new Error(); return [e.name, e.message, e instanceof Error]`)).toEqual([ + "Error", + "", + true, + ]) + }) + + test("specific error types are instanceof themselves and Error, not each other", async () => { + expect( + await value( + `const e = new TypeError("t"); return [e instanceof TypeError, e instanceof Error, e instanceof RangeError]`, + ), + ).toEqual([true, true, false]) + expect(await value(`return new Error("e") instanceof TypeError`)).toBe(false) + }) + + test("thrown errors keep instanceof through try/catch", async () => { + expect(await value(`try { throw new Error("x") } catch (e) { return [e instanceof Error, e.message] }`)).toEqual([ + true, + "x", + ]) + }) + + test("interpreter runtime failures are caught as Error values", async () => { + expect(await value(`try { JSON.parse("nope") } catch (e) { return e instanceof Error }`)).toBe(true) + expect(await value(`try { undeclared() } catch (e) { return e instanceof Error }`)).toBe(true) + }) + + test("caught failures carry the constructor name the real-JS failure would have", async () => { + // JSON.parse throws SyntaxError: name and specific-instanceof both carry through, and the + // message keeps the engine's position detail. + expect( + await value(` + try { JSON.parse("{oops") } catch (e) { + return [e.name, e instanceof SyntaxError, e instanceof Error, e instanceof TypeError, e.message.includes("JSON")] + } + `), + ).toEqual(["SyntaxError", true, true, false, true]) + expect(await value(`try { undeclared() } catch (e) { return [e.name, e instanceof ReferenceError] }`)).toEqual([ + "ReferenceError", + true, + ]) + expect(await value(`try { const c = 1; c = 2 } catch (e) { return [e.name, e instanceof TypeError] }`)).toEqual([ + "TypeError", + true, + ]) + expect(await value(`try { "a".normalize("NOPE") } catch (e) { return [e.name, e instanceof RangeError] }`)).toEqual( + ["RangeError", true], + ) + expect(await value(`try { "a".match("(") } catch (e) { return [e.name, e instanceof SyntaxError] }`)).toEqual([ + "SyntaxError", + true, + ]) + expect(await value(`try { new RegExp("(") } catch (e) { return [e.name, e instanceof SyntaxError] }`)).toEqual([ + "SyntaxError", + true, + ]) + }) + + test("diagnostics without a specific real-JS analogue are named plain Error", async () => { + expect(await value(`try { JSON.parse(5) } catch (e) { return [e.name, e instanceof Error] }`)).toEqual([ + "Error", + true, + ]) + }) + + test("Promise.allSettled rejection reasons are Error values", async () => { + expect( + await value(` + const settled = await Promise.allSettled([Promise.reject(new Error("b"))]) + return [settled[0].reason instanceof Error, settled[0].reason.message] + `), + ).toEqual([true, "b"]) + }) + + test("non-error thrown values are not instanceof Error", async () => { + expect(await value(`try { throw "raw" } catch (e) { return e instanceof Error }`)).toBe(false) + expect(await value(`try { throw { message: "shaped" } } catch (e) { return e instanceof Error }`)).toBe(false) + }) + + test("plain data is never instanceof Error", async () => { + expect(await value(`return [({}) instanceof Error, "s" instanceof Error, null instanceof Error]`)).toEqual([ + false, + false, + false, + ]) + }) + + test("error values still serialize as plain { name, message } data", async () => { + expect(await value(`return new Error("m")`)).toEqual({ name: "Error", message: "m" }) + expect(await value(`return JSON.stringify(new Error("m"))`)).toBe('{"name":"Error","message":"m"}') + expect(await value(`try { throw new Error("m") } catch (e) { return Object.keys(e) }`)).toEqual(["name", "message"]) + }) + + test("spreading an error loses the brand, like losing the prototype in JS", async () => { + expect(await value(`const e = new Error("m"); return ({ ...e }) instanceof Error`)).toBe(false) + expect(await value(`const e = new Error("m"); return { ...e }`)).toEqual({ name: "Error", message: "m" }) + }) + + test("typeof Error is function; an unknown instanceof right-hand side is a catchable error", async () => { + expect(await value(`return typeof Error`)).toBe("function") + expect(await value(`try { return 1 instanceof 5 } catch (e) { return "caught" }`)).toBe("caught") + const err = await error(`return 1 instanceof 5`) + expect(err.message).toContain("right-hand side of 'instanceof'") + }) +}) + +describe("array methods: splice, fill, copyWithin, keys/values/entries", () => { + test("splice removes in place and returns the removed elements", async () => { + expect(await value(`const a = [1,2,3,4]; const removed = a.splice(1, 2); return { removed, a }`)).toEqual({ + removed: [2, 3], + a: [1, 4], + }) + }) + + test("splice inserts new elements at the cut", async () => { + expect(await value(`const a = ["a","d"]; a.splice(1, 0, "b", "c"); return a`)).toEqual(["a", "b", "c", "d"]) + expect(await value(`const a = [1,2,3]; const removed = a.splice(1, 1, "x"); return { removed, a }`)).toEqual({ + removed: [2], + a: [1, "x", 3], + }) + }) + + test("splice with one argument removes to the end; negative start counts back", async () => { + expect(await value(`const a = [1,2,3]; const removed = a.splice(1); return { removed, a }`)).toEqual({ + removed: [2, 3], + a: [1], + }) + expect(await value(`const a = [1,2,3]; const removed = a.splice(-1); return { removed, a }`)).toEqual({ + removed: [3], + a: [1, 2], + }) + }) + + test("splice rejects inserting a container into itself", async () => { + const err = await error(`const a = [1]; a.splice(0, 0, [a]); return a`) + expect(err.kind).toBe("InvalidDataValue") + expect(err.message).toContain("circular") + }) + + test("fill overwrites a range and returns the mutated array", async () => { + expect(await value(`const a = [1,2,3,4]; return a.fill(0, 1, 3)`)).toEqual([1, 0, 0, 4]) + expect(await value(`return [1,2,3].fill("z")`)).toEqual(["z", "z", "z"]) + }) + + test("copyWithin copies a range in place", async () => { + expect(await value(`return [1,2,3,4,5].copyWithin(0, 3)`)).toEqual([4, 5, 3, 4, 5]) + }) + + test("keys/values/entries return arrays usable with for...of and spread", async () => { + expect(await value(`return [...["x","y","z"].keys()]`)).toEqual([0, 1, 2]) + expect(await value(`return ["x","y"].values()`)).toEqual(["x", "y"]) + expect( + await value(` + const out = [] + for (const [index, item] of ["a","b"].entries()) out.push(index + ":" + item) + return out + `), + ).toEqual(["0:a", "1:b"]) + expect(await value(`return [...[7].entries()]`)).toEqual([[0, 7]]) + }) +}) + +describe("string methods: localeCompare, normalize, trim aliases", () => { + test("localeCompare orders strings for sorting", async () => { + expect(await value(`return ["b","a","c"].sort((x, y) => x.localeCompare(y))`)).toEqual(["a", "b", "c"]) + expect(await value(`return "a".localeCompare("a")`)).toBe(0) + }) + + test("normalize applies unicode normalization forms", async () => { + expect(await value(`return "\\u0065\\u0301".normalize("NFC").length`)).toBe(1) + expect(await value(`return "\\u00e9".normalize("NFD").length`)).toBe(2) + expect(await value(`return "x".normalize() === "x"`)).toBe(true) + }) + + test("an invalid normalize form is a clear catchable error", async () => { + expect(await value(`try { "x".normalize("nope"); return "no" } catch (e) { return e.message }`)).toContain('"NFC"') + }) + + test("trimLeft/trimRight alias trimStart/trimEnd", async () => { + expect(await value(`return " x ".trimLeft()`)).toBe("x ") + expect(await value(`return " x ".trimRight()`)).toBe(" x") + }) +}) + +describe("compound assignment matches its binary operator", () => { + // `x op= y` must behave exactly like `x = x op y`, sharing the binary operator's coercion + // semantics (Dates string-coerce for `+` and use their time value for arithmetic; data + // objects/arrays coerce to their JS string form). + const pair = async (compound: string, expanded: string) => { + const [a, b] = await Promise.all([value(compound), value(expanded)]) + expect(a).toEqual(b) + return a + } + + test("sandbox Date += concatenates its string form, like d = d + 1", async () => { + const result = await pair(`let d = new Date(1000); d += 1; return d`, `let d = new Date(1000); d = d + 1; return d`) + expect(result).toBe("1970-01-01T00:00:01.000Z1") + }) + + test("sandbox Date numeric compound ops use its time value", async () => { + expect( + await pair(`let d = new Date(1000); d -= 400; return d`, `let d = new Date(1000); d = d - 400; return d`), + ).toBe(600) + expect(await pair(`let d = new Date(1000); d /= 4; return d`, `let d = new Date(1000); d = d / 4; return d`)).toBe( + 250, + ) + }) + + test("string += object/array matches x = x + obj", async () => { + expect(await pair(`let x = "a"; x += { b: 1 }; return x`, `let x = "a"; x = x + { b: 1 }; return x`)).toBe( + "a[object Object]", + ) + expect(await pair(`let x = "a"; x += [1, 2]; return x`, `let x = "a"; x = x + [1, 2]; return x`)).toBe("a1,2") + }) + + test("compound assignment through a member target coerces the same way", async () => { + expect( + await pair( + `const o = { s: "t" }; o.s += new Date(0); return o.s`, + `const o = { s: "t" }; o.s = o.s + new Date(0); return o.s`, + ), + ).toBe("t1970-01-01T00:00:00.000Z") + }) + + test("numeric and string compound operators sweep identically to their expansions", async () => { + const cases: Array<[string, number | string]> = [ + [`let x = 7; x += 3; return x`, 7 + 3], + [`let x = 7; x -= 3; return x`, 7 - 3], + [`let x = 7; x *= 3; return x`, 7 * 3], + [`let x = 7; x /= 2; return x`, 7 / 2], + [`let x = 7; x %= 3; return x`, 7 % 3], + [`let x = 7; x **= 2; return x`, 7 ** 2], + [`let x = 7; x &= 3; return x`, 7 & 3], + [`let x = 7; x |= 8; return x`, 7 | 8], + [`let x = 7; x ^= 2; return x`, 7 ^ 2], + [`let x = 7; x <<= 2; return x`, 7 << 2], + [`let x = -7; x >>= 1; return x`, -7 >> 1], + [`let x = -7; x >>>= 1; return x`, -7 >>> 1], + [`let x = "a"; x += "b"; return x`, "ab"], + ] + for (const [compound, expected] of cases) { + expect(await value(compound)).toBe(expected) + expect(await value(compound.replace(/x (\S+)= /, (_, op) => `x = x ${op} `))).toBe(expected) + } + }) +}) + +describe("H5: builtin coercion functions work as array callbacks", () => { + test("filter(Boolean) drops falsy values", async () => { + expect(await value(`return [0, 1, "", 2, null, 3].filter(Boolean)`)).toEqual([1, 2, 3]) + }) + + test("map(String) coerces each element", async () => { + expect(await value(`return [1, 2, 3].map(String)`)).toEqual(["1", "2", "3"]) + }) + + test("arrow callbacks still work (no regression)", async () => { + expect(await value(`return [1, 2, 3, 4].filter(x => x % 2 === 0)`)).toEqual([2, 4]) + expect(await value(`return [1, 2, 3].reduce((a, b) => a + b, 0)`)).toBe(6) + }) + + test("a non-callable callback is still rejected", async () => { + const err = await error(`return [1,2,3].map(42)`) + expect(err.message).toContain("callback") + }) +}) diff --git a/packages/codemode/test/promise.test.ts b/packages/codemode/test/promise.test.ts new file mode 100644 index 0000000000..545d463abf --- /dev/null +++ b/packages/codemode/test/promise.test.ts @@ -0,0 +1,456 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Schema } from "effect" +import { CodeMode, Tool, toolError } from "../src/index.js" + +// Wave 5 acceptance suite: first-class promise values. Un-awaited tool calls start eagerly on +// supervised fibers, `await` settles them, and Promise.all/allSettled/race/resolve/reject are +// ordinary functions over arbitrary arrays mixing promises and plain values. + +type Trace = { + starts: Array + active: number + maxActive: number + completed: number + interrupted: number +} + +const makeTrace = (): Trace => ({ starts: [], active: 0, maxActive: 0, completed: 0, interrupted: 0 }) + +/** Echoes `id` after `ms` milliseconds, recording start order, live concurrency, and interruption. */ +const sleepyTool = (trace: Trace) => + Tool.make({ + description: "Echo an id after a delay", + input: Schema.Struct({ id: Schema.Number, ms: Schema.optionalKey(Schema.Number) }), + output: Schema.Number, + run: ({ id, ms }) => + Effect.gen(function* () { + trace.starts.push(id) + trace.active += 1 + trace.maxActive = Math.max(trace.maxActive, trace.active) + yield* Effect.sleep(ms ?? 20) + trace.active -= 1 + trace.completed += 1 + return id + }).pipe( + Effect.onInterrupt(() => + Effect.sync(() => { + trace.active -= 1 + trace.interrupted += 1 + }), + ), + ), + }) + +const failingTool = Tool.make({ + description: "Always refuse", + input: Schema.Struct({}), + output: Schema.String, + run: () => Effect.fail(toolError("Lookup refused")), +}) + +const run = ( + code: string, + options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {}, +): Promise => { + const trace = options.trace ?? makeTrace() + return Effect.runPromise( + CodeMode.execute({ + tools: { host: { sleepy: sleepyTool(trace), fail: failingTool } }, + code, + ...(options.limits ? { limits: options.limits } : {}), + }), + ) +} + +const value = async (code: string, options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {}) => { + const result = await run(code, options) + if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) + return result.value +} + +const error = async (code: string, options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {}) => { + const result = await run(code, options) + if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`) + return result.error +} + +describe("first-class promise values", () => { + test("an un-awaited tool call starts eagerly, in call order, before any await", async () => { + const trace = makeTrace() + const result = await value( + ` + const a = tools.host.sleepy({ id: 1, ms: 40 }) + const b = tools.host.sleepy({ id: 2, ms: 40 }) + const rb = await b + const ra = await a + return [ra, rb] + `, + { trace }, + ) + expect(result).toEqual([1, 2]) + expect(trace.starts).toEqual([1, 2]) + // Both calls overlapped even though they were awaited sequentially. + expect(trace.maxActive).toBeGreaterThan(1) + }) + + test("awaiting the same promise twice settles once and never re-runs the call", async () => { + const result = await run(` + const p = tools.host.sleepy({ id: 7 }) + const x = await p + const y = await p + return [x, y] + `) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.value).toEqual([7, 7]) + expect(result.toolCalls).toStrictEqual([{ name: "host.sleepy" }]) + }) + + test("await of a non-promise value is a passthrough no-op", async () => { + expect(await value(`return await 42`)).toBe(42) + expect(await value(`const x = await "s"; return x`)).toBe("s") + expect(await value(`return await null`)).toBeNull() + expect(await value(`return (await [1, 2]).length`)).toBe(2) + }) + + test("returning an un-awaited tool call resolves it (async-function return semantics)", async () => { + expect(await value(`return tools.host.sleepy({ id: 9 })`)).toBe(9) + }) + + test("typeof a promise is 'object', and console.log renders it sensibly", async () => { + const result = await run(` + const p = Promise.resolve(1) + console.log(p) + return typeof p + `) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.value).toBe("object") + expect(result.logs).toStrictEqual(["[Promise (await it to get its value)]"]) + }) + + test("an awaited failure is catchable exactly like a synchronous throw", async () => { + expect( + await value(` + const p = tools.host.fail({}) + try { + await p + return "no" + } catch (e) { + return e.message + } + `), + ).toBe("Lookup refused") + }) + + test("a fire-and-forget call completes before the execution ends", async () => { + const trace = makeTrace() + const result = await value( + ` + tools.host.sleepy({ id: 1, ms: 30 }) + return "done" + `, + { trace }, + ) + expect(result).toBe("done") + expect(trace.completed).toBe(1) + expect(trace.interrupted).toBe(0) + }) + + test("a never-awaited failing call surfaces as an unhandled-rejection diagnostic", async () => { + const diagnostic = await error(` + tools.host.fail({}) + return "done" + `) + expect(diagnostic.kind).toBe("ToolFailure") + expect(diagnostic.message).toContain("Unhandled rejection from an un-awaited tool call") + expect(diagnostic.message).toContain("Lookup refused") + expect(diagnostic.suggestions?.join(" ")).toContain("await tools.ns.tool(...)") + }) +}) + +describe("promises at data boundaries", () => { + test("returning an un-awaited promise inside data is a clear await-hinting diagnostic", async () => { + const diagnostic = await error(`return { result: tools.host.sleepy({ id: 1 }) }`) + expect(diagnostic.kind).toBe("InvalidDataValue") + expect(diagnostic.message).toContain("un-awaited Promise") + expect(diagnostic.message).toContain("await tools.ns.tool(...)") + }) + + test("passing an un-awaited promise as a tool argument is a clear diagnostic", async () => { + const diagnostic = await error(`return await tools.host.sleepy({ id: tools.host.sleepy({ id: 1 }) })`) + expect(diagnostic.kind).toBe("InvalidDataValue") + expect(diagnostic.message).toContain("un-awaited Promise") + }) + + test("JSON.stringify of a promise is a diagnostic, not '{}'", async () => { + const diagnostic = await error(`return JSON.stringify(Promise.resolve(1))`) + expect(diagnostic.kind).toBe("InvalidDataValue") + expect(diagnostic.message).toContain("un-awaited Promise") + }) + + test("operators reject promise operands", async () => { + const diagnostic = await error(`return Promise.resolve(1) + 1`) + expect(diagnostic.kind).toBe("InvalidDataValue") + }) +}) + +describe("Promise.all over arbitrary arrays", () => { + test("mixes promises and plain values, preserving order", async () => { + expect( + await value(` + return await Promise.all([tools.host.sleepy({ id: 1 }), "plain", tools.host.sleepy({ id: 2 }), 42]) + `), + ).toEqual([1, "plain", 2, 42]) + }) + + test("accepts arrays built beforehand, passed as identifiers, and spread elements", async () => { + expect( + await value(` + const calls = [] + calls.push(tools.host.sleepy({ id: 1 })) + calls.push(7) + const more = [tools.host.sleepy({ id: 2 })] + const batch = [...calls, ...more, "x"] + return await Promise.all(batch) + `), + ).toEqual([1, 7, 2, "x"]) + }) + + test("runs items.map tool calls in parallel", async () => { + const trace = makeTrace() + const result = await value( + ` + const ids = [1, 2, 3, 4] + return await Promise.all(ids.map((id) => tools.host.sleepy({ id, ms: 40 }))) + `, + { trace }, + ) + expect(result).toEqual([1, 2, 3, 4]) + // maxActive counts truly-overlapping live executions, so > 1 proves real + // parallelism deterministically - no wall-clock assertion needed. + expect(trace.maxActive).toBeGreaterThan(1) + }) + + test("caps live tool-call concurrency at the fixed internal constant (8)", async () => { + const trace = makeTrace() + const result = await value( + ` + const ids = [] + for (let i = 0; i < 20; i += 1) ids.push(i) + const results = await Promise.all(ids.map((id) => tools.host.sleepy({ id, ms: 10 }))) + return results.length + `, + { trace }, + ) + expect(result).toBe(20) + expect(trace.maxActive).toBeGreaterThan(1) + expect(trace.maxActive).toBeLessThanOrEqual(8) + }) + + test("resolves the empty array", async () => { + expect(await value(`return await Promise.all([])`)).toEqual([]) + }) + + test("rejects with the first failure, catchable in-program", async () => { + expect( + await value(` + try { + await Promise.all([tools.host.sleepy({ id: 1 }), tools.host.fail({})]) + return "no" + } catch (e) { + return e.message + } + `), + ).toBe("Lookup refused") + }) + + test("a non-collection argument is a clear error", async () => { + const diagnostic = await error(`return await Promise.all(42)`) + expect(diagnostic.message).toContain("Promise.all expects an array") + }) + + test("exceeding maxToolCalls inside Promise.all is a ToolCallLimitExceeded diagnostic", async () => { + const diagnostic = await error( + `return await Promise.all([tools.host.sleepy({ id: 1 }), tools.host.sleepy({ id: 2 }), tools.host.sleepy({ id: 3 })])`, + { limits: { maxToolCalls: 2 } }, + ) + expect(diagnostic.kind).toBe("ToolCallLimitExceeded") + }) +}) + +describe("Promise.allSettled", () => { + test("reports fulfilled and rejected outcomes with catch-normalized reasons", async () => { + expect( + await value(` + return await Promise.allSettled([ + tools.host.sleepy({ id: 5 }), + tools.host.fail({}), + "plain", + Promise.reject(new Error("boom")), + ]) + `), + ).toEqual([ + { status: "fulfilled", value: 5 }, + { status: "rejected", reason: { name: "Error", message: "Lookup refused" } }, + { status: "fulfilled", value: "plain" }, + { status: "rejected", reason: { name: "Error", message: "boom" } }, + ]) + }) + + test("never rejects for program-level failures", async () => { + const result = await run(` + const settled = await Promise.allSettled([tools.host.fail({}), tools.host.fail({})]) + return settled.filter((s) => s.status === "rejected").length + `) + expect(result.ok).toBe(true) + if (result.ok) expect(result.value).toBe(2) + }) +}) + +describe("Promise.race", () => { + test("first settlement wins and losers are interrupted", async () => { + const trace = makeTrace() + const result = await value( + ` + const fast = tools.host.sleepy({ id: 1, ms: 10 }) + const slow = tools.host.sleepy({ id: 2, ms: 5000 }) + return await Promise.race([fast, slow]) + `, + { trace }, + ) + expect(result).toBe(1) + expect(trace.interrupted).toBe(1) + expect(trace.completed).toBe(1) + }) + + test("awaiting an interrupted loser afterwards is a catchable program failure", async () => { + expect( + await value(` + const fast = tools.host.sleepy({ id: 1, ms: 10 }) + const slow = tools.host.sleepy({ id: 2, ms: 5000 }) + const winner = await Promise.race([fast, slow]) + try { + await slow + return "no" + } catch (e) { + return { winner, caught: e.message } + } + `), + ).toEqual({ + winner: 1, + caught: "This tool call was interrupted because another value settled a Promise.race first.", + }) + }) + + test("a rejection can win the race", async () => { + expect( + await value(` + try { + await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 5000 })]) + return "no" + } catch (e) { + return e.message + } + `), + ).toBe("Lookup refused") + }) + + test("a plain value wins over pending promises", async () => { + const trace = makeTrace() + expect( + await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 5000 }), "immediate"])`, { trace }), + ).toBe("immediate") + expect(trace.interrupted).toBe(1) + }) + + test("an empty race is a clear error instead of hanging", async () => { + const diagnostic = await error(`return await Promise.race([])`) + expect(diagnostic.message).toContain("never settle") + }) +}) + +describe("Promise.resolve / Promise.reject", () => { + test("resolve wraps plain values and passes promises through", async () => { + expect(await value(`return await Promise.resolve(42)`)).toBe(42) + expect(await value(`return await Promise.resolve(Promise.resolve("nested"))`)).toBe("nested") + expect(await value(`return await Promise.resolve(tools.host.sleepy({ id: 3 }))`)).toBe(3) + }) + + test("reject produces a promise whose await throws the reason", async () => { + expect( + await value(` + try { + await Promise.reject("nope") + return "no" + } catch (e) { + return e + } + `), + ).toBe("nope") + }) +}) + +describe("timeout interruption of forked calls", () => { + test("the execution timeout interrupts in-flight forked fibers", async () => { + const trace = makeTrace() + const result = await run( + ` + const a = tools.host.sleepy({ id: 1, ms: 60000 }) + const b = tools.host.sleepy({ id: 2, ms: 60000 }) + return await a + `, + { trace, limits: { timeoutMs: 100 } }, + ) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error.kind).toBe("TimeoutExceeded") + // Both calls started; neither escaped the timeout - the awaited one AND the abandoned one. + expect(trace.starts).toEqual([1, 2]) + expect(trace.interrupted).toBe(2) + expect(trace.completed).toBe(0) + }) + + test("the timeout also interrupts calls inside Promise.all", async () => { + const trace = makeTrace() + const result = await run( + `return await Promise.all([tools.host.sleepy({ id: 1, ms: 60000 }), tools.host.sleepy({ id: 2, ms: 60000 })])`, + { trace, limits: { timeoutMs: 100 } }, + ) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error.kind).toBe("TimeoutExceeded") + expect(trace.interrupted).toBe(2) + }) +}) + +describe("unsupported promise surface", () => { + test(".then/.catch/.finally give a clear await-instead error", async () => { + for (const method of ["then", "catch", "finally"]) { + const diagnostic = await error(`return tools.host.sleepy({ id: 1 }).${method}((x) => x)`) + expect(diagnostic.kind).toBe("UnsupportedSyntax") + expect(diagnostic.message).toContain(`Promise.prototype.${method} is not supported`) + expect(diagnostic.message).toContain("await") + } + }) + + test("other property reads on a promise hint at the missing await", async () => { + const diagnostic = await error(`return tools.host.sleepy({ id: 1 }).value`) + expect(diagnostic.kind).toBe("InvalidDataValue") + expect(diagnostic.message).toContain("un-awaited Promise") + expect(diagnostic.message).toContain("await it first") + }) + + test("unknown Promise statics list what is available", async () => { + const diagnostic = await error(`return await Promise.any([tools.host.sleepy({ id: 1 })])`) + expect(diagnostic.message).toContain("Promise.any is not available") + expect(diagnostic.message).toContain("Promise.allSettled") + }) + + test("new Promise(...) points at tool calls instead", async () => { + const diagnostic = await error(`return new Promise((resolve) => resolve(1))`) + expect(diagnostic.kind).toBe("UnsupportedSyntax") + expect(diagnostic.message).toContain("new Promise(...) is not supported") + expect(diagnostic.message).toContain("already return promises") + }) +}) diff --git a/packages/codemode/test/signature.test.ts b/packages/codemode/test/signature.test.ts new file mode 100644 index 0000000000..47345be7d7 --- /dev/null +++ b/packages/codemode/test/signature.test.ts @@ -0,0 +1,444 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Schema } from "effect" +import { CodeMode, Tool } from "../src/index.js" +import { inputTypeScript, jsonSchemaToTypeScript, outputTypeScript } from "../src/tool-schema.js" + +// A raw JSON Schema tool in the shape an MCP adapter produces: render-only input schema +// whose property descriptions and constraints must surface as JSDoc in pretty signatures. +const listIssues = Tool.make({ + description: "List issues in a repository", + input: { + type: "object", + properties: { + owner: { type: "string", description: "Repository owner" }, + after: { type: "string", description: "Cursor from the previous response's pageInfo" }, + perPage: { type: "number", description: "Results per page", default: 30 }, + labels: { type: "array", items: { type: "string" }, description: "Filter by labels", minItems: 1, maxItems: 10 }, + state: { type: "string", enum: ["open", "closed"] }, + }, + required: ["owner"], + }, + run: () => Effect.succeed("[]"), +}) + +// An Effect Schema tool whose field annotations must flow through the emitted JSON Schema. +const lookupOrder = Tool.make({ + description: "Look up an order", + input: Schema.Struct({ + id: Schema.String.annotate({ description: "Order identifier" }), + verbose: Schema.optionalKey(Schema.Boolean), + }), + output: Schema.Struct({ + status: Schema.String.annotate({ description: "Current order status" }), + }), + run: () => Effect.succeed({ status: "open" }), +}) + +describe("pretty signature rendering", () => { + test("described fields get JSDoc comments; undescribed and untagged fields get none", () => { + expect(inputTypeScript(listIssues, true)).toBe( + [ + "{", + " /** Repository owner */", + " owner: string", + " /** Cursor from the previous response's pageInfo */", + " after?: string", + " /**", + " * Results per page", + " * @default 30", + " */", + " perPage?: number", + " /**", + " * Filter by labels", + " * @minItems 1", + " * @maxItems 10", + " */", + " labels?: Array", + ' state?: "open" | "closed"', + "}", + ].join("\n"), + ) + }) + + test("compact mode output is unchanged by the pretty machinery", () => { + expect(inputTypeScript(listIssues)).toBe( + '{ owner: string; after?: string; perPage?: number; labels?: Array; state?: "open" | "closed" }', + ) + expect(inputTypeScript(lookupOrder)).toBe("{ id: string; verbose?: boolean }") + expect(outputTypeScript(lookupOrder)).toBe("{ status: string }") + }) + + test("nested objects recurse with increasing indent and their own JSDoc", () => { + const pretty = jsonSchemaToTypeScript( + { + type: "object", + properties: { + filter: { + type: "object", + description: "Search filter", + properties: { state: { type: "string", description: "Issue state" } }, + }, + }, + }, + true, + ) + expect(pretty).toBe( + ["{", " /** Search filter */", " filter?: {", " /** Issue state */", " state?: string", " }", "}"].join( + "\n", + ), + ) + }) + + test("Effect Schema annotations become JSDoc on input and output fields", () => { + expect(inputTypeScript(lookupOrder, true)).toBe( + ["{", " /** Order identifier */", " id: string", " verbose?: boolean", "}"].join("\n"), + ) + expect(outputTypeScript(lookupOrder, true)).toBe( + ["{", " /** Current order status */", " status: string", "}"].join("\n"), + ) + }) + + test("constraints TypeScript cannot express surface as JSDoc tags", () => { + const pretty = jsonSchemaToTypeScript( + { + type: "object", + properties: { + legacy: { type: "string", deprecated: true }, + homepage: { type: "string", format: "uri" }, + tags: { type: "array", items: { type: "string" }, minItems: 2, maxItems: 5, default: ["a", "b"] }, + }, + }, + true, + ) + expect(pretty).toContain(" /** @deprecated */\n legacy?: string") + expect(pretty).toContain(" /** @format uri */\n homepage?: string") + expect(pretty).toContain( + [ + " /**", + ' * @default ["a","b"]', + " * @minItems 2", + " * @maxItems 5", + " */", + " tags?: Array", + ].join("\n"), + ) + }) + + test("skips an unserializable default rather than emitting a broken tag", () => { + const pretty = jsonSchemaToTypeScript( + { type: "object", properties: { size: { type: "number", default: 1n } } }, + true, + ) + expect(pretty).toBe(["{", " size?: number", "}"].join("\n")) + }) + + test("neutralizes */ inside descriptions so nothing closes the comment early", () => { + const pretty = jsonSchemaToTypeScript( + { type: "object", properties: { note: { type: "string", description: "Ends */ early" } } }, + true, + ) + expect(pretty).toContain(" /** Ends * / early */") + expect(pretty).not.toContain("Ends */") + }) + + test("multiline descriptions become *-prefixed blocks with blank edges trimmed", () => { + const pretty = jsonSchemaToTypeScript( + { + type: "object", + properties: { query: { type: "string", description: "\nFirst line\n\nSecond line\n" } }, + }, + true, + ) + expect(pretty).toBe( + ["{", " /**", " * First line", " *", " * Second line", " */", " query?: string", "}"].join("\n"), + ) + }) + + test("stays total on cyclic $refs and pathological nesting in both modes", () => { + const cyclic = { + $ref: "#/$defs/Node", + $defs: { Node: { type: "object", properties: { child: { $ref: "#/$defs/Node" }, name: { type: "string" } } } }, + } as const + expect(jsonSchemaToTypeScript(cyclic)).toBe("{ child?: unknown; name?: string }") + expect(jsonSchemaToTypeScript(cyclic, true)).toContain("child?: unknown") + + let deep: Record = { type: "string" } + for (let level = 0; level < 12; level += 1) deep = { type: "object", properties: { next: deep } } + for (const pretty of [false, true]) { + const rendered = jsonSchemaToTypeScript(deep, pretty) + expect(rendered).toContain("unknown") + expect(rendered).toContain("next?:") + } + }) + + test("intersects ref and union siblings instead of discarding them", () => { + expect( + jsonSchemaToTypeScript({ + $ref: "#/$defs/User", + properties: { active: { type: "boolean" } }, + required: ["active"], + $defs: { + User: { type: "object", properties: { id: { type: "string" } }, required: ["id"] }, + }, + }), + ).toBe("{ id: string } & { active: boolean }") + expect( + jsonSchemaToTypeScript({ + type: "object", + properties: { common: { type: "boolean" } }, + required: ["common"], + anyOf: [ + { type: "object", properties: { name: { type: "string" } }, required: ["name"] }, + { type: "object", properties: { count: { type: "number" } }, required: ["count"] }, + ], + }), + ).toBe("({ name: string } | { count: number }) & { common: boolean }") + expect(jsonSchemaToTypeScript({ $ref: "https://example.com/schema.json" })).toBe("unknown") + expect( + jsonSchemaToTypeScript({ + $ref: "#/$defs/User/properties/id", + $defs: { User: { type: "object" }, id: { type: "string" } }, + }), + ).toBe("unknown") + expect( + jsonSchemaToTypeScript({ + type: ["object", "null"], + properties: { name: { type: "string" } }, + }), + ).toBe("{ name?: string } | null") + }) +}) + +describe("non-identifier property names render as quoted keys", () => { + // MCP-style schemas routinely carry property names that are not bare TS identifiers + // (`foo-bar`, `@type`, dotted names); the rendered signature must quote them so the + // model sees a valid TypeScript object type. Bare identifiers stay unquoted. + const rawSchema = { + type: "object", + properties: { + "foo-bar": { type: "string" }, + "@type": { type: "string" }, + "x.y": { type: "number", description: "Dotted name" }, + "123": { type: "number" }, + plain: { type: "boolean" }, + }, + required: ["@type"], + } as const + + test("compact rendering quotes non-identifier keys and leaves identifiers bare", () => { + expect(jsonSchemaToTypeScript(rawSchema)).toBe( + '{ "123"?: number; "foo-bar"?: string; "@type": string; "x.y"?: number; plain?: boolean }', + ) + }) + + test("pretty rendering quotes non-identifier keys and keeps their JSDoc", () => { + expect(jsonSchemaToTypeScript(rawSchema, true)).toBe( + [ + "{", + ' "123"?: number', + ' "foo-bar"?: string', + ' "@type": string', + " /** Dotted name */", + ' "x.y"?: number', + " plain?: boolean", + "}", + ].join("\n"), + ) + }) + + test("JSON Schema input and output signatures of a tool both quote", () => { + const tool = Tool.make({ + description: "Adapter tool with awkward field names", + input: rawSchema, + output: { + type: "object", + properties: { "content-type": { type: "string" } }, + required: ["content-type"], + } as const, + run: () => Effect.succeed({ "content-type": "text/plain" }), + }) + expect(inputTypeScript(tool)).toContain('"foo-bar"?: string') + expect(outputTypeScript(tool)).toBe('{ "content-type": string }') + expect(outputTypeScript(tool, true)).toBe(["{", ' "content-type": string', "}"].join("\n")) + }) + + test("Effect Schema structs with non-identifier field names quote too", () => { + const tool = Tool.make({ + description: "Schema tool with awkward field names", + input: Schema.Struct({ "foo-bar": Schema.String, plain: Schema.optionalKey(Schema.Number) }), + run: () => Effect.succeed(null), + }) + expect(inputTypeScript(tool)).toBe('{ "foo-bar": string; plain?: number }') + expect(inputTypeScript(tool, true)).toBe(["{", ' "foo-bar": string', " plain?: number", "}"].join("\n")) + }) +}) + +describe("union schemas render every alternative", () => { + test("anyOf with a number branch keeps sibling alternatives", () => { + const schema = { + anyOf: [{ type: "string" }, { type: "number" }], + } as const + expect(jsonSchemaToTypeScript(schema)).toBe("string | number") + expect(jsonSchemaToTypeScript(schema, true)).toBe("string | number") + }) + + test("nullable numeric unions keep null", () => { + const schema = { + oneOf: [{ type: "number" }, { type: "null" }], + } as const + expect(jsonSchemaToTypeScript(schema)).toBe("number | null") + expect(jsonSchemaToTypeScript(schema, true)).toBe("number | null") + }) + + test("tool input and output signatures preserve numeric unions", () => { + const tool = Tool.make({ + description: "Tool with numeric unions", + input: { + type: "object", + properties: { + value: { anyOf: [{ type: "string" }, { type: "number" }] }, + }, + } as const, + output: { anyOf: [{ type: "number" }, { type: "boolean" }] } as const, + run: () => Effect.succeed(1), + }) + expect(inputTypeScript(tool)).toBe("{ value?: string | number }") + expect(outputTypeScript(tool)).toBe("number | boolean") + }) + + test("allOf renders intersections with parenthesized union members", () => { + const schema = { + allOf: [ + { type: "object", properties: { id: { type: "string" } } }, + { type: ["string", "null"] }, + ], + } as const + expect(jsonSchemaToTypeScript(schema)).toBe("{ id?: string } & (string | null)") + }) + + test("allOf does not discard an unresolved constraint", () => { + expect(jsonSchemaToTypeScript({ allOf: [{ type: "string" }, { $ref: "https://example.com/external.json" }] })).toBe( + "unknown", + ) + expect( + jsonSchemaToTypeScript({ allOf: [{ type: "string" }, { allOf: [{ $ref: "https://example.com/external.json" }] }] }), + ).toBe("unknown") + expect( + jsonSchemaToTypeScript({ + type: "string", + allOf: [{ $ref: "#/$defs/Constraint" }], + $defs: { Constraint: { description: "TypeScript-neutral constraint" } }, + }), + ).toBe("string") + }) +}) + +describe("pretty signatures in search results", () => { + const runtime = CodeMode.make({ tools: { github: { list_issues: listIssues }, orders: { lookup: lookupOrder } } }) + + const search = async (query: string) => { + const result = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`), + ) + expect(result.ok).toBe(true) + if (!result.ok) throw new Error("search failed") + return result.value as { items: Array<{ path: string; signature: string }>; total: number } + } + + test("a raw JSON Schema (MCP-style) tool's result signature carries field JSDoc and tags", async () => { + const { items } = await search("list issues repository") + const item = items.find(({ path }) => path === "tools.github.list_issues")! + expect(item.signature).toBe( + [ + "tools.github.list_issues(input: {", + " /** Repository owner */", + " owner: string", + " /** Cursor from the previous response's pageInfo */", + " after?: string", + " /**", + " * Results per page", + " * @default 30", + " */", + " perPage?: number", + " /**", + " * Filter by labels", + " * @minItems 1", + " * @maxItems 10", + " */", + " labels?: Array", + ' state?: "open" | "closed"', + "}): Promise", + ].join("\n"), + ) + }) + + test("an annotated Effect Schema tool's result signature carries field JSDoc (exact-path lookup too)", async () => { + for (const query of ["look up order", "tools.orders.lookup"]) { + const { items } = await search(query) + const item = items.find(({ path }) => path === "tools.orders.lookup")! + expect(item.signature).toBe( + [ + "tools.orders.lookup(input: {", + " /** Order identifier */", + " id: string", + " verbose?: boolean", + "}): Promise<{", + " /** Current order status */", + " status: string", + "}>", + ].join("\n"), + ) + } + }) + + test("the inline catalog line for the same tool stays single-line compact", () => { + const instructions = runtime.instructions() + expect(instructions).toContain( + ' - tools.github.list_issues(input: { owner: string; after?: string; perPage?: number; labels?: Array; state?: "open" | "closed" }): Promise // List issues in a repository', + ) + expect(instructions).toContain( + " - tools.orders.lookup(input: { id: string; verbose?: boolean }): Promise<{ status: string }> // Look up an order", + ) + expect(instructions).not.toContain("/**") + }) +}) + +describe("non-identifier tool paths", () => { + const resolveLibrary = Tool.make({ + description: "Resolve a Context7 library ID", + input: { + type: "object", + properties: { + query: { type: "string" }, + libraryName: { type: "string" }, + }, + required: ["query", "libraryName"], + } as const, + run: () => Effect.succeed("/reactjs/react.dev"), + }) + const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } }) + + test("inline catalog uses bracket notation for dashed tool names", () => { + const instructions = runtime.instructions() + + expect(instructions).toContain( + 'tools.context7["resolve-library-id"](input: { query: string; libraryName: string }): Promise', + ) + expect(instructions).toContain("Do not infer or normalize tool names") + expect(instructions).toContain("bracket notation and quotes are part of the path") + expect(instructions).not.toContain("tools.context7.resolve-library-id") + expect(instructions).not.toContain("tools.context7.resolve_library_id") + }) + + test("search results return callable bracket-notation paths and signatures", async () => { + const result = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: "resolve library" })`), + ) + expect(result.ok).toBe(true) + if (!result.ok) throw new Error("search failed") + + const value = result.value as { items: Array<{ path: string; signature: string }> } + expect(value.items[0]?.path).toBe('tools.context7["resolve-library-id"]') + expect(value.items[0]?.signature).toContain('tools.context7["resolve-library-id"](input: {') + }) +}) diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts new file mode 100644 index 0000000000..ac0e8e2e79 --- /dev/null +++ b/packages/codemode/test/stdlib.test.ts @@ -0,0 +1,495 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { CodeMode, Tool } from "../src/index.js" + +// Standard-library value types: Date, RegExp, Map, Set. Programs use them as ordinary JS; +// intra-sandbox checkpoints (Object.* helpers, spread, coercion inputs) preserve the live +// values, while at the host boundary (final result, tool arguments, JSON.stringify) they +// serialize exactly as JSON.stringify would: Date -> ISO string (invalid -> null), +// RegExp/Map/Set -> {}. +const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} })) +const value = async (code: string) => { + const result = await run(code) + if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) + return result.value +} +const error = async (code: string) => { + const result = await run(code) + if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`) + return result.error +} + +describe("Date", () => { + test("Date.now() returns a number", async () => { + expect(await value(`return typeof Date.now()`)).toBe("number") + }) + + test("epoch construction and ISO rendering", async () => { + expect(await value(`return new Date(0).toISOString()`)).toBe("1970-01-01T00:00:00.000Z") + }) + + test("string parsing round-trips", async () => { + expect(await value(`return new Date("2024-01-02T03:04:05.000Z").getTime()`)).toBe(1704164645000) + expect(await value(`return Date.parse("2024-01-02T03:04:05.000Z")`)).toBe(1704164645000) + }) + + test("date arithmetic and comparison use the time value", async () => { + expect(await value(`const a = new Date(1000); const b = new Date(3000); return b - a`)).toBe(2000) + expect(await value(`const a = new Date(1000); const b = new Date(3000); return a < b`)).toBe(true) + expect(await value(`return +new Date(42)`)).toBe(42) + }) + + test("UTC getters read calendar components", async () => { + expect( + await value( + `const d = new Date("2024-03-05T06:07:08.009Z"); return [d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), d.getUTCMilliseconds()]`, + ), + ).toEqual([2024, 2, 5, 6, 7, 8, 9]) + }) + + test("invalid dates yield NaN times, guardable in-sandbox", async () => { + expect(await value(`return Number.isNaN(new Date("garbage").getTime())`)).toBe(true) + expect(await value(`return new Date("garbage").toJSON()`)).toBeNull() + }) + + test("toISOString on an invalid date is a catchable error", async () => { + expect(await value(`try { new Date("garbage").toISOString(); return "no" } catch { return "caught" }`)).toBe( + "caught", + ) + }) + + test("template interpolation renders the ISO form", async () => { + expect(await value("return `at ${new Date(0)}`")).toBe("at 1970-01-01T00:00:00.000Z") + }) + + test("dates serialize to ISO strings at the boundary, direct and nested", async () => { + expect(await value(`return new Date(0)`)).toBe("1970-01-01T00:00:00.000Z") + expect(await value(`return { when: new Date(0), tags: [new Date(1000)] }`)).toEqual({ + when: "1970-01-01T00:00:00.000Z", + tags: ["1970-01-01T00:00:01.000Z"], + }) + expect(await value(`return JSON.stringify({ d: new Date(0) })`)).toBe('{"d":"1970-01-01T00:00:00.000Z"}') + }) + + test("coercions: Number is the time, String is ISO, Boolean is true", async () => { + expect(await value(`return Number(new Date(5))`)).toBe(5) + expect(await value(`return String(new Date(0))`)).toBe("1970-01-01T00:00:00.000Z") + expect(await value(`return Boolean(new Date(0))`)).toBe(true) + }) + + test("sorting dates with a numeric comparator", async () => { + expect( + await value(` + const dates = [new Date(3000), new Date(1000), new Date(2000)] + return dates.sort((a, b) => a - b).map((d) => d.getTime()) + `), + ).toEqual([1000, 2000, 3000]) + }) + + test("new Date(year, month, day) accepts component form", async () => { + expect(await value(`const d = new Date(2024, 0, 2); return [d.getFullYear(), d.getMonth(), d.getDate()]`)).toEqual([ + 2024, 0, 2, + ]) + }) + + test("typeof and unknown properties are forgiving", async () => { + expect(await value(`return typeof new Date(0)`)).toBe("object") + expect(await value(`return new Date(0).nope === undefined`)).toBe(true) + }) +}) + +describe("RegExp", () => { + test("literal test", async () => { + expect(await value(`return /ab+c/.test("xabbbc")`)).toBe(true) + expect(await value(`return /ab+c/.test("nope")`)).toBe(false) + }) + + test("exec exposes captures and index", async () => { + expect(await value(`const m = /a(b+)/.exec("xxabbc"); return { full: m[0], group: m[1], index: m.index }`)).toEqual( + { + full: "abb", + group: "bb", + index: 2, + }, + ) + expect(await value(`return /a/.exec("zzz")`)).toBeNull() + }) + + test("named groups read through", async () => { + expect( + await value(`const m = /(?[a-z]+)-(?\\d+)/.exec("id ab-42"); return m.groups.word + m.groups.num`), + ).toBe("ab42") + }) + + test("global exec advances lastIndex across calls", async () => { + expect( + await value(` + const r = /\\d+/g + const first = r.exec("a1b22c") + const second = r.exec("a1b22c") + return [first[0], second[0]] + `), + ).toEqual(["1", "22"]) + }) + + test("string match: non-global carries index, global lists all matches", async () => { + expect(await value(`const m = "a1b22".match(/\\d+/); return [m[0], m.index]`)).toEqual(["1", 1]) + expect(await value(`return "a1b22".match(/\\d+/g)`)).toEqual(["1", "22"]) + expect(await value(`return "abc".match(/\\d/)`)).toBeNull() + }) + + test("matchAll materializes match arrays with captures", async () => { + expect(await value(`return "a1b22".matchAll(/(\\d+)/g).map((m) => m[1])`)).toEqual(["1", "22"]) + }) + + test("replace and replaceAll with patterns and $1 substitution", async () => { + expect(await value(`return "a1b2".replace(/\\d/, "#")`)).toBe("a#b2") + expect(await value(`return "a1b2".replace(/\\d/g, "#")`)).toBe("a#b#") + expect(await value(`return "a1b2".replaceAll(/\\d/g, "#")`)).toBe("a#b#") + expect(await value(`return "hi bob".replace(/b(o)b/, "[$1]")`)).toBe("hi [o]") + }) + + test("replaceAll without the g flag is a catchable error", async () => { + expect(await value(`try { "a".replaceAll(/a/, "b"); return "no" } catch { return "caught" }`)).toBe("caught") + }) + + test("split and search accept patterns", async () => { + expect(await value(`return "a1b22c".split(/\\d+/)`)).toEqual(["a", "b", "c"]) + expect(await value(`return "ab42".search(/\\d/)`)).toBe(2) + expect(await value(`return "ab".search(/\\d/)`)).toBe(-1) + }) + + test("new RegExp constructs from strings; invalid patterns are catchable", async () => { + expect(await value(`return new RegExp("a+", "i").test("AAA")`)).toBe(true) + expect(await value(`try { new RegExp("("); return "no" } catch { return "caught" }`)).toBe("caught") + expect(await value(`return [/a/ instanceof RegExp, /a/.source]`)).toEqual([true, "a"]) + }) + + test("invalid patterns fail with actionable messages", async () => { + const fromString = await error(`return "abc".match("(")`) + expect(fromString.message).toContain('String.match received the string "("') + expect(fromString.message).toContain("escape them with a backslash") + + const fromConstructor = await error(`return new RegExp("(")`) + expect(fromConstructor.message).toContain('new RegExp(...) received "("') + expect(fromConstructor.message).toContain("escape them with a backslash") + + const fromFlags = await error(`return new RegExp("a", "xz")`) + expect(fromFlags.message).toContain('invalid flags "xz"') + expect(fromFlags.message).toContain("Valid flags are") + }) + + test("missing g-flag errors say how to fix the call", async () => { + expect((await error(`return "aa".replaceAll(/a/, "b")`)).message).toContain("write /a/g, or use String.replace") + expect((await error(`return "aa".matchAll(/a/)`)).message).toContain("write /a/g, or use String.match") + }) + + test("a non-pattern argument names the expected shapes", async () => { + const err = await error(`return "abc".match(42)`) + expect(err.message).toContain("expects a regular expression") + expect(err.message).toContain("not number") + }) + + test("source and flags properties read through", async () => { + expect(await value(`const r = /ab/gi; return { source: r.source, flags: r.flags, global: r.global }`)).toEqual({ + source: "ab", + flags: "gi", + global: true, + }) + }) + + test("regexes serialize to {} at the boundary, like JSON", async () => { + expect(await value(`return /a/`)).toEqual({}) + expect(await value(`return JSON.stringify({ r: /a/g })`)).toBe('{"r":{}}') + }) + + test("template interpolation renders the literal form", async () => { + expect(await value("return `${/ab/g}`")).toBe("/ab/g") + }) +}) + +describe("Map", () => { + test("get/set/has/size with chaining", async () => { + expect( + await value(` + const m = new Map() + m.set("a", 1).set("b", 2) + return { a: m.get("a"), b: m.get("b"), has: m.has("a"), miss: m.get("zz") === undefined, size: m.size } + `), + ).toEqual({ a: 1, b: 2, has: true, miss: true, size: 5 - 3 }) + }) + + test("object keys use identity", async () => { + expect( + await value(` + const key = { id: 1 } + const m = new Map() + m.set(key, "hit") + return [m.get(key), m.get({ id: 1 }) === undefined] + `), + ).toEqual(["hit", true]) + }) + + test("construction from entry pairs and another Map", async () => { + expect(await value(`const m = new Map([["a", 1], ["b", 2]]); return m.get("b")`)).toBe(2) + expect( + await value( + `const m = new Map([["a", 1]]); const n = new Map(m); n.set("b", 2); return [n.get("a"), n.get("b"), m.has("b")]`, + ), + ).toEqual([1, 2, false]) + expect((await error(`return new Map("nope")`)).message).toMatch(/\[key, value\] pairs/) + expect((await error(`return new Map(["flat"])`)).message).toMatch(/\[key, value\] pairs/) + }) + + test("keys/values/entries return arrays", async () => { + expect( + await value(` + const m = new Map([["a", 1], ["b", 2]]) + return { keys: m.keys(), values: m.values(), entries: m.entries() } + `), + ).toEqual({ + keys: ["a", "b"], + values: [1, 2], + entries: [ + ["a", 1], + ["b", 2], + ], + }) + }) + + test("Object.fromEntries(map) and Array.from(map)", async () => { + expect(await value(`return Object.fromEntries(new Map([["a", 1], ["b", 2]]))`)).toEqual({ a: 1, b: 2 }) + expect(await value(`return Array.from(new Map([["a", 1]]))`)).toEqual([["a", 1]]) + }) + + test("for...of iterates [key, value] pairs with destructuring", async () => { + expect( + await value(` + const m = new Map([["a", 1], ["b", 2]]) + let total = 0 + let names = "" + for (const [key, count] of m) { names += key; total += count } + return names + total + `), + ).toBe("ab3") + }) + + test("spread produces entry pairs", async () => { + expect(await value(`return [...new Map([["a", 1]])]`)).toEqual([["a", 1]]) + }) + + test("forEach passes (value, key)", async () => { + expect( + await value(` + const m = new Map([["a", 1], ["b", 2]]) + const seen = [] + m.forEach((count, key) => seen.push(key + count)) + return seen + `), + ).toEqual(["a1", "b2"]) + }) + + test("delete and clear", async () => { + expect( + await value(` + const m = new Map([["a", 1], ["b", 2]]) + const removed = m.delete("a") + const missed = m.delete("zz") + const sizeAfterDelete = m.size + m.clear() + return [removed, missed, sizeAfterDelete, m.size] + `), + ).toEqual([true, false, 1, 0]) + }) + + test("counting idiom: grouped tallies", async () => { + expect( + await value(` + const words = ["a", "b", "a", "c", "a"] + const counts = new Map() + for (const word of words) counts.set(word, (counts.get(word) ?? 0) + 1) + return Object.fromEntries(counts) + `), + ).toEqual({ a: 3, b: 1, c: 1 }) + }) + + test("maps serialize to {} at the boundary, like JSON", async () => { + expect(await value(`return new Map([["a", 1]])`)).toEqual({}) + expect(await value(`return JSON.stringify(new Map([["a", 1]]))`)).toBe("{}") + }) + + test("console.log renders map contents for debugging", async () => { + const result = await run(`console.log(new Map([["a", 1]])); return null`) + expect(result.ok).toBe(true) + expect(result.logs?.[0]).toBe(`Map(1) [["a",1]]`) + }) +}) + +describe("Set", () => { + test("add/has/delete/size with chaining", async () => { + expect( + await value(` + const s = new Set() + s.add(1).add(2).add(1) + const removed = s.delete(2) + return [s.size, s.has(1), s.has(2), removed] + `), + ).toEqual([1, true, false, true]) + }) + + test("dedupe idiom: [...new Set(items)]", async () => { + expect(await value(`return [...new Set([1, 2, 2, 3, 1])]`)).toEqual([1, 2, 3]) + }) + + test("construction from strings and other Sets", async () => { + expect(await value(`return [...new Set("aba")]`)).toEqual(["a", "b"]) + expect(await value(`return Array.from(new Set(new Set([1, 2])))`)).toEqual([1, 2]) + }) + + test("SameValueZero: NaN is findable", async () => { + expect(await value(`const s = new Set([NaN]); return s.has(NaN)`)).toBe(true) + }) + + test("for...of iterates values", async () => { + expect( + await value(` + let total = 0 + for (const n of new Set([1, 2, 3])) total += n + return total + `), + ).toBe(6) + }) + + test("sets serialize to {} at the boundary, like JSON", async () => { + expect(await value(`return { s: new Set([1]) }`)).toEqual({ s: {} }) + }) +}) + +describe("stdlib integration", () => { + test("typeof reports constructors as functions and never throws", async () => { + expect(await value(`return typeof Map`)).toBe("function") + expect(await value(`return typeof ((x) => x)`)).toBe("function") + expect(await value(`return typeof Math`)).toBe("object") + expect(await value(`return typeof tools`)).toBe("object") + }) + + test("negation works on any value", async () => { + expect(await value(`return !new Map()`)).toBe(false) + expect(await value(`const fn = () => 1; return !fn`)).toBe(false) + }) + + test("object spread of sandbox values is a no-op, like JS", async () => { + expect(await value(`return { ...new Map([["a", 1]]), kept: true }`)).toEqual({ kept: true }) + }) + + test("dates inside Map values survive in-sandbox reads", async () => { + expect( + await value(` + const m = new Map([["start", new Date(1000)]]) + return m.get("start").getTime() + `), + ).toBe(1000) + }) + + test("instanceof recognizes the stdlib value types", async () => { + expect( + await value( + `return [new Date(0) instanceof Date, /a/ instanceof RegExp, new Map() instanceof Map, new Set() instanceof Set]`, + ), + ).toEqual([true, true, true, true]) + expect( + await value(`return [[1] instanceof Array, [1] instanceof Object, ({}) instanceof Object, 5 instanceof Object]`), + ).toEqual([true, true, true, false]) + expect(await value(`return [new Map() instanceof Set, "s" instanceof Date]`)).toEqual([false, false]) + expect( + await value(`const p = Promise.resolve(1); const isPromise = p instanceof Promise; await p; return isPromise`), + ).toBe(true) + }) + + test("realistic pipeline: parse, extract with regex, dedupe, count by day", async () => { + expect( + await value(` + const raw = '[{"at":"2024-01-01T05:00:00Z","tag":"a b"},{"at":"2024-01-01T09:00:00Z","tag":"b c"},{"at":"2024-01-02T01:00:00Z","tag":"a"}]' + const rows = JSON.parse(raw) + const tags = new Set() + const byDay = new Map() + for (const row of rows) { + for (const m of row.tag.matchAll(/[a-z]+/g)) tags.add(m[0]) + const day = new Date(row.at).toISOString().slice(0, 10) + byDay.set(day, (byDay.get(day) ?? 0) + 1) + } + return { tags: [...tags].sort((a, b) => (a < b ? -1 : 1)), byDay: Object.fromEntries(byDay) } + `), + ).toEqual({ tags: ["a", "b", "c"], byDay: { "2024-01-01": 2, "2024-01-02": 1 } }) + }) +}) + +describe("sandbox values at intra-sandbox checkpoints", () => { + test("Object.values/entries keep Dates usable", async () => { + expect(await value(`return Object.values({ d: new Date(0) })[0].getTime()`)).toBe(0) + expect(await value(`const [key, d] = Object.entries({ d: new Date(0) })[0]; return key + ":" + d.getTime()`)).toBe( + "d:0", + ) + }) + + test("Object.assign keeps Maps usable", async () => { + expect(await value(`const merged = Object.assign({}, { m: new Map([["a", 1]]) }); return merged.m.get("a")`)).toBe( + 1, + ) + }) + + test("object and array spread keep sandbox values usable", async () => { + expect( + await value(` + const src = { m: new Map([["a", 1]]) } + const copy = { ...src } + copy.m.set("b", 2) + return [copy.m.get("a"), src.m.get("b")] + `), + ).toEqual([1, 2]) + expect(await value(`const list = [new Date(1000)]; const copy = [...list]; return copy[0].getTime()`)).toBe(1000) + }) + + test("Array.from over arrays keeps nested sandbox values usable", async () => { + expect(await value(`return Array.from([new Date(5)])[0].getTime()`)).toBe(5) + }) + + test("regexes stay callable through Object.values", async () => { + expect(await value(`return Object.values({ r: /ab+/ })[0].test("abb")`)).toBe(true) + }) + + test("Object.* helpers see sandbox values as empty objects, never internals", async () => { + expect(await value(`return Object.keys(new Map([["a", 1]]))`)).toEqual([]) + expect(await value(`return Object.values(new Date(0))`)).toEqual([]) + expect(await value(`return Object.entries(new Set([1]))`)).toEqual([]) + expect(await value(`return Object.assign({}, new Map([["a", 1]]))`)).toEqual({}) + expect(await value(`return Object.hasOwn(new Date(0), "time")`)).toBe(false) + }) + + test("the host boundary still serializes JSON forms: results, JSON.stringify, and tool arguments", async () => { + expect(await value(`return { d: new Date(0), m: new Map([["a", 1]]) }`)).toEqual({ + d: "1970-01-01T00:00:00.000Z", + m: {}, + }) + expect(await value(`return JSON.stringify({ d: new Date(0) })`)).toBe('{"d":"1970-01-01T00:00:00.000Z"}') + + const observed: Array = [] + const capture = Tool.make({ + description: "Capture the exact input the host receives", + input: { type: "object" }, + run: (input) => + Effect.sync(() => { + observed.push(input) + return "ok" + }), + }) + const result = await Effect.runPromise( + CodeMode.execute({ + tools: { host: { capture } }, + code: `return await tools.host.capture({ when: new Date(0), tags: new Map([["a", 1]]) })`, + }), + ) + expect(result.ok).toBe(true) + expect(observed).toStrictEqual([{ when: "1970-01-01T00:00:00.000Z", tags: {} }]) + }) +}) diff --git a/packages/codemode/tsconfig.json b/packages/codemode/tsconfig.json new file mode 100644 index 0000000000..fe5c4d217b --- /dev/null +++ b/packages/codemode/tsconfig.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "noUncheckedIndexedAccess": false + } +} diff --git a/packages/core/package.json b/packages/core/package.json index 56bcbb53ba..84a51e61d2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -90,6 +90,7 @@ "@ff-labs/fff-bun": "0.9.4", "@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:*", diff --git a/packages/core/schema.json b/packages/core/schema.json index e8a2502a34..d9e0b20b22 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,9 +1,9 @@ { "version": "7", "dialect": "sqlite", - "id": "22e57fed-b9b8-4e94-a3b4-f94bece680a8", + "id": "96e9fe64-d810-4102-8f79-3317a88bb6d2", "prevIds": [ - "f14a9b18-8207-487e-a3d3-227e629ba9ad" + "22e57fed-b9b8-4e94-a3b4-f94bece680a8" ], "ddl": [ { @@ -526,6 +526,16 @@ "entityType": "columns", "table": "event" }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created", + "entityType": "columns", + "table": "event" + }, { "type": "text", "notNull": true, diff --git a/packages/core/src/account.ts b/packages/core/src/account.ts index d364d6f344..7e81780e95 100644 --- a/packages/core/src/account.ts +++ b/packages/core/src/account.ts @@ -1,7 +1,7 @@ export * as AccountV2 from "./account" import { Schema } from "effect" -import type * as HttpClientError from "effect/unstable/http/HttpClientError" +import type { HttpClientError } from "effect/unstable/http" export const ID = Schema.String.pipe(Schema.brand("AccountID")) export type ID = Schema.Schema.Type diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 78688ce12c..ab34db8ea4 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -1,12 +1,11 @@ export * as Catalog from "./catalog" import { makeLocationNode } from "./effect/app-node" -import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect" +import { Array, Context, Effect, Layer, Option, Order, pipe } from "effect" import { Catalog } from "@opencode-ai/schema/catalog" import { ModelV2 } from "./model" import { ProviderV2 } from "./provider" import { EventV2 } from "./event" -import { Policy } from "./policy" import { State } from "./state" import { Integration } from "./integration" @@ -17,8 +16,6 @@ export type ProviderRecord = { export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID } -export const PolicyActions = Schema.Literals(["provider.use"]) - export const Event = Catalog.Event type Data = { @@ -65,7 +62,6 @@ const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2.Service - const policy = yield* Policy.Service const integrations = yield* Integration.Service const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => { @@ -159,13 +155,6 @@ const layer = Layer.effect( return result }, finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) { - if (policy.hasStatements()) { - for (const record of [...catalog.provider.list()]) { - if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") { - catalog.provider.remove(record.provider.id) - } - } - } yield* events.publish(Event.Updated, {}) }), }) @@ -294,4 +283,4 @@ const layer = Layer.effect( const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/ -export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Policy.node, Integration.node] }) +export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Integration.node] }) diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 6c295cc06e..598c6fd369 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -3,18 +3,19 @@ export * as Config from "./config" import { makeLocationNode } from "./effect/app-node" import path from "path" import { type ParseError, parse } from "jsonc-parser" -import { Context, Effect, Layer, Option, Schema } from "effect" +import { Context, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect" import { Permission } from "@opencode-ai/schema/permission" +import { Config as ConfigSchema } from "@opencode-ai/schema/config" +import { EventV2 } from "./event" +import { Watcher } from "./filesystem/watcher" import { FSUtil } from "./fs-util" import { Global } from "./global" import { Location } from "./location" -import { Policy } from "./policy" import { AbsolutePath } from "./schema" import { ConfigAgent } from "./config/agent" import { ConfigAttachments } from "./config/attachments" import { ConfigCompaction } from "./config/compaction" import { ConfigCommand } from "./config/command" -import { ConfigExperimental } from "./config/experimental" import { ConfigFormatter } from "./config/formatter" import { ConfigLSP } from "./config/lsp" import { ConfigMCP } from "./config/mcp" @@ -22,6 +23,7 @@ import { ConfigPlugin } from "./config/plugin" import { ConfigProvider } from "./config/provider" import { ConfigReference } from "./config/reference" import { ConfigToolOutput } from "./config/tool-output" +import { ConfigVariable } from "./config/variable" import { ConfigVcs } from "./config/vcs" import { ConfigWatcher } from "./config/watcher" import { ConfigV1 } from "./v1/config/config" @@ -101,13 +103,12 @@ export class Info extends Schema.Class("Config.Info")({ description: "Named local directories or Git repositories available as external context", }), plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({ - description: "Ordered external plugin packages to load", + description: "Ordered plugin enablement directives and external package declarations", }), vcs: ConfigVcs.Info.pipe(Schema.optional).annotate({ description: "Plugin-provided VCS backends keyed by type; detection markers are only honored in global configuration", }), - experimental: ConfigExperimental.Experimental.pipe(Schema.optional), providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional), }) {} @@ -143,7 +144,8 @@ const layer = Layer.effect( const fs = yield* FSUtil.Service const global = yield* Global.Service const location = yield* Location.Service - const policy = yield* Policy.Service + const watcher = yield* Watcher.Service + const events = yield* EventV2.Service const names = ["opencode.json", "opencode.jsonc"] const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions) @@ -152,9 +154,10 @@ const layer = Layer.effect( const loadFile = Effect.fnUntraced(function* (filepath: string) { const text = yield* fs.readFileStringSafe(filepath) if (!text) return + const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text }) const errors: ParseError[] = [] - const input: unknown = parse(text, errors, { allowTrailingComma: true }) + const input: unknown = parse(substituted, errors, { allowTrailingComma: true }) if (errors.length) return const info = Option.getOrUndefined( @@ -175,45 +178,78 @@ const layer = Layer.effect( ] }) - const globalDirectory = AbsolutePath.make(global.config) - const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config) - // Read configuration once when this location opens. Later calls reuse these - // values until the location is reopened. - const discovered = locationIsGlobal - ? [] - : yield* fs - .up({ - targets: [".opencode", ...names.toReversed()], - start: location.directory, - stop: location.project.directory, - }) - .pipe(Effect.orDie) - const directories = [ - globalDirectory, - ...discovered - .filter((item) => path.basename(item) === ".opencode") - .toReversed() - .map((directory) => AbsolutePath.make(directory)), + const discover = Effect.fn("Config.discover")(function* () { + const globalDirectory = AbsolutePath.make(global.config) + const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config) + const discovered = locationIsGlobal + ? [] + : yield* fs + .up({ + targets: [".opencode", ...names.toReversed()], + start: location.directory, + stop: location.project.directory, + }) + .pipe(Effect.orDie) + const directories = [ + globalDirectory, + ...discovered + .filter((item) => path.basename(item) === ".opencode") + .toReversed() + .map((directory) => AbsolutePath.make(directory)), + ] + const directPaths = discovered.filter((item) => path.basename(item) !== ".opencode").toReversed() + const direct = yield* Effect.forEach(directPaths, loadFile).pipe( + Effect.orDie, + Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)), + ) + const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie) + return { + entries: [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()], + directories, + files: directPaths, + } + }) + + const initial = yield* discover() + let configs = initial.entries + const updates = yield* PubSub.unbounded() + const subscriptions = new Map>() + const targets = (snapshot: typeof initial) => [ + ...snapshot.directories.map((path) => ({ path, type: "directory" as const })), + ...snapshot.files + .filter((file) => !snapshot.directories.some((directory) => FSUtil.contains(directory, file))) + .map((path) => ({ path, type: "file" as const })), ] - // A config closer to the opened directory should win over one higher up. - // Search starts nearby, so reverse the results before applying them. - const directPaths = discovered.filter((item) => path.basename(item) !== ".opencode").toReversed() - const direct = yield* Effect.forEach(directPaths, loadFile).pipe( - Effect.orDie, - Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)), - ) - const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie) - // Apply general settings first and more specific settings last: - // global config, project files, then `.opencode` files. - const configs = [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()] - // Rules use the opposite order so a user-global rule can override a - // repository rule. Statement order inside each file stays unchanged. - yield* policy.load( - configs - .filter((config): config is Document => config.type === "document") - .toReversed() - .flatMap((config) => config.info.experimental?.policies ?? []), + const reconcile = Effect.fn("Config.reconcileWatches")(function* (snapshot: typeof initial) { + const next = new Map(targets(snapshot).map((target) => [JSON.stringify(target), target])) + for (const [key, stop] of subscriptions) { + if (next.has(key)) continue + yield* stop + subscriptions.delete(key) + } + for (const [key, target] of next) { + if (subscriptions.has(key)) continue + const fiber = yield* watcher.subscribe(target).pipe( + Stream.runForEach((update) => PubSub.publish(updates, update)), + Effect.forkScoped({ startImmediately: true }), + ) + subscriptions.set(key, Fiber.interrupt(fiber)) + } + }) + + yield* Stream.fromPubSub(updates).pipe( + Stream.debounce("100 millis"), + Stream.runForEach((update) => + Effect.gen(function* () { + const next = yield* discover() + configs = next.entries + yield* reconcile(next) + yield* events.publish(ConfigSchema.Event.Updated, {}) + }).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause }))), + ), + Effect.forkScoped({ startImmediately: true }), ) + yield* reconcile(initial) return Service.of({ entries: Effect.fn("Config.entries")(function* () { @@ -226,5 +262,5 @@ const layer = Layer.effect( export const node = makeLocationNode({ service: Service, layer, - deps: [FSUtil.node, Global.node, Location.node, Policy.node], + deps: [Watcher.node, EventV2.node, FSUtil.node, Global.node, Location.node], }) diff --git a/packages/core/src/config/experimental.ts b/packages/core/src/config/experimental.ts deleted file mode 100644 index 12a02635db..0000000000 --- a/packages/core/src/config/experimental.ts +++ /dev/null @@ -1,18 +0,0 @@ -export * as ConfigExperimental from "./experimental" - -import { Schema } from "effect" -import { Catalog } from "../catalog" -import { Policy as PolicyV2 } from "../policy" - -// Each core domain exports the policy actions it supports. Adding an action to -// this union makes it valid in authored config while keeping Policy generic. -export const PolicyAction = Schema.Union([Catalog.PolicyActions]) - -export class Policy extends Schema.Class("ConfigV2.Experimental.Policy")({ - ...PolicyV2.Info.fields, - action: PolicyAction, -}) {} - -export class Experimental extends Schema.Class("ConfigV2.Experimental")({ - policies: Policy.pipe(Schema.Array, Schema.optional), -}) {} diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index 48efe75804..fa8b61ce18 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -1,8 +1,8 @@ export * as ConfigAgentPlugin from "./agent" -import { define } from "../../plugin/internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import path from "path" -import { Effect, Option, Schema } from "effect" +import { Effect, Option, Schema, Stream } from "effect" import { AgentV2 } from "../../agent" import { Config } from "../../config" import { ConfigAgent } from "../agent" @@ -34,68 +34,79 @@ const agentKeys = new Set([ ]) export const Plugin = define({ - id: "config-agent", + id: "opencode.config.agent", effect: Effect.fn(function* (ctx) { const config = yield* Config.Service const fs = yield* FSUtil.Service - yield* ctx.agent.transform( - Effect.fn(function* (draft) { - const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { - if (entry.type === "document") return Effect.succeed([entry]) - return Effect.gen(function* () { - const files = yield* discover(fs, entry.path) - return yield* Effect.forEach(files, (file) => - fs.readFileStringSafe(file.filepath).pipe( - Effect.map((content) => content && decode(file, content)), - Effect.catch(() => Effect.succeed(undefined)), - ), - ).pipe( - Effect.map((documents) => - documents.filter((document): document is Config.Document => document !== undefined), - ), - ) - }) - }).pipe(Effect.map((documents) => documents.flat())) - const global = documents.flatMap((document) => document.info.permissions ?? []) - const configuredDefault = Config.latest(documents, "default_agent") - if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault)) - for (const current of draft.list()) { - draft.update(current.id, (agent) => agent.permissions.push(...global)) - } + const load = Effect.fn("ConfigAgentPlugin.load")(function* () { + return yield* Effect.forEach(yield* config.entries(), (entry) => { + if (entry.type === "document") return Effect.succeed([entry]) + return Effect.gen(function* () { + const files = yield* discover(fs, entry.path) + return yield* Effect.forEach(files, (file) => + fs.readFileStringSafe(file.filepath).pipe( + Effect.map((content) => content && decode(file, content)), + Effect.catch(() => Effect.succeed(undefined)), + ), + ).pipe( + Effect.map((documents) => + documents.filter((document): document is Config.Document => document !== undefined), + ), + ) + }) + }).pipe(Effect.map((documents) => documents.flat())) + }) + const loaded = { documents: yield* load() } + yield* ctx.agent.transform((draft) => { + const global = loaded.documents.flatMap((document) => document.info.permissions ?? []) + const configuredDefault = Config.latest(loaded.documents, "default_agent") + if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault)) + for (const current of draft.list()) { + draft.update(current.id, (agent) => agent.permissions.push(...global)) + } - for (const document of documents) { - for (const [id, item] of Object.entries(document.info.agents ?? {})) { - const agentID = AgentV2.ID.make(id) - if (item.disabled) { - draft.remove(agentID) - continue - } - - const exists = draft.get(agentID) !== undefined - draft.update(agentID, (agent) => { - if (!exists) agent.permissions.push(...global) - if (item.model !== undefined) { - const model = ModelV2.parse(item.model) - agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant } - } - if (item.variant !== undefined && agent.model !== undefined) { - agent.model.variant = ModelV2.VariantID.make(item.variant) - } - if (item.request !== undefined) { - Object.assign(agent.request.headers, item.request.headers ?? {}) - Object.assign(agent.request.body, item.request.body ?? {}) - } - if (item.system !== undefined) agent.system = item.system - if (item.description !== undefined) agent.description = item.description - if (item.mode !== undefined) agent.mode = item.mode - if (item.hidden !== undefined) agent.hidden = item.hidden - if (item.color !== undefined) agent.color = item.color - if (item.steps !== undefined) agent.steps = item.steps - if (item.permissions !== undefined) agent.permissions.push(...item.permissions) - }) + for (const document of loaded.documents) { + for (const [id, item] of Object.entries(document.info.agents ?? {})) { + const agentID = AgentV2.ID.make(id) + if (item.disabled) { + draft.remove(agentID) + continue } + + const exists = draft.get(agentID) !== undefined + draft.update(agentID, (agent) => { + if (!exists) agent.permissions.push(...global) + if (item.model !== undefined) { + const model = ModelV2.parse(item.model) + agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant } + } + if (item.variant !== undefined && agent.model !== undefined) { + agent.model.variant = ModelV2.VariantID.make(item.variant) + } + if (item.request !== undefined) { + Object.assign(agent.request.headers, item.request.headers ?? {}) + Object.assign(agent.request.body, item.request.body ?? {}) + } + if (item.system !== undefined) agent.system = item.system + if (item.description !== undefined) agent.description = item.description + if (item.mode !== undefined) agent.mode = item.mode + if (item.hidden !== undefined) agent.hidden = item.hidden + if (item.color !== undefined) agent.color = item.color + if (item.steps !== undefined) agent.steps = item.steps + if (item.permissions !== undefined) agent.permissions.push(...item.permissions) + }) } - }), + } + }) + yield* ctx.event.subscribe().pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runForEach(() => + load().pipe( + Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))), + Effect.andThen(ctx.agent.reload()), + ), + ), + Effect.forkScoped({ startImmediately: true }), ) }), }) diff --git a/packages/core/src/config/plugin/command.ts b/packages/core/src/config/plugin/command.ts index f9b31f8e45..a7babbb5aa 100644 --- a/packages/core/src/config/plugin/command.ts +++ b/packages/core/src/config/plugin/command.ts @@ -1,8 +1,8 @@ export * as ConfigCommandPlugin from "./command" -import { define } from "../../plugin/internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import path from "path" -import { Effect, Option, Schema } from "effect" +import { Effect, Option, Schema, Stream } from "effect" import { CommandV2 } from "../../command" import { Config } from "../../config" import { FSUtil } from "../../fs-util" @@ -13,38 +13,49 @@ import { ConfigMarkdown } from "../markdown" const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info) export const Plugin = define({ - id: "config-command", + id: "opencode.config.command", effect: Effect.fn(function* (ctx) { const config = yield* Config.Service const fs = yield* FSUtil.Service - yield* ctx.command.transform( - Effect.fn(function* (draft) { - const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { - if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }]) - return loadDirectory(fs, entry.path).pipe( - Effect.map((commands) => [ - { commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }, - ]), - ) - }).pipe(Effect.map((documents) => documents.flat())) - for (const document of documents) { - for (const [name, command] of Object.entries(document.commands ?? {})) { - draft.update(name, (item) => { - item.template = command.template - if (command.description !== undefined) item.description = command.description - if (command.agent !== undefined) item.agent = command.agent - if (command.model !== undefined) { - const model = ModelV2.parse(command.model) - item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant } - } - if (command.variant !== undefined && item.model !== undefined) { - item.model.variant = ModelV2.VariantID.make(command.variant) - } - if (command.subtask !== undefined) item.subtask = command.subtask - }) - } + const load = Effect.fn("ConfigCommandPlugin.load")(function* () { + return yield* Effect.forEach(yield* config.entries(), (entry) => { + if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }]) + return loadDirectory(fs, entry.path).pipe( + Effect.map((commands) => [ + { commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }, + ]), + ) + }).pipe(Effect.map((documents) => documents.flat())) + }) + const loaded = { documents: yield* load() } + yield* ctx.command.transform((draft) => { + for (const document of loaded.documents) { + for (const [name, command] of Object.entries(document.commands ?? {})) { + draft.update(name, (item) => { + item.template = command.template + if (command.description !== undefined) item.description = command.description + if (command.agent !== undefined) item.agent = command.agent + if (command.model !== undefined) { + const model = ModelV2.parse(command.model) + item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant } + } + if (command.variant !== undefined && item.model !== undefined) { + item.model.variant = ModelV2.VariantID.make(command.variant) + } + if (command.subtask !== undefined) item.subtask = command.subtask + }) } - }), + } + }) + yield* ctx.event.subscribe().pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runForEach(() => + load().pipe( + Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))), + Effect.andThen(ctx.command.reload()), + ), + ), + Effect.forkScoped({ startImmediately: true }), ) }), }) diff --git a/packages/core/src/config/plugin/external.ts b/packages/core/src/config/plugin/external.ts deleted file mode 100644 index 22f49bbf16..0000000000 --- a/packages/core/src/config/plugin/external.ts +++ /dev/null @@ -1,134 +0,0 @@ -export * as ConfigExternalPlugin from "./external" - -import type { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect" -import type { Plugin as PromisePlugin } from "@opencode-ai/plugin/v2/promise" -import { Effect, Schema } from "effect" -import path from "path" -import { fileURLToPath, pathToFileURL } from "url" -import { Config } from "../../config" -import { FSUtil } from "../../fs-util" -import { Location } from "../../location" -import { Npm } from "../../npm" -import { define } from "../../plugin/internal" -import { PluginPromise } from "../../plugin/promise" - -const PluginModule = Schema.Struct({ - default: Schema.Union([ - Schema.Struct({ - id: Schema.String, - effect: Schema.declare( - (input): input is EffectPlugin["effect"] => typeof input === "function", - ), - }), - Schema.Struct({ - id: Schema.String, - setup: Schema.declare( - (input): input is PromisePlugin["setup"] => typeof input === "function", - ), - }), - ]), -}) - -const PluginPackage = Schema.Struct({ - exports: Schema.optional(Schema.Unknown), - main: Schema.optional(Schema.String), - module: Schema.optional(Schema.String), -}) - -export const Plugin = define({ - id: "config-plugin", - effect: Effect.fn(function* (ctx) { - const config = yield* Config.Service - const fs = yield* FSUtil.Service - const location = yield* Location.Service - const npm = yield* Npm.Service - yield* Effect.gen(function* () { - const configured: { package: string; options?: Record }[] = [] - - for (const entry of yield* config.entries()) { - if (entry.type === "document") { - const directory = entry.path ? path.dirname(entry.path) : location.directory - for (const item of entry.info.plugins ?? []) { - const ref = typeof item === "string" ? { package: item } : item - const packageName = (() => { - if (ref.package.startsWith("file://")) return fileURLToPath(ref.package) - if (ref.package.startsWith("./") || ref.package.startsWith("../")) { - return path.resolve(directory, ref.package) - } - return ref.package - })() - configured.push({ package: packageName, options: ref.options }) - } - } - - if (entry.type === "directory") { - const files = yield* fs - .glob("{plugin,plugins}/*.{ts,js}", { - cwd: entry.path, - absolute: true, - include: "file", - dot: true, - symlink: true, - }) - .pipe(Effect.orElseSucceed(() => [])) - const directories = yield* fs - .glob("{plugin,plugins}/*", { - cwd: entry.path, - absolute: true, - include: "all", - dot: true, - symlink: true, - }) - .pipe( - Effect.flatMap((items) => - Effect.filter(items, (item) => fs.isDir(item), { - concurrency: "unbounded", - }), - ), - Effect.orElseSucceed(() => []), - ) - const packages = yield* Effect.forEach( - directories.sort(), - (directory) => resolvePackageEntrypoint(fs, directory), - { concurrency: "unbounded" }, - ).pipe(Effect.map((items) => items.filter((item): item is string => item !== undefined))) - files.sort() - for (const file of files) configured.push({ package: file }) - for (const file of packages) configured.push({ package: file }) - } - } - - for (const ref of configured) { - yield* Effect.gen(function* () { - const entrypoint = path.isAbsolute(ref.package) - ? pathToFileURL(ref.package).href - : (yield* npm.add(ref.package)).entrypoint - if (!entrypoint) return - yield* Effect.log({ msg: "loading plugin", id: ref.package, entrypoint }) - const mod = yield* Effect.promise(() => import(entrypoint)) - const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default - const plugin = "effect" in value ? value : PluginPromise.fromPromise(value) - yield* ctx.plugin.add({ - id: plugin.id, - effect: (host) => plugin.effect({ ...host, options: ref.options ?? {} }), - }) - }).pipe(Effect.ignoreCause) - } - }) - }), -}) - -const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string) { - const pkg = yield* fs.readJson(path.join(directory, "package.json")).pipe( - Effect.flatMap(Schema.decodeUnknownEffect(PluginPackage)), - Effect.catch(() => Effect.succeed(undefined)), - ) - const exported = typeof pkg?.exports === "string" ? pkg.exports : undefined - const entries = [exported, pkg?.module, pkg?.main, "index.ts", "index.js"] - - return yield* Effect.forEach(entries, (entry) => { - if (!entry) return Effect.succeed(undefined) - const file = path.resolve(directory, entry) - return fs.isFile(file).pipe(Effect.map((exists) => (exists ? file : undefined))) - }).pipe(Effect.map((items) => items.find((item): item is string => item !== undefined))) -}) diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index d8ec3a58ae..320895d128 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -1,116 +1,123 @@ export * as ConfigProviderPlugin from "./provider" -import { define } from "../../plugin/internal" -import { Effect } from "effect" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { Effect, Stream } from "effect" import { Config } from "../../config" import { ModelV2 } from "../../model" export const Plugin = define({ - id: "config-provider", + id: "opencode.config.provider", effect: Effect.fn(function* (ctx) { const config = yield* Config.Service - yield* ctx.integration.transform( - Effect.fn(function* (integrations) { - const files = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document") - const configuredIntegrations = new Set( - files.flatMap((file) => - Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => - provider.env === undefined ? [] : [id], - ), + const loaded = { entries: yield* config.entries() } + yield* ctx.integration.transform((integrations) => { + const files = loaded.entries.filter((entry): entry is Config.Document => entry.type === "document") + const configuredIntegrations = new Set( + files.flatMap((file) => + Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => + provider.env === undefined ? [] : [id], ), - ) - for (const file of files) { - for (const [id, item] of Object.entries(file.info.providers ?? {})) { - const integrationID = id - if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue - integrations.update(integrationID, (integration) => { - integration.name = item.name ?? integration.name + ), + ) + for (const file of files) { + for (const [id, item] of Object.entries(file.info.providers ?? {})) { + const integrationID = id + if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue + integrations.update(integrationID, (integration) => { + integration.name = item.name ?? integration.name + }) + if (item.env !== undefined) { + integrations.method.update({ + integrationID, + method: { type: "env", names: [...item.env] }, }) - if (item.env !== undefined) { - integrations.method.update({ - integrationID, - method: { type: "env", names: [...item.env] }, - }) - } } } - }), - ) + } + }) - yield* ctx.catalog.transform( - Effect.fn(function* (catalog) { - const entries = yield* config.entries() - const files = entries.filter((entry): entry is Config.Document => entry.type === "document") - const configuredDefault = Config.latest(entries, "model") - if (configuredDefault !== undefined) { - const model = ModelV2.parse(configuredDefault) - catalog.model.default.set(model.providerID, model.modelID) - } - for (const file of files) { - for (const [id, item] of Object.entries(file.info.providers ?? {})) { - const providerID = id - catalog.provider.update(providerID, (provider) => { - if (item.name !== undefined) provider.name = item.name - if (item.api !== undefined) provider.api = { ...item.api } - if (item.request !== undefined) { - Object.assign(provider.request.settings, item.request.settings) - Object.assign(provider.request.headers, item.request.headers) - Object.assign(provider.request.body, item.request.body) - } - }) - for (const [id, config] of Object.entries(item.models ?? {})) { - catalog.model.update(providerID, id, (model) => { - if (config.family !== undefined) model.family = config.family - if (config.name !== undefined) model.name = config.name - if (config.api !== undefined) model.api = { ...model.api, ...config.api } - if (config.capabilities !== undefined) { - model.capabilities = { - tools: config.capabilities.tools, - input: [...config.capabilities.input], - output: [...config.capabilities.output], - } - } - if (config.request !== undefined) { - Object.assign(model.request.settings, config.request.settings) - Object.assign(model.request.headers, config.request.headers) - Object.assign(model.request.body, config.request.body) - if (config.request.variant !== undefined) model.request.variant = config.request.variant - } - if (config.variants !== undefined) { - for (const variant of config.variants) { - let existing = model.variants.find((item) => item.id === variant.id) - if (!existing) { - existing = { - id: variant.id, - settings: {}, - headers: {}, - body: {}, - } - model.variants.push(existing) - } - Object.assign(existing.settings, variant.settings) - Object.assign(existing.headers, variant.headers) - Object.assign(existing.body, variant.body) - } - } - if (config.cost !== undefined) { - model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({ - tier: cost.tier && { ...cost.tier }, - input: cost.input, - output: cost.output, - cache: { - read: cost.cache?.read ?? 0, - write: cost.cache?.write ?? 0, - }, - })) - } - if (config.disabled !== undefined) model.enabled = !config.disabled - if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit } - }) + yield* ctx.catalog.transform((catalog) => { + const files = loaded.entries.filter((entry): entry is Config.Document => entry.type === "document") + const configuredDefault = Config.latest(loaded.entries, "model") + if (configuredDefault !== undefined) { + const model = ModelV2.parse(configuredDefault) + catalog.model.default.set(model.providerID, model.modelID) + } + for (const file of files) { + for (const [id, item] of Object.entries(file.info.providers ?? {})) { + const providerID = id + catalog.provider.update(providerID, (provider) => { + if (item.name !== undefined) provider.name = item.name + if (item.api !== undefined) provider.api = { ...item.api } + if (item.request !== undefined) { + Object.assign(provider.request.settings, item.request.settings) + Object.assign(provider.request.headers, item.request.headers) + Object.assign(provider.request.body, item.request.body) } + }) + for (const [id, config] of Object.entries(item.models ?? {})) { + catalog.model.update(providerID, id, (model) => { + if (config.family !== undefined) model.family = config.family + if (config.name !== undefined) model.name = config.name + if (config.api !== undefined) model.api = { ...model.api, ...config.api } + if (config.capabilities !== undefined) { + model.capabilities = { + tools: config.capabilities.tools, + input: [...config.capabilities.input], + output: [...config.capabilities.output], + } + } + if (config.request !== undefined) { + Object.assign(model.request.settings, config.request.settings) + Object.assign(model.request.headers, config.request.headers) + Object.assign(model.request.body, config.request.body) + if (config.request.variant !== undefined) model.request.variant = config.request.variant + } + if (config.variants !== undefined) { + for (const variant of config.variants) { + let existing = model.variants.find((item) => item.id === variant.id) + if (!existing) { + existing = { + id: variant.id, + settings: {}, + headers: {}, + body: {}, + } + model.variants.push(existing) + } + Object.assign(existing.settings, variant.settings) + Object.assign(existing.headers, variant.headers) + Object.assign(existing.body, variant.body) + } + } + if (config.cost !== undefined) { + model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({ + tier: cost.tier && { ...cost.tier }, + input: cost.input, + output: cost.output, + cache: { + read: cost.cache?.read ?? 0, + write: cost.cache?.write ?? 0, + }, + })) + } + if (config.disabled !== undefined) model.enabled = !config.disabled + if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit } + }) } } - }), + } + }) + yield* ctx.event.subscribe().pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runForEach(() => + config.entries().pipe( + Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))), + Effect.andThen(ctx.integration.reload()), + Effect.andThen(ctx.catalog.reload()), + ), + ), + Effect.forkScoped({ startImmediately: true }), ) }), }) diff --git a/packages/core/src/config/plugin/reference.ts b/packages/core/src/config/plugin/reference.ts index d5fa53c411..75e0cc10e4 100644 --- a/packages/core/src/config/plugin/reference.ts +++ b/packages/core/src/config/plugin/reference.ts @@ -1,8 +1,8 @@ export * as ConfigReferencePlugin from "./reference" -import { define } from "../../plugin/internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import path from "path" -import { Effect } from "effect" +import { Effect, Stream } from "effect" import { Config } from "../../config" import { ConfigReference } from "../reference" import { Reference } from "../../reference" @@ -11,45 +11,52 @@ import { Global } from "../../global" import { Location } from "../../location" export const Plugin = define({ - id: "core/config-reference", + id: "opencode.config.reference", effect: Effect.fn(function* (ctx) { const config = yield* Config.Service const location = yield* Location.Service const global = yield* Global.Service - yield* ctx.reference.transform( - Effect.fn(function* (draft) { - const entries = new Map() - for (const doc of (yield* config.entries()).filter( - (entry): entry is Config.Document => entry.type === "document", - )) { - const directory = doc.path ? path.dirname(doc.path) : location.directory - for (const [name, entry] of Object.entries(doc.info.references ?? {})) { - if (!validAlias(name)) continue - const description = typeof entry === "string" ? undefined : entry.description - const hidden = typeof entry === "string" ? undefined : entry.hidden - entries.set( - name, - local(entry) - ? Reference.LocalSource.make({ - type: "local", - path: AbsolutePath.make( - localPath(directory, global.home, typeof entry === "string" ? entry : entry.path), - ), - ...(description === undefined ? {} : { description }), - ...(hidden === undefined ? {} : { hidden }), - }) - : Reference.GitSource.make({ - type: "git", - repository: typeof entry === "string" ? entry : entry.repository, - ...(entry.branch === undefined ? {} : { branch: entry.branch }), - ...(description === undefined ? {} : { description }), - ...(hidden === undefined ? {} : { hidden }), - }), - ) - } + const loaded = { entries: yield* config.entries() } + yield* ctx.reference.transform((draft) => { + const entries = new Map() + for (const doc of loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")) { + const directory = doc.path ? path.dirname(doc.path) : location.directory + for (const [name, entry] of Object.entries(doc.info.references ?? {})) { + if (!validAlias(name)) continue + const description = typeof entry === "string" ? undefined : entry.description + const hidden = typeof entry === "string" ? undefined : entry.hidden + entries.set( + name, + local(entry) + ? Reference.LocalSource.make({ + type: "local", + path: AbsolutePath.make( + localPath(directory, global.home, typeof entry === "string" ? entry : entry.path), + ), + ...(description === undefined ? {} : { description }), + ...(hidden === undefined ? {} : { hidden }), + }) + : Reference.GitSource.make({ + type: "git", + repository: typeof entry === "string" ? entry : entry.repository, + ...(entry.branch === undefined ? {} : { branch: entry.branch }), + ...(description === undefined ? {} : { description }), + ...(hidden === undefined ? {} : { hidden }), + }), + ) } - for (const [name, source] of entries) draft.add(name, source) - }), + } + for (const [name, source] of entries) draft.add(name, source) + }) + yield* ctx.event.subscribe().pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runForEach(() => + config.entries().pipe( + Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))), + Effect.andThen(ctx.reference.reload()), + ), + ), + Effect.forkScoped({ startImmediately: true }), ) }), }) diff --git a/packages/core/src/config/plugin/skill.ts b/packages/core/src/config/plugin/skill.ts index 765992a765..84546b901c 100644 --- a/packages/core/src/config/plugin/skill.ts +++ b/packages/core/src/config/plugin/skill.ts @@ -1,8 +1,8 @@ export * as ConfigSkillPlugin from "./skill" -import { define } from "../../plugin/internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import path from "path" -import { Effect } from "effect" +import { Effect, Stream } from "effect" import { Config } from "../../config" import { AbsolutePath } from "../../schema" import { SkillV2 } from "../../skill" @@ -10,41 +10,49 @@ import { Global } from "../../global" import { Location } from "../../location" export const Plugin = define({ - id: "config-skill", + id: "opencode.config.skill", effect: Effect.fn(function* (ctx) { const config = yield* Config.Service const global = yield* Global.Service const location = yield* Location.Service - yield* ctx.skill.transform( - Effect.fn(function* (draft) { - const entries = yield* config.entries() - const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) - const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : [])) - for (const directory of directories) { - draft.source( - SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }), - ) - draft.source( - SkillV2.DirectorySource.make({ - type: "directory", - path: AbsolutePath.make(path.join(directory, "skills")), - }), - ) + const loaded = { entries: yield* config.entries() } + yield* ctx.skill.transform((draft) => { + const directories = loaded.entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) + const items = loaded.entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : [])) + for (const directory of directories) { + draft.source( + SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }), + ) + draft.source( + SkillV2.DirectorySource.make({ + type: "directory", + path: AbsolutePath.make(path.join(directory, "skills")), + }), + ) + } + for (const item of items) { + if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) { + draft.source(SkillV2.UrlSource.make({ type: "url", url: item })) + continue } - for (const item of items) { - if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) { - draft.source(SkillV2.UrlSource.make({ type: "url", url: item })) - continue - } - const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item - draft.source( - SkillV2.DirectorySource.make({ - type: "directory", - path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)), - }), - ) - } - }), + const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item + draft.source( + SkillV2.DirectorySource.make({ + type: "directory", + path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)), + }), + ) + } + }) + yield* ctx.event.subscribe().pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runForEach(() => + config.entries().pipe( + Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))), + Effect.andThen(ctx.skill.reload()), + ), + ), + Effect.forkScoped({ startImmediately: true }), ) }), }) diff --git a/packages/core/src/config/variable.ts b/packages/core/src/config/variable.ts new file mode 100644 index 0000000000..3bb1d22dd3 --- /dev/null +++ b/packages/core/src/config/variable.ts @@ -0,0 +1,85 @@ +export * as ConfigVariable from "./variable" + +import os from "os" +import path from "path" +import { Effect } from "effect" +import { FSUtil } from "../fs-util" +import { InvalidError } from "../v1/config/error" + +type ParseSource = + | { + type: "path" + path: string + } + | { + type: "virtual" + source: string + dir: string + } + +type SubstituteInput = ParseSource & { + text: string + missing?: "error" | "empty" + env?: Record +} + +/** Apply {env:VAR} and {file:path} substitutions to config text. */ +export const substitute = Effect.fn("ConfigVariable.substitute")(function* (input: SubstituteInput) { + const text = input.text.replace( + /\{env:([^}]+)\}/g, + (_, varName: string) => (input.env?.[varName] ?? process.env[varName]) || "", + ) + if (!text.includes("{file:")) return text + return yield* substituteFiles(input, text) +}) + +const substituteFiles = Effect.fnUntraced(function* (input: SubstituteInput, text: string) { + const fs = yield* FSUtil.Service + const configDir = input.type === "path" ? path.dirname(input.path) : input.dir + const configSource = input.type === "path" ? input.path : input.source + const matches = Array.from(text.matchAll(/\{file:[^}]+\}/g)) + let out = "" + let cursor = 0 + + for (const match of matches) { + const token = match[0] + const index = match.index + out += text.slice(cursor, index) + + const lineStart = text.lastIndexOf("\n", index - 1) + 1 + const prefix = text.slice(lineStart, index).trimStart() + if (prefix.startsWith("//")) { + out += token + cursor = index + token.length + continue + } + + const filePath = token.replace(/^\{file:/, "").replace(/\}$/, "") + const expandedPath = filePath.startsWith("~/") ? path.join(os.homedir(), filePath.slice(2)) : filePath + const resolvedPath = path.isAbsolute(expandedPath) ? expandedPath : path.resolve(configDir, expandedPath) + const fileContent = yield* fs.readFileString(resolvedPath).pipe( + Effect.catch((error) => { + if (input.missing === "empty") return Effect.succeed("") + + const message = `bad file reference: "${token}"` + return Effect.fail( + new InvalidError( + { + path: configSource, + message: + error._tag === "PlatformError" && error.reason._tag === "NotFound" + ? `${message} ${resolvedPath} does not exist` + : message, + }, + { cause: error }, + ), + ) + }), + ) + + out += JSON.stringify(fileContent.trim()).slice(1, -1) + cursor = index + token.length + } + + return out + text.slice(cursor) +}) diff --git a/packages/core/src/control-plane/move-session.ts b/packages/core/src/control-plane/move-session.ts index e227f88df5..6f0c6e3081 100644 --- a/packages/core/src/control-plane/move-session.ts +++ b/packages/core/src/control-plane/move-session.ts @@ -106,8 +106,7 @@ const layer = Layer.effect( yield* events.publish(SessionEvent.Moved, { sessionID: input.sessionID, location: Location.Ref.make({ directory }), - subdirectory: RelativePath.make(path.relative(destination.directory, directory).replaceAll("\\", "/")), - timestamp: yield* DateTime.now, + subpath: RelativePath.make(path.relative(destination.directory, directory).replaceAll("\\", "/")), }) if (patch) { diff --git a/packages/core/src/cross-spawn-spawner.ts b/packages/core/src/cross-spawn-spawner.ts index 6ea9022acf..f6a04ffd93 100644 --- a/packages/core/src/cross-spawn-spawner.ts +++ b/packages/core/src/cross-spawn-spawner.ts @@ -1,26 +1,17 @@ -import type * as Arr from "effect/Array" -import { NodeFileSystem, NodeSink, NodeStream } from "@effect/platform-node" -import * as NodePath from "@effect/platform-node/NodePath" -import * as Deferred from "effect/Deferred" -import * as Effect from "effect/Effect" -import * as Exit from "effect/Exit" -import * as FileSystem from "effect/FileSystem" -import * as Layer from "effect/Layer" -import * as Path from "effect/Path" -import * as PlatformError from "effect/PlatformError" -import * as Predicate from "effect/Predicate" -import type * as Scope from "effect/Scope" -import * as Sink from "effect/Sink" -import * as Stream from "effect/Stream" -import * as ChildProcess from "effect/unstable/process/ChildProcess" -import type { ChildProcessHandle } from "effect/unstable/process/ChildProcessSpawner" +import type { NonEmptyReadonlyArray } from "effect/Array" +import { NodeFileSystem, NodePath, NodeSink, NodeStream } from "@effect/platform-node" +import { Deferred, Effect, Exit, FileSystem, Layer, Path, PlatformError, Predicate, Sink, Stream } from "effect" +import type { Scope } from "effect" +import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner, ExitCode, - make as makeSpawner, + make, makeHandle, ProcessId, + type ChildProcessHandle, } from "effect/unstable/process/ChildProcessSpawner" +// ast-grep-ignore: no-star-import import * as NodeChildProcess from "node:child_process" import { PassThrough } from "node:stream" import launch from "cross-spawn" @@ -71,7 +62,7 @@ const flatten = (command: ChildProcess.Command) => { if (commands.length === 0) throw new Error("flatten produced empty commands array") const [head, ...tail] = commands return { - commands: [head, ...tail] as Arr.NonEmptyReadonlyArray, + commands: [head, ...tail] as NonEmptyReadonlyArray, opts, } } @@ -96,7 +87,7 @@ const toPlatformError = ( type ExitSignal = Deferred.Deferred -export const make = Effect.gen(function* () { +const makeCrossSpawnSpawner = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path @@ -494,12 +485,12 @@ export const make = Effect.gen(function* () { }, ) - return makeSpawner(spawnCommand) + return make(spawnCommand) }) const layer: Layer.Layer = Layer.effect( ChildProcessSpawner, - make, + makeCrossSpawnSpawner, ) export const node = makeGlobalNode({ service: ChildProcessSpawner, layer, deps: [filesystem, path] }) diff --git a/packages/core/src/database/database.ts b/packages/core/src/database/database.ts index d61adf047e..f3cc8b3f9f 100644 --- a/packages/core/src/database/database.ts +++ b/packages/core/src/database/database.ts @@ -1,7 +1,7 @@ export * as Database from "./database" import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" -import { layer as sqliteLayer } from "#sqlite" +import { layer } from "#sqlite" import { Context, Effect, Layer } from "effect" import { Global } from "../global" import { Flag } from "../flag/flag" @@ -19,7 +19,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/storage/Database") {} -const layer = Layer.effect( +const databaseLayer = Layer.effect( Service, Effect.gen(function* () { const db = yield* makeDatabase @@ -37,7 +37,7 @@ const layer = Layer.effect( ) export function layerFromPath(filename: string) { - return layer.pipe(Layer.provide(sqliteLayer({ filename }))) + return databaseLayer.pipe(Layer.provide(layer({ filename }))) } export function path() { diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 55c1b212cd..e6a236f527 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -41,5 +41,8 @@ export const migrations = ( import("./migration/20260622170816_reset_v2_session_state"), import("./migration/20260622202450_simplify_session_input"), import("./migration/20260702134641_add_session_context_entry"), + import("./migration/20260703090000_reset_v2_event_rename_sweep"), + import("./migration/20260703181610_event_created_column"), + import("./migration/20260703190000_reset_v2_shell_event_payloads"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration.ts b/packages/core/src/database/migration.ts index 90dee8acbf..17bf0a5c44 100644 --- a/packages/core/src/database/migration.ts +++ b/packages/core/src/database/migration.ts @@ -22,7 +22,7 @@ export function apply(db: Database) { sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`, ) if (tables.some((table) => table.name === "session")) return yield* applyOnly(db, migrations) - if (tables.length > 0) return yield* Effect.die("Database is not empty and has no session table") + if (tables.length > 0) return yield* Effect.die(new Error("Database is not empty and has no session table")) yield* db.transaction((tx) => Effect.gen(function* () { yield* schema.up(tx) diff --git a/packages/core/src/database/migration/20260703090000_reset_v2_event_rename_sweep.ts b/packages/core/src/database/migration/20260703090000_reset_v2_event_rename_sweep.ts new file mode 100644 index 0000000000..20672f0734 --- /dev/null +++ b/packages/core/src/database/migration/20260703090000_reset_v2_event_rename_sweep.ts @@ -0,0 +1,17 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260703090000_reset_v2_event_rename_sweep", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`DELETE FROM \`session_input\`;`) + yield* tx.run(`DELETE FROM \`session_message\`;`) + yield* tx.run(`DELETE FROM \`event\`;`) + yield* tx.run(`DELETE FROM \`event_sequence\`;`) + // `created` column is added by the generated 20260703181610_event_created_column + // migration, which runs after this wipe (NOT NULL without default is safe on the + // emptied table). + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260703181610_event_created_column.ts b/packages/core/src/database/migration/20260703181610_event_created_column.ts new file mode 100644 index 0000000000..29d2cf9c12 --- /dev/null +++ b/packages/core/src/database/migration/20260703181610_event_created_column.ts @@ -0,0 +1,11 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260703181610_event_created_column", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`event\` ADD \`created\` integer NOT NULL;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260703190000_reset_v2_shell_event_payloads.ts b/packages/core/src/database/migration/20260703190000_reset_v2_shell_event_payloads.ts new file mode 100644 index 0000000000..ffbe40652c --- /dev/null +++ b/packages/core/src/database/migration/20260703190000_reset_v2_shell_event_payloads.ts @@ -0,0 +1,14 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260703190000_reset_v2_shell_event_payloads", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`DELETE FROM \`session_input\`;`) + yield* tx.run(`DELETE FROM \`session_message\`;`) + yield* tx.run(`DELETE FROM \`event\`;`) + yield* tx.run(`DELETE FROM \`event_sequence\`;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/path.ts b/packages/core/src/database/path.ts index 379d5f8aa7..93fd2d5773 100644 --- a/packages/core/src/database/path.ts +++ b/packages/core/src/database/path.ts @@ -1,5 +1,6 @@ import nodePath from "path" import { customType } from "drizzle-orm/sqlite-core" +import { Schema } from "effect" import { AbsolutePath } from "../schema" function storagePath(input: string) { @@ -74,6 +75,8 @@ export const pathColumn = customType<{ }, }) +const decodeAbsoluteArray = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Array(Schema.String))) + export const absoluteArrayColumn = customType<{ data: AbsolutePath[] driverData: string @@ -86,6 +89,6 @@ export const absoluteArrayColumn = customType<{ return JSON.stringify(input.map(absolute)) }, fromDriver(input) { - return (JSON.parse(input) as string[]).map((item) => AbsolutePath.make(toPlatform(absolute(item)))) + return decodeAbsoluteArray(input).map((item) => AbsolutePath.make(toPlatform(absolute(item)))) }, }) diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index d8f0f1c665..17a7d12aed 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -81,6 +81,7 @@ export default { \`id\` text PRIMARY KEY, \`aggregate_id\` text NOT NULL, \`seq\` integer NOT NULL, + \`created\` integer NOT NULL, \`type\` text NOT NULL, \`data\` text NOT NULL, CONSTRAINT \`fk_event_aggregate_id_event_sequence_aggregate_id_fk\` FOREIGN KEY (\`aggregate_id\`) REFERENCES \`event_sequence\`(\`aggregate_id\`) ON DELETE CASCADE diff --git a/packages/core/src/database/sqlite.bun.ts b/packages/core/src/database/sqlite.bun.ts index e15f4c117e..bbb3ad6143 100644 --- a/packages/core/src/database/sqlite.bun.ts +++ b/packages/core/src/database/sqlite.bun.ts @@ -1,18 +1,11 @@ import { Database } from "bun:sqlite" import { drizzle } from "drizzle-orm/bun-sqlite" -import * as Context from "effect/Context" -import * as Effect from "effect/Effect" -import * as Fiber from "effect/Fiber" +import { Context, Effect, Fiber, Layer, Scope, Semaphore, Stream } from "effect" import { identity } from "effect/Function" -import * as Layer from "effect/Layer" -import * as Scope from "effect/Scope" -import * as Semaphore from "effect/Semaphore" -import * as Stream from "effect/Stream" -import * as Reactivity from "effect/unstable/reactivity/Reactivity" -import * as Client from "effect/unstable/sql/SqlClient" +import { Reactivity } from "effect/unstable/reactivity" +import { SqlClient, Statement } from "effect/unstable/sql" import type { Connection } from "effect/unstable/sql/SqlConnection" import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError" -import * as Statement from "effect/unstable/sql/Statement" import { Sqlite } from "./sqlite" const ATTR_DB_SYSTEM_NAME = "db.system.name" @@ -20,7 +13,7 @@ const ATTR_DB_SYSTEM_NAME = "db.system.name" const TypeId = "~@opencode-ai/core/database/SqliteBun" as const type TypeId = typeof TypeId -interface SqliteClient extends Client.SqlClient { +interface SqliteClient extends SqlClient.SqlClient { readonly [TypeId]: TypeId readonly config: Config readonly export: Effect.Effect @@ -57,7 +50,7 @@ const make = (options: Config) => Effect.withFiber>, SqlError>((fiber) => { const statement = native.query(query) // @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627 - statement.safeIntegers(Context.get(fiber.context, Client.SafeIntegers)) + statement.safeIntegers(Context.get(fiber.context, SqlClient.SafeIntegers)) try { return Effect.succeed((statement.all(...(params as any)) ?? []) as Array>) } catch (cause) { @@ -73,7 +66,7 @@ const make = (options: Config) => Effect.withFiber, SqlError>((fiber) => { const statement = native.query(query) // @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627 - statement.safeIntegers(Context.get(fiber.context, Client.SafeIntegers)) + statement.safeIntegers(Context.get(fiber.context, SqlClient.SafeIntegers)) try { return Effect.succeed((statement.values(...(params as any)) ?? []) as Array) } catch (cause) { @@ -130,7 +123,7 @@ const make = (options: Config) => }) const client = Object.assign( - (yield* Client.make({ + (yield* SqlClient.make({ acquirer, compiler, transactionAcquirer, @@ -166,7 +159,7 @@ const nativeLayer = (config: Config) => }), ) -const sqliteLayer = (config: Config) => Layer.effect(Client.SqlClient, make(config)) +const sqliteLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config)) const drizzleLayer = Layer.effect( Sqlite.Drizzle, diff --git a/packages/core/src/database/sqlite.node.ts b/packages/core/src/database/sqlite.node.ts index 6eaecbee26..6fcbd76e4e 100644 --- a/packages/core/src/database/sqlite.node.ts +++ b/packages/core/src/database/sqlite.node.ts @@ -1,18 +1,11 @@ import { DatabaseSync, type SQLInputValue } from "node:sqlite" import { drizzle } from "drizzle-orm/node-sqlite" -import * as Context from "effect/Context" -import * as Effect from "effect/Effect" -import * as Fiber from "effect/Fiber" +import { Context, Effect, Fiber, Layer, Scope, Semaphore, Stream } from "effect" import { identity } from "effect/Function" -import * as Layer from "effect/Layer" -import * as Scope from "effect/Scope" -import * as Semaphore from "effect/Semaphore" -import * as Stream from "effect/Stream" -import * as Reactivity from "effect/unstable/reactivity/Reactivity" -import * as Client from "effect/unstable/sql/SqlClient" +import { Reactivity } from "effect/unstable/reactivity" +import { SqlClient, Statement } from "effect/unstable/sql" import type { Connection } from "effect/unstable/sql/SqlConnection" import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError" -import * as Statement from "effect/unstable/sql/Statement" import { Sqlite } from "./sqlite" const ATTR_DB_SYSTEM_NAME = "db.system.name" @@ -20,7 +13,7 @@ const ATTR_DB_SYSTEM_NAME = "db.system.name" const TypeId = "~@opencode-ai/core/database/SqliteNode" as const type TypeId = typeof TypeId -interface SqliteClient extends Client.SqlClient { +interface SqliteClient extends SqlClient.SqlClient { readonly [TypeId]: TypeId readonly config: Config readonly loadExtension: (path: string) => Effect.Effect @@ -56,7 +49,7 @@ const make = (options: Config) => const run = (query: string, params: ReadonlyArray = []) => Effect.withFiber>, SqlError>((fiber) => { const statement = native.prepare(query) - statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers)) + statement.setReadBigInts(Context.get(fiber.context, SqlClient.SafeIntegers)) try { return Effect.succeed(statement.all(...(params as SQLInputValue[])) as Array>) } catch (cause) { @@ -71,7 +64,7 @@ const make = (options: Config) => const runValues = (query: string, params: ReadonlyArray = []) => Effect.withFiber>, SqlError>((fiber) => { const statement = native.prepare(query) - statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers)) + statement.setReadBigInts(Context.get(fiber.context, SqlClient.SafeIntegers)) statement.setReturnArrays(true) try { return Effect.succeed( @@ -124,7 +117,7 @@ const make = (options: Config) => }) const client = Object.assign( - (yield* Client.make({ + (yield* SqlClient.make({ acquirer, compiler, transactionAcquirer, @@ -161,7 +154,7 @@ const nativeLayer = (config: Config) => }), ) -const sqliteLayer = (config: Config) => Layer.effect(Client.SqlClient, make(config)) +const sqliteLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config)) const drizzleLayer = Layer.effect( Sqlite.Drizzle, diff --git a/packages/core/src/effect/layer-node.ts b/packages/core/src/effect/layer-node.ts index b58692a155..dc7aa972f0 100644 --- a/packages/core/src/effect/layer-node.ts +++ b/packages/core/src/effect/layer-node.ts @@ -227,10 +227,10 @@ export function hoist + Types.has(event.type) ? Effect.logInfo("event", { event }) : Effect.void, + ) + yield* Effect.addFinalizer(() => unsubscribe) + }), +) + +export const node = makeGlobalNode({ name: "event-logger", layer, deps: [EventV2.node] }) diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 66ee91c6c6..a5b052b42f 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -1,6 +1,6 @@ export * as EventV2 from "./event" -import { Cause, Context, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect" +import { Cause, Context, DateTime, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect" import { Event } from "@opencode-ai/schema/event" import type { Data, Definition, Payload } from "@opencode-ai/schema/event" import type { EventLog } from "@opencode-ai/schema/event-log" @@ -55,6 +55,7 @@ export const reserveSequence = Effect.fn("EventV2.reserveSequence")(function* ( export type SerializedEvent = { readonly id: ID readonly type: string + readonly created?: DateTime.Utc readonly seq: number readonly aggregateID: string readonly data: Record @@ -81,6 +82,7 @@ const decodeSerializedEvent = (event: SerializedEvent): Payload => { } return { id: event.id, + created: event.created ?? DateTime.makeUnsafe(0), type: definition.type, durable: envelope(event.aggregateID, event.seq, definition.durable.version), data: Schema.decodeUnknownSync(definition.data)(event.data), @@ -92,8 +94,9 @@ export class SubscriberOverflowError extends Schema.TaggedErrorClass()("@opencode/Event") {} -export const liveBounded = (events: Interface, capacity: number) => +export const liveBounded = ( + events: Interface, + options: { readonly capacity: number; readonly accept?: (event: Payload) => boolean }, +) => Effect.gen(function* () { - const queue = yield* Queue.dropping(capacity) + const queue = yield* Queue.dropping(options.capacity) const unsubscribe = yield* events.listen((event) => - Queue.offer(queue, event).pipe( - Effect.flatMap((accepted) => - accepted ? Effect.void : Queue.fail(queue, new SubscriberOverflowError({ capacity })).pipe(Effect.asVoid), - ), - ), + options.accept && !options.accept(event) + ? Effect.void + : Queue.offer(queue, event).pipe( + Effect.flatMap((accepted) => + accepted + ? Effect.void + : Queue.fail(queue, new SubscriberOverflowError({ capacity: options.capacity })).pipe(Effect.asVoid), + ), + ), ) yield* Effect.addFinalizer(() => unsubscribe.pipe(Effect.andThen(Queue.shutdown(queue)), Effect.asVoid)) return Stream.fromQueue(queue) @@ -294,6 +304,7 @@ export const layerWith = (options?: LayerOptions) => if ( stored?.id === event.id && stored.type === versionedType(definition.type, durable.version) && + stored.created === DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(0)) && isDeepStrictEqual(stored.data, encoded) ) { if (input.ownerID && row?.ownerID == null) { @@ -365,6 +376,7 @@ export const layerWith = (options?: LayerOptions) => id: event.id, aggregate_id: aggregateID, seq, + created: DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(0)), type: versionedType(definition.type, durable.version), data: encoded, }, @@ -470,6 +482,7 @@ export const layerWith = (options?: LayerOptions) => definition, { id: options?.id ?? ID.create(), + created: yield* DateTime.now, ...(options?.metadata ? { metadata: options.metadata } : {}), type: definition.type, ...(location ? { location } : {}), @@ -493,6 +506,7 @@ export const layerWith = (options?: LayerOptions) => } else { const payload = { id: event.id, + created: event.created ?? DateTime.makeUnsafe(0), type: definition.type, data: Schema.decodeUnknownSync(definition.data)(event.data), } as Payload @@ -569,12 +583,32 @@ export const layerWith = (options?: LayerOptions) => .pipe(Effect.orDie) } + const local = (stream: Stream.Stream) => + Stream.unwrap( + Effect.serviceOption(Location.Service).pipe( + Effect.map((location) => + Option.match(location, { + onNone: () => stream, + onSome: (location) => + stream.pipe( + Stream.filter( + (event) => + !event.location || + (event.location.directory === location.directory && + event.location.workspaceID === location.workspaceID), + ), + ), + }), + ), + ), + ) + const subscribe = (definition: D): Stream.Stream> => - Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe( + local(Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub))))).pipe( Stream.map((event) => event as Payload), ) - const streamLive = (): Stream.Stream => Stream.fromPubSub(pubsub.live) + const streamLive = (): Stream.Stream => local(Stream.fromPubSub(pubsub.live)) const readAfter = ( aggregateID: string, @@ -609,6 +643,7 @@ export const layerWith = (options?: LayerOptions) => return [ decodeSerializedEvent({ id: event.id, + created: DateTime.makeUnsafe(event.created), aggregateID: event.aggregate_id, seq: event.seq, type: event.type, diff --git a/packages/core/src/event/sql.ts b/packages/core/src/event/sql.ts index 38fe34f1e3..17c88cefd3 100644 --- a/packages/core/src/event/sql.ts +++ b/packages/core/src/event/sql.ts @@ -15,6 +15,7 @@ export const EventTable = sqliteTable( .notNull() .references(() => EventSequenceTable.aggregate_id, { onDelete: "cascade" }), seq: integer().notNull(), + created: integer().notNull(), type: text().notNull(), data: text({ mode: "json" }).$type>().notNull(), }, diff --git a/packages/core/src/filesystem/ignore.ts b/packages/core/src/filesystem/ignore.ts index 2f5f52bf25..4d88eb5e87 100644 --- a/packages/core/src/filesystem/ignore.ts +++ b/packages/core/src/filesystem/ignore.ts @@ -45,7 +45,7 @@ const FILES = [ "**/.nyc_output/**", ] -export const PATTERNS = [...FILES, ...FOLDERS] +export const PATTERNS = [...FILES, ...FOLDERS, `**/{${Array.from(FOLDERS).join(",")}}/**`] export function match(filepath: string, opts?: { extra?: string[]; whitelist?: string[] }) { for (const pattern of opts?.whitelist || []) { diff --git a/packages/core/src/filesystem/location-watcher.ts b/packages/core/src/filesystem/location-watcher.ts new file mode 100644 index 0000000000..613b43b5f8 --- /dev/null +++ b/packages/core/src/filesystem/location-watcher.ts @@ -0,0 +1,90 @@ +export * as LocationWatcher from "./location-watcher" + +import { makeLocationNode } from "../effect/app-node" +import { Context, Effect, Layer, Stream } from "effect" +import { FileSystem } from "@opencode-ai/schema/filesystem" +import os from "os" +import path from "path" +import { Config } from "../config" +import { EventV2 } from "../event" +import { FSUtil } from "../fs-util" +import { Git } from "../git" +import { Location } from "../location" +import { Watcher } from "./watcher" +import { Ignore } from "./ignore" +import { Protected } from "./protected" + +function protecteds(dir: string) { + return Protected.paths().filter((item) => { + const relative = path.relative(dir, item) + return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative) + }) +} + +export interface Interface {} + +export class Service extends Context.Service()("@opencode/LocationWatcher") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const location = yield* Location.Service + const watcher = yield* Watcher.Service + const events = yield* EventV2.Service + const fs = yield* FSUtil.Service + const git = yield* Git.Service + const configService = yield* Config.Service + const publish = (update: { type: "create" | "update" | "delete"; path: string }) => + events.publish(FileSystem.Event.Changed, { + file: update.path, + event: update.type === "create" ? "add" : update.type === "update" ? "change" : "unlink", + }) + + yield* Effect.gen(function* () { + const config = (yield* configService.entries()) + .filter((entry): entry is Config.Document => entry.type === "document") + .flatMap((item) => item.info.watcher?.ignore ?? []) + const home = path.resolve(location.directory) === path.resolve(os.homedir()) + + if (!home) { + yield* watcher + .subscribe({ + path: location.directory, + type: "directory", + ignore: [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)], + }) + .pipe(Stream.runForEach(publish), Effect.forkScoped) + } + if (home) { + yield* Effect.logInfo("location watcher skipped home directory", { directory: location.directory }) + } + + if (location.vcs?.type === "git") { + const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory + const vcs = resolved + ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) + : undefined + if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) { + const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap( + (entry) => (entry.name === "HEAD" ? [] : [entry.name]), + ) + yield* watcher + .subscribe({ path: vcs, type: "directory", ignore }) + .pipe(Stream.runForEach(publish), Effect.forkScoped) + } + } + }).pipe( + Effect.withSpan("LocationWatcher.start", { attributes: { directory: location.directory } }), + Effect.catchCause((cause) => Effect.logError("failed to init location watcher service", { cause })), + Effect.forkScoped, + ) + + return Service.of({}) + }), +) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [Watcher.node, FSUtil.node, Location.node, Config.node, Git.node, EventV2.node], +}) diff --git a/packages/core/src/filesystem/watcher.ts b/packages/core/src/filesystem/watcher.ts index fe7a5b2594..9a72903d84 100644 --- a/packages/core/src/filesystem/watcher.ts +++ b/packages/core/src/filesystem/watcher.ts @@ -3,26 +3,20 @@ export * as Watcher from "./watcher" // @ts-ignore import { createWrapper } from "@parcel/watcher/wrapper" import type ParcelWatcher from "@parcel/watcher" -import { makeLocationNode } from "../effect/app-node" -import { Cause, Context, Effect, Layer } from "effect" -import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher" -import os from "os" -import path from "path" -import { Config } from "../config" -import { EventV2 } from "../event" +import { FileSystem } from "@opencode-ai/schema/filesystem" +import { makeGlobalNode } from "../effect/app-node" +import { Cause, Context, Effect, Layer, PubSub, Scope, Stream } from "effect" +import { KeyedMutex } from "../effect/keyed-mutex" import { Flag } from "../flag/flag" -import { FSUtil } from "../fs-util" -import { Git } from "../git" -import { Location } from "../location" import { lazy } from "../util/lazy" -import { Ignore } from "./ignore" -import { Protected } from "./protected" +import { watch as watchFileSystem } from "node:fs" +import path from "path" declare const OPENCODE_LIBC: string | undefined const SUBSCRIBE_TIMEOUT_MS = 10_000 -export const Event = FileSystemWatcher.Event +export const Event = { Updated: FileSystem.Event.Changed } const watcher = lazy((): typeof import("@parcel/watcher") | undefined => { try { @@ -42,107 +36,134 @@ function getBackend() { if (process.platform === "linux") return "inotify" } -function protecteds(dir: string) { - return Protected.paths().filter((item) => { - const relative = path.relative(dir, item) - return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative) - }) +export const hasNativeBinding = () => !!watcher() +export type Update = ParcelWatcher.Event + +export type WatchInput = + | { readonly path: string; readonly type: "file" } + | { readonly path: string; readonly type: "directory"; readonly ignore?: readonly string[] } + +export interface Interface { + readonly subscribe: (input: WatchInput) => Stream.Stream } -export const hasNativeBinding = () => !!watcher() - -export interface Interface {} - -export class Service extends Context.Service()("@opencode/v2/FileWatcher") {} +export class Service extends Context.Service()("@opencode/Watcher") {} const layer = Layer.effect( Service, Effect.gen(function* () { - if (Flag.OPENCODE_DISABLE_FILEWATCHER) return Service.of({}) - const backend = getBackend() - const location = yield* Location.Service - if (path.resolve(location.directory) === path.resolve(os.homedir())) { - yield* Effect.logInfo("watcher skipped home directory", { directory: location.directory }) - return Service.of({}) - } - if (!backend) { - yield* Effect.logError("watcher backend not supported", { - directory: location.directory, - platform: process.platform, - }) - return Service.of({}) + const native = watcher() + if (Flag.OPENCODE_DISABLE_FILEWATCHER) { + return Service.of({ subscribe: () => Stream.empty }) } - const w = watcher() - if (!w) return Service.of({}) - - yield* Effect.logInfo("watcher backend", { directory: location.directory, platform: process.platform, backend }) - const events = yield* EventV2.Service - const fs = yield* FSUtil.Service - const git = yield* Git.Service - const context = yield* Effect.context() - const runFork = Effect.runForkWith(context) - const subscriptions: ParcelWatcher.AsyncSubscription[] = [] - yield* Effect.addFinalizer(() => - Effect.promise(() => Promise.allSettled(subscriptions.map((subscription) => subscription.unsubscribe()))), - ) - - const callback: ParcelWatcher.SubscribeCallback = (_error, updates) => { - if (_error) runFork(Effect.logError("watcher callback failed", { error: _error })) - for (const update of updates) { - if (update.type === "create") runFork(events.publish(Event.Updated, { file: update.path, event: "add" })) - if (update.type === "update") runFork(events.publish(Event.Updated, { file: update.path, event: "change" })) - if (update.type === "delete") runFork(events.publish(Event.Updated, { file: update.path, event: "unlink" })) - } + type Entry = { + readonly pubsub: PubSub.PubSub + readonly subscription: { readonly unsubscribe: () => Promise } + refs: number } + const entries = new Map() + const locks = KeyedMutex.makeUnsafe() - const subscribe = (directory: string, ignore: string[]) => { - const pending = w.subscribe(directory, callback, { ignore, backend }) - return Effect.promise(() => pending).pipe( - Effect.tap((subscription) => - Effect.sync(() => subscriptions.push(subscription)).pipe( - Effect.andThen(Effect.logInfo("watcher subscribed", { directory, backend, ignores: ignore.length })), - ), - ), - Effect.timeout(SUBSCRIBE_TIMEOUT_MS), - Effect.catchCause((cause) => { - pending.then((subscription) => subscription.unsubscribe()).catch(() => {}) - return Effect.logError("failed to subscribe", { directory, cause: Cause.pretty(cause) }) + const acquire = Effect.fn("Watcher.acquire")(function* (input: WatchInput) { + const scope = yield* Scope.Scope + const target = path.resolve(input.path) + const directory = input.type === "file" ? path.dirname(target) : target + const ignore = [...new Set(input.type === "directory" ? (input.ignore ?? []) : [])].toSorted() + const id = JSON.stringify([input.type, target, ignore]) + const pubsub = yield* locks.withLock(id)( + Effect.gen(function* () { + const existing = entries.get(id) + if (existing) { + existing.refs++ + return existing.pubsub + } + const pubsub = yield* PubSub.unbounded() + const subscription = yield* input.type === "file" + ? Effect.sync(() => { + const subscription = watchFileSystem(directory, { recursive: false }, (_event, file) => { + if (file && path.resolve(directory, file.toString()) !== target) return + PubSub.publishUnsafe(pubsub, { + path: target, + type: "update", + } satisfies Update) + }) + if ("on" in subscription && typeof subscription.on === "function") { + subscription.on("error", (error: unknown) => + Effect.runFork(Effect.logError("watcher callback failed", { path: target, error })), + ) + } + return { unsubscribe: () => Promise.resolve(subscription.close()) } + }) + : subscribeDirectory(native, backend, directory, ignore, pubsub) + if (subscription) { + entries.set(id, { pubsub, subscription, refs: 1 }) + yield* Effect.logInfo("watcher started", { + path: target, + type: input.type, + backend: input.type === "file" ? "node" : backend, + ignores: ignore.length, + }) + return pubsub + } + yield* PubSub.shutdown(pubsub) + return pubsub }), ) - } - const config = (yield* (yield* Config.Service).entries()) - .filter((entry): entry is Config.Document => entry.type === "document") - .flatMap((item) => item.info.watcher?.ignore ?? []) - yield* Effect.forkScoped( - subscribe(location.directory, [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)]), - ) - - if (location.vcs?.type === "git") { - const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory - const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined - if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) { - const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap( - (entry) => (entry.name === "HEAD" ? [] : [entry.name]), - ) - yield* Effect.forkScoped(subscribe(vcs, ignore)) - } - } - - return Service.of({}) - }).pipe( - Effect.catchCause((cause) => { - return Effect.logError("failed to init watcher service", { cause: Cause.pretty(cause) }).pipe( - Effect.as(Service.of({})), + yield* Scope.addFinalizer( + scope, + locks.withLock(id)( + Effect.gen(function* () { + const entry = entries.get(id) + if (!entry) return + entry.refs-- + if (entry.refs > 0) return + entries.delete(id) + yield* Effect.promise(() => entry.subscription.unsubscribe()).pipe(Effect.ignore) + yield* PubSub.shutdown(entry.pubsub) + yield* Effect.logInfo("watcher stopped", { path: target, type: input.type }) + }), + ), ) - }), - ), + return pubsub + }) + + const subscribe = (input: WatchInput) => + Stream.unwrap(acquire(input).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))) + + return Service.of({ subscribe }) + }), ) -export const node = makeLocationNode({ - service: Service, - layer, - deps: [FSUtil.node, Location.node, Config.node, Git.node, EventV2.node], -}) +export const node = makeGlobalNode({ service: Service, layer, deps: [] }) + +function subscribeDirectory( + native: typeof import("@parcel/watcher") | undefined, + backend: ParcelWatcher.BackendType | undefined, + directory: string, + ignore: string[], + pubsub: PubSub.PubSub, +) { + if (!native || !backend) { + return Effect.logError("watcher backend not supported", { directory, platform: process.platform }).pipe( + Effect.as(undefined), + ) + } + const callback: ParcelWatcher.SubscribeCallback = (error, updates) => { + if (error) Effect.runFork(Effect.logError("watcher callback failed", { error })) + for (const update of updates) PubSub.publishUnsafe(pubsub, update) + } + const pending = native.subscribe(directory, callback, { ignore, backend }) + return Effect.promise(() => pending).pipe( + Effect.timeout(SUBSCRIBE_TIMEOUT_MS), + Effect.catchCause((cause) => { + pending.then((subscription) => subscription.unsubscribe()).catch(() => {}) + return Effect.logError("failed to subscribe", { + directory, + cause: Cause.pretty(cause), + }).pipe(Effect.as(undefined)) + }), + ) +} diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 1c975040dc..b33c20c1ab 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -52,6 +52,9 @@ export const Flag = { get OPENCODE_EXPERIMENTAL_REFERENCES() { return enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES") }, + get CODEMODE_ENABLED() { + return process.env["CODEMODE_ENABLED"] === undefined || truthy("CODEMODE_ENABLED") + }, get OPENCODE_TUI_CONFIG() { return process.env["OPENCODE_TUI_CONFIG"] }, diff --git a/packages/core/src/form.ts b/packages/core/src/form.ts index 47a72ed23a..7cdda87b4c 100644 --- a/packages/core/src/form.ts +++ b/packages/core/src/form.ts @@ -21,6 +21,7 @@ export type When = Form.When export const State = Form.State export type State = typeof State.Type +export type TerminalState = Exclude export const Answer = Form.Answer export type Answer = typeof Answer.Type @@ -78,7 +79,7 @@ export interface ListInput { export interface Interface { readonly create: (input: CreateInput) => Effect.Effect - readonly ask: (input: CreateInput) => Effect.Effect + readonly ask: (input: CreateInput) => Effect.Effect readonly get: (id: ID) => Effect.Effect readonly list: (input?: ListInput) => Effect.Effect> readonly state: (id: ID) => Effect.Effect @@ -91,17 +92,21 @@ export class Service extends Context.Service()("@opencode/Fo interface Entry { readonly form: Info readonly state: State - readonly deferred: Deferred.Deferred + readonly deferred: Deferred.Deferred } export const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2.Service - const forms = yield* Cache.makeWith(() => Effect.die("Form cache must be used via set/getSuccess, never get"), { - capacity: Number.MAX_SAFE_INTEGER, - timeToLive: (exit) => (Exit.isSuccess(exit) && exit.value.state.status === "pending" ? Duration.infinity : RETENTION), - }) + const forms = yield* Cache.makeWith( + () => Effect.die(new Error("Form cache must be used via set/getSuccess, never get")), + { + capacity: Number.MAX_SAFE_INTEGER, + timeToLive: (exit) => + Exit.isSuccess(exit) && exit.value.state.status === "pending" ? Duration.infinity : RETENTION, + }, + ) const find = Effect.fn("Form.find")(function* (id: ID) { return yield* Cache.getSuccess(forms, id).pipe( @@ -131,11 +136,13 @@ export const layer = Layer.effect( ...(input.metadata === undefined ? {} : { metadata: input.metadata }), } const form: Info = - input.mode === "form" ? { ...base, mode: "form", fields: input.fields } : { ...base, mode: "url", url: input.url } + input.mode === "form" + ? { ...base, mode: "form", fields: input.fields } + : { ...base, mode: "url", url: input.url } const entry: Entry = { form, state: { status: "pending" }, - deferred: yield* Deferred.make(), + deferred: yield* Deferred.make(), } yield* Cache.set(forms, id, entry) yield* events.publish(Event.Created, { form }).pipe(Effect.onError(() => Cache.invalidate(forms, id))) @@ -149,7 +156,9 @@ export const layer = Layer.effect( Effect.gen(function* () { const form = yield* create(input) const entry = yield* find(form.id).pipe(Effect.orDie) - return yield* restore(Deferred.await(entry.deferred)).pipe(Effect.onInterrupt(() => Effect.ignore(cancel(form.id)))) + return yield* restore(Deferred.await(entry.deferred)).pipe( + Effect.onInterrupt(() => Effect.ignore(cancel(form.id))), + ) }), ), ) @@ -177,7 +186,7 @@ export const layer = Layer.effect( if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id: input.id }) const invalid = validateAnswer(entry.form, input.answer) if (invalid) return yield* new InvalidAnswerError({ id: input.id, message: invalid }) - const next: State = { status: "answered", answer: input.answer } + const next: TerminalState = { status: "answered", answer: input.answer } yield* events.publish(Event.Replied, { id: input.id, sessionID: entry.form.sessionID, answer: input.answer }) yield* Cache.set(forms, input.id, { ...entry, state: next }) yield* Deferred.succeed(entry.deferred, next) @@ -190,7 +199,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const entry = yield* find(id) if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id }) - const next: State = { status: "cancelled" } + const next: TerminalState = { status: "cancelled" } yield* events.publish(Event.Cancelled, { id, sessionID: entry.form.sessionID }) yield* Cache.set(forms, id, { ...entry, state: next }) yield* Deferred.succeed(entry.deferred, next) @@ -301,7 +310,8 @@ function validateField(field: Form.Field, value: Form.Value): string | undefined return `Form field has invalid pattern: ${field.key}` } } - if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return `Expected email for form field: ${field.key}` + if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) + return `Expected email for form field: ${field.key}` if (field.format === "uri" && !isUri(value)) return `Expected URI for form field: ${field.key}` if (field.format === "date" && !isDate(value)) return `Expected date for form field: ${field.key}` if (field.format === "date-time" && !isDateTime(value)) return `Expected date-time for form field: ${field.key}` @@ -324,8 +334,10 @@ function validateField(field: Form.Field, value: Form.Value): string | undefined if (field.type === "multiselect") { if (!isStringArray(value)) return `Expected string array for form field: ${field.key}` if (field.required && value.length === 0) return `Missing required form field: ${field.key}` - if (field.minItems !== undefined && value.length < field.minItems) return `Too few selections for form field: ${field.key}` - if (field.maxItems !== undefined && value.length > field.maxItems) return `Too many selections for form field: ${field.key}` + if (field.minItems !== undefined && value.length < field.minItems) + return `Too few selections for form field: ${field.key}` + if (field.maxItems !== undefined && value.length > field.maxItems) + return `Too many selections for form field: ${field.key}` if (!field.custom && value.some((item) => !field.options.some((option) => option.value === item))) { return `Invalid option for form field: ${field.key}` } diff --git a/packages/core/src/fs-util.ts b/packages/core/src/fs-util.ts index ff71477d70..e01b546384 100644 --- a/packages/core/src/fs-util.ts +++ b/packages/core/src/fs-util.ts @@ -1,7 +1,7 @@ import { NodeFileSystem } from "@effect/platform-node" -import { dirname, isAbsolute, join, relative, resolve as pathResolve, sep } from "path" +import path, { dirname, isAbsolute, join, relative, sep } from "path" import { realpathSync } from "fs" -import * as NFS from "fs/promises" +import { readdir } from "fs/promises" import { lookup } from "mime-types" import { Context, Effect, FileSystem, Layer, Schema } from "effect" import type { PlatformError } from "effect/PlatformError" @@ -38,6 +38,7 @@ export namespace FSUtil { readonly ensureDir: (path: string) => Effect.Effect readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect readonly readDirectoryEntries: (path: string) => Effect.Effect + readonly resolve: (path: string) => Effect.Effect readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect @@ -49,7 +50,9 @@ export namespace FSUtil { export const use = serviceUse(Service) - const layer = Layer.effect( + // Exported so simulation can wrap this layer and override the methods that + // bypass the injected FileSystem (readDirectoryEntries, glob, globUp). + export const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FileSystem.FileSystem @@ -77,7 +80,7 @@ export namespace FSUtil { const readDirectoryEntries = Effect.fn("FileSystem.readDirectoryEntries")(function* (dirPath: string) { return yield* Effect.tryPromise({ try: async () => { - const entries = await NFS.readdir(dirPath, { withFileTypes: true }) + const entries = await readdir(dirPath, { withFileTypes: true }) return entries.map( (e): DirEntry => ({ name: e.name, @@ -89,6 +92,14 @@ export namespace FSUtil { }) }) + const resolve = Effect.fn("FileSystem.resolve")(function* (input: string) { + const resolved = path.resolve(windowsPath(input)) + return yield* fs.realPath(resolved).pipe( + Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(resolved)), + Effect.orDie, + ) + }) + const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) { const text = yield* fs.readFileString(path) return yield* Effect.try({ @@ -187,6 +198,7 @@ export namespace FSUtil { isDir, isFile, readDirectoryEntries, + resolve, readJson, writeJson, ensureDir, @@ -209,7 +221,7 @@ export namespace FSUtil { export function normalizePath(p: string): string { if (process.platform !== "win32") return p - const resolved = pathResolve(windowsPath(p)) + const resolved = path.resolve(windowsPath(p)) try { return realpathSync.native(resolved) } catch { @@ -227,7 +239,7 @@ export namespace FSUtil { } export function resolve(p: string): string { - const resolved = pathResolve(windowsPath(p)) + const resolved = path.resolve(windowsPath(p)) try { return normalizePath(realpathSync(resolved)) } catch (e: any) { diff --git a/packages/core/src/id/id.ts b/packages/core/src/id/id.ts index be1efc446a..b4d6e496ea 100644 --- a/packages/core/src/id/id.ts +++ b/packages/core/src/id/id.ts @@ -1,4 +1,4 @@ -import { create as createIdentifier } from "@opencode-ai/schema/identifier" +import { create } from "@opencode-ai/schema/identifier" const prefixes = { job: "job", @@ -23,7 +23,7 @@ export function descending(prefix: keyof typeof prefixes, given?: string) { function generateID(prefix: keyof typeof prefixes, direction: "descending" | "ascending", given?: string): string { if (!given) { - return create(prefixes[prefix], direction) + return createID(prefixes[prefix], direction) } if (!given.startsWith(prefixes[prefix])) { @@ -32,10 +32,12 @@ function generateID(prefix: keyof typeof prefixes, direction: "descending" | "as return given } -export function create(prefix: string, direction: "descending" | "ascending", timestamp?: number): string { - return prefix + "_" + createIdentifier(direction === "descending", timestamp) +function createID(prefix: string, direction: "descending" | "ascending", timestamp?: number): string { + return prefix + "_" + create(direction === "descending", timestamp) } +export { createID as create } + /** Extract timestamp from an ascending ID. Does not work with descending IDs. */ export function timestamp(id: string): number { const prefix = id.split("_")[0] diff --git a/packages/core/src/instruction-context.ts b/packages/core/src/instruction-context.ts index 94d9e787ce..66d9e14c01 100644 --- a/packages/core/src/instruction-context.ts +++ b/packages/core/src/instruction-context.ts @@ -43,22 +43,24 @@ const layer = Layer.effect( }) const observe = Effect.fn("InstructionContext.observe")(function* () { - const start = FSUtil.resolve(location.directory) - const stop = FSUtil.resolve(location.project.directory) + const start = yield* fs.resolve(location.directory) + const stop = yield* fs.resolve(location.project.directory) const fromProject = relative(stop, start) const insideProject = fromProject === "" || (fromProject !== ".." && !fromProject.startsWith(`..${sep}`) && !isAbsolute(fromProject)) const discovered = new Set( - (Flag.OPENCODE_DISABLE_PROJECT_CONFIG || !insideProject - ? [] - : yield* fs.up({ - targets: ["AGENTS.md"], - start, - stop, - }) - ).map(FSUtil.resolve), + yield* Effect.forEach( + Flag.OPENCODE_DISABLE_PROJECT_CONFIG || !insideProject + ? [] + : yield* fs.up({ + targets: ["AGENTS.md"], + start, + stop, + }), + fs.resolve, + ), ) - const paths = Array.dedupe([FSUtil.resolve(join(global.config, "AGENTS.md")), ...discovered]) + const paths = Array.dedupe([yield* fs.resolve(join(global.config, "AGENTS.md")), ...discovered]) const files = yield* Effect.forEach( paths, (path) => diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 9acbbe93eb..12dad07dd3 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -406,7 +406,7 @@ const layer = Layer.effect( .get() .integrations.get(input.integrationID) ?.methods.some((method) => method.type === "key") - if (!method) return yield* Effect.die(`Key method not found: ${input.integrationID}`) + if (!method) return yield* Effect.die(new Error(`Key method not found: ${input.integrationID}`)) yield* credentials.create({ integrationID: input.integrationID, label: input.label, @@ -418,7 +418,7 @@ const layer = Layer.effect( oauth: Effect.fn("Integration.connection.oauth")(function* (input) { const method = state.get().integrations.get(input.integrationID)?.implementations.get(input.methodID) if (!method) { - return yield* Effect.die(`OAuth method not found: ${input.integrationID}/${input.methodID}`) + return yield* Effect.die(new Error(`OAuth method not found: ${input.integrationID}/${input.methodID}`)) } const attemptScope = yield* Scope.fork(scope) const authorization = yield* authorize(method.authorize(input.inputs)).pipe( @@ -475,7 +475,7 @@ const layer = Layer.effect( attempt: { status: Effect.fn("Integration.attempt.status")(function* (attemptID) { const attempt = (yield* SynchronizedRef.get(attempts)).get(attemptID) - if (!attempt) return yield* Effect.die(`OAuth attempt not found: ${attemptID}`) + if (!attempt) return yield* Effect.die(new Error(`OAuth attempt not found: ${attemptID}`)) if (attempt.status === "failed") { return { status: attempt.status, message: attempt.message ?? "Authorization failed", time: attempt.time } } @@ -488,12 +488,13 @@ const layer = Layer.effect( if (match.authorization.mode === "code" && input.code === undefined) return [match, current] return [match, new Map(current).set(input.attemptID, { ...match, completing: true })] }) - if (!attempt) return yield* Effect.die(`OAuth attempt not found: ${input.attemptID}`) + if (!attempt) return yield* Effect.die(new Error(`OAuth attempt not found: ${input.attemptID}`)) if (attempt.status !== "pending") return if (attempt.authorization.mode === "code" && input.code === undefined) { return yield* new CodeRequiredError({ attemptID: input.attemptID }) } - if (attempt.completing) return yield* Effect.die(`OAuth attempt already completing: ${input.attemptID}`) + if (attempt.completing) + return yield* Effect.die(new Error(`OAuth attempt already completing: ${input.attemptID}`)) const callback = attempt.authorization.mode === "auto" ? attempt.authorization.callback diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 446fa84ec1..18d80d56b9 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -5,31 +5,38 @@ import { Catalog } from "./catalog" import { CommandV2 } from "./command" import { Config } from "./config" import { LayerNode } from "./effect/layer-node" -import { Node } from "./effect/app-node" +import { makeLocationNode, Node } from "./effect/app-node" +import { httpClient } from "./effect/app-node-platform" +import { EventV2 } from "./event" import { FileMutation } from "./file-mutation" import { FileSystem } from "./filesystem" import { FileSystemSearch } from "./filesystem/search" +import { FSUtil } from "./fs-util" import { Generate } from "./generate" import { Form } from "./form" -import { Watcher } from "./filesystem/watcher" +import { Global } from "./global" +import { LocationWatcher } from "./filesystem/location-watcher" import { Image } from "./image" import { Integration } from "./integration" import { Location } from "./location" import { LocationMutation } from "./location-mutation" import { LocationServiceMap } from "./location-service-map" import { MCP } from "./mcp/index" +import { ModelsDev } from "./models-dev" +import { Npm } from "./npm" import { PermissionV2 } from "./permission" import { PluginV2 } from "./plugin" -import { PluginInternal } from "./plugin/internal" -import { Policy } from "./policy" -import { Project } from "./project" +import { PluginRuntime } from "./plugin/runtime" +import { SdkPlugins } from "./plugin/sdk" +import { PluginSupervisor } from "./plugin/supervisor" import { ProjectCopy } from "./project/copy" import { Pty } from "./pty" import { QuestionV2 } from "./question" import { Shell } from "./shell" import { Reference } from "./reference" import { ReferenceGuidance } from "./reference/guidance" -import * as SessionRunnerLLM from "./session/runner/llm" +import { Ripgrep } from "./ripgrep" +import { SessionRunnerLLM } from "./session/runner/llm" import { SessionRunnerModel } from "./session/runner/model" import { SessionCompaction } from "./session/compaction" import { SessionTitle } from "./session/title" @@ -41,20 +48,56 @@ import { InstructionContext } from "./instruction-context" import { SystemContextBuiltIns } from "./system-context/builtins" import { SessionContextEntry } from "./session/context-entry" import { SessionInstructions } from "./session/instructions" -import { BuiltInTools } from "./tool/builtins" import { McpTool } from "./tool/mcp" import { ReadToolFileSystem } from "./tool/read-filesystem" import { ToolRegistry } from "./tool/registry" +import { WebSearchTool } from "./tool/websearch" import { ToolOutputStore } from "./tool-output-store" import { Vcs } from "./vcs" import { VcsBackends } from "./vcs/backends" export { LocationServiceMap } from "./location-service-map" -export const locationServices = LayerNode.group([ - Project.node, +const pluginSupervisorNode = makeLocationNode({ + service: PluginSupervisor.Service, + layer: PluginSupervisor.layer, + deps: [ + PluginV2.node, + SdkPlugins.node, + AgentV2.node, + Catalog.node, + CommandV2.node, + Config.node, + EventV2.node, + FileMutation.node, + FileSystem.node, + FSUtil.node, + Global.node, + httpClient, + Image.node, + Integration.node, + Location.node, + LocationMutation.node, + ModelsDev.node, + Npm.node, + PermissionV2.node, + PluginRuntime.node, + Form.node, + ReadToolFileSystem.node, + Reference.node, + Ripgrep.node, + SessionInstructions.node, + SessionTodo.node, + Shell.node, + SkillV2.node, + ToolRegistry.toolsNode, + VcsBackends.node, + WebSearchTool.configNode, + ], +}) + +const locationServiceNodes = [ Location.node, - Policy.node, Config.node, AgentV2.node, CommandV2.node, @@ -63,12 +106,11 @@ export const locationServices = LayerNode.group([ Catalog.node, AISDK.node, PluginV2.node, - PluginInternal.node, + pluginSupervisorNode, ProjectCopy.node, ProjectCopy.refreshNode, FileSystemSearch.node, FileSystem.node, - Watcher.node, Pty.node, Shell.node, SkillV2.node, @@ -90,7 +132,6 @@ export const locationServices = LayerNode.group([ QuestionV2.node, Generate.node, ReadToolFileSystem.node, - BuiltInTools.node, McpTool.node, SessionInstructions.node, SessionRunnerModel.node, @@ -100,7 +141,11 @@ export const locationServices = LayerNode.group([ SessionRunnerLLM.node, VcsBackends.node, Vcs.node, -]) + // Start repository watches only after boot-critical filesystem and Git work. + LocationWatcher.node, +] as const satisfies readonly Node.LocationNode[] + +export const locationServices = LayerNode.group(locationServiceNodes) export type LocationServices = LayerNode.Output export type LocationError = LayerNode.Error @@ -112,15 +157,21 @@ export function buildLocationServiceMap( LocationServiceMap.Service, LayerMap.make( (ref: Location.Ref) => { + const startedAt = performance.now() const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]]) + // Apply replacements during hoist, not afterward: replacements can + // introduce new tagged dependencies (Location.boundNode depends on + // Project), and the hoist walk is the only pass that can still slice + // those back out. const location = LayerNode.hoist(locationServices, Node.tags.values.global, allReplacements) return LayerNode.compile(location.node).pipe( Layer.fresh, Layer.tap(() => - Effect.logInfo("booting location services", { + Effect.logInfo("location services booted", { directory: ref.directory, workspaceID: ref.workspaceID, + durationMs: Math.round(performance.now() - startedAt), }), ), Layer.provide(LayerNode.compile(location.hoisted)), diff --git a/packages/core/src/mcp/client.ts b/packages/core/src/mcp/client.ts index fcd0814fb8..02362f6428 100644 --- a/packages/core/src/mcp/client.ts +++ b/packages/core/src/mcp/client.ts @@ -46,7 +46,11 @@ const TolerantListPromptsResult = ListPromptsResultSchema.extend({ export class NeedsAuthError extends Schema.TaggedErrorClass()("MCP.NeedsAuthError", { server: Schema.String, -}) {} +}) { + override get message() { + return `MCP server requires authentication: ${this.server}` + } +} export class ConnectError extends Schema.TaggedErrorClass()("MCP.ConnectError", { server: Schema.String, @@ -57,6 +61,7 @@ export interface ToolDefinition { readonly name: string readonly description: string | undefined readonly inputSchema: unknown + readonly outputSchema: unknown } export interface PromptDefinition { @@ -231,6 +236,7 @@ export const connect = Effect.fnUntraced(function* ( name: tool.name, description: tool.description, inputSchema: tool.inputSchema, + outputSchema: "outputSchema" in tool ? tool.outputSchema : undefined, })) }), prompts: () => diff --git a/packages/core/src/mcp/guidance.ts b/packages/core/src/mcp/guidance.ts index eed26170b0..0963a69d23 100644 --- a/packages/core/src/mcp/guidance.ts +++ b/packages/core/src/mcp/guidance.ts @@ -1,6 +1,7 @@ export * as McpGuidance from "./guidance" import { makeLocationNode } from "../effect/app-node" +import { Flag } from "../flag/flag" import { Context, Effect, Layer, Schema } from "effect" import { AgentV2 } from "../agent" import { PermissionV2 } from "../permission" @@ -17,6 +18,11 @@ type Summary = typeof Summary.Type const entries = (servers: ReadonlyArray) => servers.flatMap((server) => [ ` `, + ...(Flag.CODEMODE_ENABLED + ? [ + ` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.group(server.server))}]\`.`, + ] + : []), ...server.instructions.split("\n").map((line) => ` ${line}`), " ", ]) @@ -64,15 +70,17 @@ export const layer = Layer.effect( load: Effect.fn("McpGuidance.load")(function* (selection) { const agent = selection.info if (!agent) return SystemContext.empty + if (Flag.CODEMODE_ENABLED && PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny") + return SystemContext.empty const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], { concurrency: "unbounded", }) - // Hide a server only when every tool it contributes is wholly denied for this agent. + // Instructions are useful only when this agent can reach at least one server tool. const visible = instructions .filter((item) => { const owned = tools.filter((tool) => tool.server === item.server) return ( - owned.length === 0 || + (!Flag.CODEMODE_ENABLED && owned.length === 0) || owned.some( (tool) => PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny", diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index 2075c8dd19..1ce8c3d3ec 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -42,6 +42,7 @@ export class Tool extends Schema.Class("MCP.Tool")({ name: Schema.String, description: Schema.String.pipe(Schema.optional), inputSchema: Schema.Unknown.pipe(Schema.optional), + outputSchema: Schema.Unknown.pipe(Schema.optional), }) {} export const ToolResultContent = Schema.Union([ @@ -127,7 +128,11 @@ export class ResourceContent extends Schema.Class("MCP.Resource export class NotFoundError extends Schema.TaggedErrorClass()("MCP.NotFoundError", { server: ServerName, -}) {} +}) { + override get message() { + return `MCP server not found: ${this.server}` + } +} export class ToolCallError extends Schema.TaggedErrorClass()("MCP.ToolCallError", { server: ServerName, @@ -201,7 +206,7 @@ export const layer = Layer.effect( for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) { runtime.set(ServerName.make(name), { config: { ...server, timeout: { ...timeout, ...server.timeout } }, - status: { status: "disconnected" }, + status: { status: "pending" }, startup: Deferred.makeUnsafe(), }) } @@ -387,7 +392,13 @@ export const layer = Layer.effect( } satisfies MCPClient.ElicitationHandler const toTool = (server: ServerName, def: MCPClient.ToolDefinition) => - new Tool({ server, name: def.name, description: def.description, inputSchema: def.inputSchema }) + new Tool({ + server, + name: def.name, + description: def.description, + inputSchema: def.inputSchema, + outputSchema: def.outputSchema, + }) const toPrompt = (server: ServerName, def: MCPClient.PromptDefinition) => new Prompt({ diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index cf2991168a..a88aebfe07 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -47,7 +47,7 @@ const Cost = Schema.Struct({ const ReasoningOption = Schema.Union([ Schema.Struct({ type: Schema.Literal("effort"), - values: Schema.Array(Schema.String), + values: Schema.Array(Schema.Union([Schema.String, Schema.Null])), }), Schema.Struct({ type: Schema.Literal("toggle"), @@ -67,7 +67,7 @@ export const Model = Schema.Struct({ attachment: Schema.Boolean, reasoning: Schema.Boolean, reasoning_options: Schema.optional(Schema.Array(ReasoningOption)), - temperature: Schema.Boolean, + temperature: Schema.optional(Schema.Boolean), tool_call: Schema.Boolean, interleaved: Schema.optional( Schema.Union([ @@ -125,6 +125,10 @@ export const Provider = Schema.Struct({ export type Provider = Schema.Schema.Type +const Providers = Schema.Record(Schema.String, Provider) +const decodeProviders = Schema.decodeUnknownEffect(Schema.fromJsonString(Providers)) +const decodeProvidersUnknown = Schema.decodeUnknownEffect(Providers) + export const Event = ModelsDev.Event declare const OPENCODE_MODELS_DEV: Record | undefined @@ -176,6 +180,7 @@ const layer = Layer.effect( }) const loadFromDisk = fs.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).pipe( + Effect.flatMap(decodeProvidersUnknown), Effect.catch((error) => { if ( Flag.OPENCODE_MODELS_PATH === undefined && @@ -186,11 +191,17 @@ const layer = Layer.effect( } return Effect.succeed(undefined) }), - Effect.map((v) => v as Record | undefined), ) const loadSnapshot = Effect.sync(() => typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV, + ).pipe( + Effect.flatMap((snapshot) => + snapshot === undefined ? Effect.succeed(undefined) : decodeProvidersUnknown(snapshot), + ), + Effect.catch((cause) => + Effect.logWarning("bundled models snapshot failed schema decode", { cause }).pipe(Effect.as(undefined)), + ), ) const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () { @@ -221,7 +232,7 @@ const layer = Layer.effect( return yield* fetchAndWrite() }), ) - return JSON.parse(text) as Record + return yield* decodeProviders(text) }).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie) const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity) diff --git a/packages/core/src/observability.ts b/packages/core/src/observability.ts index 22285974d8..e99d8919d2 100644 --- a/packages/core/src/observability.ts +++ b/packages/core/src/observability.ts @@ -8,6 +8,12 @@ import { OtlpSerialization } from "effect/unstable/observability" import { Logging } from "./observability/logging" import { Otlp } from "./observability/otlp" +const local = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe( + Layer.provide(NodeFileSystem.layer), + Layer.orDie, + Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())), +) + export const layer = Layer.unwrap( Effect.gen(function* () { const logs = Logger.layer([...Logging.loggers(), ...Otlp.loggers()], { mergeWithExisting: false }).pipe( @@ -19,6 +25,6 @@ export const layer = Layer.unwrap( ) return Layer.merge(logs, yield* Effect.promise(Otlp.tracingLayer)) }), -) +).pipe(Layer.catchCause(() => local)) export const node = LayerNode.make({ name: "observability", layer, deps: [] }) diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index 791d0a9bcd..3a8dc12b25 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -1,7 +1,7 @@ export * as PermissionV2 from "./permission" import { makeLocationNode } from "./effect/app-node" -import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect" +import { Context, Deferred, Effect, Layer, Schema } from "effect" import { Permission } from "@opencode-ai/schema/permission" import { EventV2 } from "./event" import { Location } from "./location" @@ -11,7 +11,9 @@ import { SessionStore } from "./session/store" import { Wildcard } from "./util/wildcard" import { PermissionSaved } from "./permission/saved" -export { Effect, Rule, Ruleset } from "@opencode-ai/schema/permission" +const PermissionEffect = Permission.Effect +export { PermissionEffect as Effect } +export { Rule, Ruleset } from "@opencode-ai/schema/permission" const missingAgentPermissions: Permission.Ruleset = [{ action: "*", resource: "*", effect: "deny" }] export const ID = Permission.ID @@ -90,12 +92,12 @@ export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset { } export interface Interface { - readonly ask: (input: AssertInput) => EffectRuntime.Effect - readonly assert: (input: AssertInput) => EffectRuntime.Effect - readonly reply: (input: ReplyInput) => EffectRuntime.Effect - readonly get: (id: ID) => EffectRuntime.Effect - readonly forSession: (sessionID: SessionV2.ID) => EffectRuntime.Effect> - readonly list: () => EffectRuntime.Effect> + readonly ask: (input: AssertInput) => Effect.Effect + readonly assert: (input: AssertInput) => Effect.Effect + readonly reply: (input: ReplyInput) => Effect.Effect + readonly get: (id: ID) => Effect.Effect + readonly forSession: (sessionID: SessionV2.ID) => Effect.Effect> + readonly list: () => Effect.Effect> } export class Service extends Context.Service()("@opencode/v2/Permission") {} @@ -108,7 +110,7 @@ interface Pending { const layer = Layer.effect( Service, - EffectRuntime.gen(function* () { + Effect.gen(function* () { const events = yield* EventV2.Service const location = yield* Location.Service const agents = yield* AgentV2.Service @@ -116,28 +118,25 @@ const layer = Layer.effect( const saved = yield* PermissionSaved.Service const pending = new Map() - yield* EffectRuntime.addFinalizer(() => - EffectRuntime.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new RejectedError()), { + yield* Effect.addFinalizer(() => + Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new RejectedError()), { discard: true, }).pipe( - EffectRuntime.ensuring( - EffectRuntime.sync(() => { + Effect.ensuring( + Effect.sync(() => { pending.clear() }), ), ), ) - const savedRules = EffectRuntime.fnUntraced(function* () { + const savedRules = Effect.fnUntraced(function* () { return (yield* saved.list({ projectID: location.project.id })).map( (item): Permission.Rule => ({ action: item.action, resource: item.resource, effect: "allow" }), ) }) - const configured = EffectRuntime.fn("PermissionV2.configured")(function* ( - sessionID: SessionV2.ID, - agentID?: AgentV2.ID, - ) { + const configured = Effect.fn("PermissionV2.configured")(function* (sessionID: SessionV2.ID, agentID?: AgentV2.ID) { const session = yield* sessions.get(sessionID) if (!session) return yield* new SessionV2.NotFoundError({ sessionID }) const agent = yield* agents.resolve(agentID ?? session.agent) @@ -152,7 +151,7 @@ const layer = Layer.effect( return rules.filter((rule) => Wildcard.match(input.action, rule.action)) } - const evaluateInput = EffectRuntime.fnUntraced(function* (input: AssertInput) { + const evaluateInput = Effect.fnUntraced(function* (input: AssertInput) { const rules = yield* configured(input.sessionID, input.agent) if (denied(input, rules)) return { effect: "deny" as const, rules } const all = [...rules, ...(yield* savedRules())] @@ -174,29 +173,30 @@ const layer = Layer.effect( } const create = (request: Request, agent?: AgentV2.ID) => - EffectRuntime.uninterruptible( - EffectRuntime.gen(function* () { + Effect.uninterruptible( + Effect.gen(function* () { const deferred = yield* Deferred.make() const item = { request, agent, deferred } - if (pending.has(request.id)) return yield* EffectRuntime.die(`Duplicate pending permission ID: ${request.id}`) + if (pending.has(request.id)) + return yield* Effect.die(new Error(`Duplicate pending permission ID: ${request.id}`)) pending.set(request.id, item) yield* events .publish(Event.Asked, request) - .pipe(EffectRuntime.onError(() => EffectRuntime.sync(() => pending.delete(request.id)))) + .pipe(Effect.onError(() => Effect.sync(() => pending.delete(request.id)))) return item }), ) - const ask = EffectRuntime.fn("PermissionV2.ask")(function* (input: AssertInput) { + const ask = Effect.fn("PermissionV2.ask")(function* (input: AssertInput) { const result = yield* evaluateInput(input) const value = request(input) if (result.effect === "ask") yield* create(value, input.agent) return { id: value.id, effect: result.effect } }) - const assert = EffectRuntime.fn("PermissionV2.assert")((input: AssertInput) => - EffectRuntime.uninterruptibleMask((restore) => - EffectRuntime.gen(function* () { + const assert = Effect.fn("PermissionV2.assert")((input: AssertInput) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { const result = yield* evaluateInput(input) if (result.effect === "deny") { return yield* new DeniedError({ @@ -206,8 +206,8 @@ const layer = Layer.effect( if (result.effect === "allow") return const item = yield* create(request(input), input.agent) return yield* restore(Deferred.await(item.deferred)).pipe( - EffectRuntime.ensuring( - EffectRuntime.sync(() => { + Effect.ensuring( + Effect.sync(() => { pending.delete(item.request.id) }), ), @@ -216,9 +216,9 @@ const layer = Layer.effect( ), ) - const reply = EffectRuntime.fn("PermissionV2.reply")((input: ReplyInput) => - EffectRuntime.uninterruptible( - EffectRuntime.gen(function* () { + const reply = Effect.fn("PermissionV2.reply")((input: ReplyInput) => + Effect.uninterruptible( + Effect.gen(function* () { const existing = pending.get(input.requestID) if (!existing) return yield* new NotFoundError({ requestID: input.requestID }) yield* events.publish(Event.Replied, { @@ -261,7 +261,7 @@ const layer = Layer.effect( for (const [id, item] of pending) { const input = { ...item.request } const rules = yield* configured(item.request.sessionID, item.agent).pipe( - EffectRuntime.catchTag("Session.NotFoundError", () => EffectRuntime.succeed(undefined)), + Effect.catchTag("Session.NotFoundError", () => Effect.succeed(undefined)), ) if (!rules) continue if (denied(input, rules)) continue @@ -284,15 +284,15 @@ const layer = Layer.effect( ), ) - const list = EffectRuntime.fn("PermissionV2.list")(function* () { + const list = Effect.fn("PermissionV2.list")(function* () { return Array.from(pending.values(), (item) => item.request) }) - const get = EffectRuntime.fn("PermissionV2.get")(function* (id: ID) { + const get = Effect.fn("PermissionV2.get")(function* (id: ID) { return pending.get(id)?.request }) - const forSession = EffectRuntime.fn("PermissionV2.forSession")(function* (sessionID: SessionV2.ID) { + const forSession = Effect.fn("PermissionV2.forSession")(function* (sessionID: SessionV2.ID) { return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID) }) diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 56612dfcff..8427a5f74b 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -1,16 +1,15 @@ export * as PluginV2 from "./plugin" +import type { Plugin } from "@opencode-ai/plugin/v2/effect" +import { Event, ID, type Info } from "@opencode-ai/schema/plugin" import { makeLocationNode } from "./effect/app-node" -import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect" -import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/v2/effect" -import { Plugin } from "@opencode-ai/schema/plugin" +import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect" import { AgentV2 } from "./agent" import { AISDK } from "./aisdk" import { Catalog } from "./catalog" import { CommandV2 } from "./command" import { EventV2 } from "./event" import { Integration } from "./integration" -import { KeyedMutex } from "./effect/keyed-mutex" import { Location } from "./location" import { PluginHost } from "./plugin/host" import { PluginRuntime } from "./plugin/runtime" @@ -21,16 +20,8 @@ import { ToolRegistry } from "./tool/registry" import { ToolHooks } from "./tool/hooks" import { VcsBackends } from "./vcs/backends" -export const ID = Plugin.ID -export type ID = typeof ID.Type -export const Info = Plugin.Info -export type Info = Plugin.Info -export const Event = Plugin.Event - export interface Interface { - readonly add: (id: ID, effect: PluginDefinition["effect"]) => Effect.Effect - readonly remove: (id: ID) => Effect.Effect - readonly wait: (id: ID) => Effect.Effect + readonly activate: (plugins: readonly { readonly plugin: Plugin; readonly version?: string }[]) => Effect.Effect readonly list: () => Effect.Effect } @@ -40,110 +31,83 @@ const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2.Service - const locks = KeyedMutex.makeUnsafe() const scope = yield* Scope.make() - const active = new Map() - const loading = new Set() - const waiters = new Map>>() - const failures = new Map>() - let host: Parameters[0] + const active = new Map() + const lock = Semaphore.makeUnsafe(1) + let generation: readonly { readonly id: typeof ID.Type; readonly version?: string }[] | undefined = [] + let host: Parameters[0] - const add = Effect.fn("Plugin.add")(function* (id: ID, effect: PluginDefinition["effect"]) { - if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${id}`) + const activate = Effect.fn("Plugin.activate")(function* ( + plugins: readonly { readonly plugin: Plugin; readonly version?: string }[], + ) { + const definitions = plugins.map((entry) => ({ + ...entry.plugin, + id: ID.make(entry.plugin.id), + ...(entry.version === undefined ? {} : { version: entry.version }), + })) + const ids = new Set() + for (const definition of definitions) { + if (ids.has(definition.id)) return yield* Effect.die(new Error(`Duplicate plugin ID: ${definition.id}`)) + ids.add(definition.id) + } - yield* locks.withLock(id)( - Effect.sync(() => { - loading.add(id) - failures.delete(id) - }).pipe( - Effect.andThen( - State.batch( - Effect.gen(function* () { - const existing = active.get(id) - active.delete(id) - if (existing) yield* Scope.close(existing, Exit.void).pipe(Effect.ignore) + yield* lock.withPermit( + Effect.gen(function* () { + if ( + generation !== undefined && + generation.length === definitions.length && + generation.every( + (plugin, index) => plugin.id === definitions[index]?.id && plugin.version === definitions[index]?.version, + ) + ) { + return + } + generation = undefined + const exit = yield* State.batch( + Effect.gen(function* () { + const scopes = Array.from(active.values()).toReversed() + active.clear() + const inherit = yield* State.inherit() + yield* Effect.forEach(scopes, (scope) => Scope.close(scope, Exit.void).pipe(Effect.ignore), { + discard: true, + }) + for (const definition of definitions) { const child = yield* Scope.fork(scope) - yield* effect(host).pipe( - Scope.provide(child), - Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }), + const loaded = yield* Effect.suspend(() => definition.effect(host)).pipe( + inherit, + Effect.updateContext((_context: Context.Context) => Context.make(Scope.Scope, child)), + Effect.withSpan("Plugin.load", { attributes: { "plugin.id": definition.id } }), + Effect.andThen(events.publish(Event.Added, { id: definition.id })), Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)), + Effect.exit, ) - yield* events.publish(Event.Added, { id }) - active.set(id, child) - yield* Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.succeed(waiter, undefined), { - discard: true, - }) - waiters.delete(id) - }), - ), - ), - Effect.onExit((exit) => { - if (Exit.isSuccess(exit)) return Effect.void - failures.set(id, exit) - return Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.done(waiter, exit), { - discard: true, - }).pipe(Effect.ensuring(Effect.sync(() => waiters.delete(id)))) - }), - Effect.ensuring(Effect.sync(() => loading.delete(id))), - ), - ) - }) - - const remove = Effect.fn("Plugin.remove")(function* (id: ID) { - if (loading.has(id)) return yield* Effect.die(`Cannot remove plugin ${id} while it is loading`) - - yield* locks.withLock(id)( - State.batch( - Effect.gen(function* () { - const current = active.get(id) - active.delete(id) - failures.delete(id) - if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore) - }), - ), - ) - }) - - const wait = Effect.fn("Plugin.wait")(function* (id: ID) { - const waiter = yield* Deferred.make() - const pending = yield* locks.withLock(id)( - Effect.sync(() => { - if (active.has(id)) return false - const failure = failures.get(id) - if (failure) return failure - const current = waiters.get(id) ?? new Set() - current.add(waiter) - waiters.set(id, current) - return true - }), - ) - if (!pending) return - if (typeof pending !== "boolean") return yield* pending - yield* Deferred.await(waiter).pipe( - Effect.ensuring( - locks.withLock(id)( - Effect.sync(() => { - const current = waiters.get(id) - current?.delete(waiter) - if (current?.size === 0) waiters.delete(id) + if (Exit.isFailure(loaded)) return loaded + active.set(definition.id, child) + } + return Exit.void }), - ), - ), + ) + if (Exit.isFailure(exit)) return yield* exit + generation = definitions.map((definition) => ({ + id: definition.id, + ...(definition.version === undefined ? {} : { version: definition.version }), + })) + yield* events.publish(Event.Updated, {}) + }), ) }) yield* Effect.addFinalizer((exit) => Effect.gen(function* () { active.clear() + generation = [] yield* State.batch(Scope.close(scope, exit)) }), ) const service = Service.of({ - add, - remove, - wait, + activate, list: Effect.fn("Plugin.list")(function* () { return Array.from(active.keys()).map((id) => ({ id })) }), diff --git a/packages/core/src/plugin/agent.ts b/packages/core/src/plugin/agent.ts index a0e3556ce8..7549089bf3 100644 --- a/packages/core/src/plugin/agent.ts +++ b/packages/core/src/plugin/agent.ts @@ -1,7 +1,7 @@ export * as AgentPlugin from "./agent" import path from "path" -import { define } from "./internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { Effect } from "effect" import { AgentV2 } from "../agent" import { Global } from "../global" @@ -100,7 +100,7 @@ Rules: - If the conversation ends with an imperative statement or request to the user (e.g. "Now please run the command and paste the console output"), always include that exact request in the summary` export const Plugin = define({ - id: "agent", + id: "opencode.agent", effect: Effect.fn(function* (ctx) { const location = yield* Location.Service const worktree = location.directory diff --git a/packages/core/src/plugin/command.ts b/packages/core/src/plugin/command.ts index 1989641238..7bb14dad31 100644 --- a/packages/core/src/plugin/command.ts +++ b/packages/core/src/plugin/command.ts @@ -1,13 +1,13 @@ export * as CommandPlugin from "./command" -import { define } from "./internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { Effect } from "effect" import { Location } from "../location" import PROMPT_INITIALIZE from "./command/initialize.txt" import PROMPT_REVIEW from "./command/review.txt" export const Plugin = define({ - id: "command", + id: "opencode.command", effect: Effect.fn(function* (ctx) { const location = yield* Location.Service yield* ctx.command.transform((draft) => { diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index ba11098d16..e0a8989605 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -1,12 +1,14 @@ export * as PluginHost from "./host" -import type { PluginContext as Interface } from "@opencode-ai/plugin/v2/effect" -import { Effect, Schema } from "effect" +import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import { EventManifest } from "@opencode-ai/schema/event-manifest" +import { Effect, Schema, Stream } from "effect" import { AgentV2 } from "../agent" import { AISDK } from "../aisdk" import { Catalog } from "../catalog" import { CommandV2 } from "../command" import { Credential } from "../credential" +import { EventV2 } from "../event" import { Integration } from "../integration" import { Location } from "../location" import { ModelV2 } from "../model" @@ -22,12 +24,12 @@ import { VcsBackends } from "../vcs/backends" import { WorkspaceV2 } from "../workspace" const mutable = (value: T) => value as DeepMutable - export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) { const agents = yield* AgentV2.Service const aisdk = yield* AISDK.Service const catalog = yield* Catalog.Service const commands = yield* CommandV2.Service + const events = yield* EventV2.Service const integration = yield* Integration.Service const location = yield* Location.Service const reference = yield* Reference.Service @@ -42,7 +44,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int workspaceID: location.workspaceID, project: location.project, }) - const locationRef = (input?: Parameters[0]) => + const locationRef = (input?: Parameters[0]) => input?.location === undefined ? undefined : Location.Ref.make({ @@ -54,6 +56,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }) const isCurrentLocation = (ref: Location.Ref) => ref.directory === location.directory && ref.workspaceID === location.workspaceID + const response = (effect: Effect.Effect) => + effect.pipe(Effect.map((data) => ({ location: locationInfo(), data }))) return { options: {}, @@ -65,15 +69,15 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }, reload: agents.reload, transform: (callback) => - agents.transform((draft) => + agents.transform((draft) => { callback({ list: () => mutable(draft.list()), get: (id) => mutable(draft.get(AgentV2.ID.make(id))), default: (id) => draft.default(id === undefined ? undefined : AgentV2.ID.make(id)), update: (id, update) => draft.update(AgentV2.ID.make(id), update), remove: (id) => draft.remove(AgentV2.ID.make(id)), - }), - ), + }) + }), }, aisdk: { sdk: (callback) => @@ -104,9 +108,26 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }), }, catalog: { + provider: { + list: () => response(catalog.provider.available()), + get: (input) => + catalog.provider + .get(ProviderV2.ID.make(input.providerID)) + .pipe( + Effect.flatMap((provider) => + provider === undefined + ? Effect.fail(new Error(`Provider not found: ${input.providerID}`)) + : response(Effect.succeed(provider)), + ), + ), + }, + model: { + list: () => response(catalog.model.available()), + default: () => response(catalog.model.default()), + }, reload: catalog.reload, transform: (callback) => - catalog.transform((draft) => + catalog.transform((draft) => { callback({ provider: { list: () => mutable(draft.provider.list()), @@ -127,14 +148,42 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int draft.model.default.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), }, }, - }), - ), + }) + }), }, command: { + list: () => response(commands.list()), reload: commands.reload, - transform: commands.transform, + transform: (callback) => + commands.transform((draft) => { + callback(draft) + }), + }, + event: { + subscribe: () => events.live().pipe(Stream.filter(EventManifest.isServer)), }, integration: { + list: () => response(integration.list()), + get: (input) => response(integration.get(Integration.ID.make(input.integrationID))), + connectKey: (input) => + integration.connection.key({ + integrationID: Integration.ID.make(input.integrationID), + key: input.key, + label: input.label, + }), + connectOauth: (input) => + response( + integration.connection.oauth({ + integrationID: Integration.ID.make(input.integrationID), + methodID: Integration.MethodID.make(input.methodID), + inputs: input.inputs, + label: input.label, + }), + ), + attemptStatus: (input) => response(integration.attempt.status(Integration.AttemptID.make(input.attemptID))), + attemptComplete: (input) => + integration.attempt.complete({ attemptID: Integration.AttemptID.make(input.attemptID), code: input.code }), + attemptCancel: (input) => integration.attempt.cancel(Integration.AttemptID.make(input.attemptID)), reload: integration.reload, connection: { active: (id) => integration.connection.active(Integration.ID.make(id)), @@ -144,7 +193,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int ), }, transform: (callback) => - integration.transform((draft) => + integration.transform((draft) => { callback({ list: () => mutable(draft.list()), get: (id) => mutable(draft.get(Integration.ID.make(id))), @@ -221,36 +270,37 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int remove: (id, method) => draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)), }, - }), - ), + }) + }), }, plugin: { - add: (input) => plugin.add(PluginV2.ID.make(input.id), input.effect), - remove: (id) => plugin.remove(PluginV2.ID.make(id)), + list: () => response(plugin.list()), }, reference: { + list: () => response(reference.list()), reload: reference.reload, transform: (callback) => - reference.transform((draft) => + reference.transform((draft) => { callback({ add: (name, source) => draft.add(name, Schema.decodeUnknownSync(Reference.Source)(source)), remove: draft.remove, list: draft.list, - }), - ), + }) + }), }, skill: { + list: () => response(skill.list()), reload: skill.reload, transform: (callback) => - skill.transform((draft) => + skill.transform((draft) => { callback({ source: (source) => draft.source(Schema.decodeUnknownSync(SkillV2.Source)(source)), list: draft.list, - }), - ), + }) + }), }, tool: { - register: (input) => tools.register(input), + register: (input, options) => tools.register(input, options), execute: { before: (callback) => toolHooks.hook.before((event) => { @@ -310,5 +360,5 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int command: runtime.session.command, interrupt: (input) => runtime.session.interrupt(input.sessionID), }, - } satisfies Interface + } satisfies PluginContext }) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index e334f0e9b2..eff0025c92 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -1,168 +1,170 @@ export * as PluginInternal from "./internal" -import { makeLocationNode } from "../effect/app-node" -import { httpClient } from "../effect/app-node-platform" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" -import { Context, Effect, Layer, Scope } from "effect" +import type { Plugin } from "@opencode-ai/plugin/v2/effect" +import { Context, Effect, Scope } from "effect" +import { HttpClient } from "effect/unstable/http" import { AgentV2 } from "../agent" import { Catalog } from "../catalog" import { CommandV2 } from "../command" import { Config } from "../config" import { ConfigAgentPlugin } from "../config/plugin/agent" import { ConfigCommandPlugin } from "../config/plugin/command" -import { ConfigExternalPlugin } from "../config/plugin/external" import { ConfigProviderPlugin } from "../config/plugin/provider" import { ConfigReferencePlugin } from "../config/plugin/reference" import { ConfigSkillPlugin } from "../config/plugin/skill" import { EventV2 } from "../event" +import { FileMutation } from "../file-mutation" +import { Form } from "../form" import { FileSystem } from "../filesystem" import { FSUtil } from "../fs-util" import { Global } from "../global" +import { Image } from "../image" import { Integration } from "../integration" import { Location } from "../location" import { LocationMutation } from "../location-mutation" import { ModelsDev } from "../models-dev" import { Npm } from "../npm" -import { PluginV2 } from "../plugin" -import { PluginRuntime } from "../plugin/runtime" import { PermissionV2 } from "../permission" import { Reference } from "../reference" import { Ripgrep } from "../ripgrep" +import { SessionInstructions } from "../session/instructions" +import { SessionTodo } from "../session/todo" import { Shell } from "../shell" import { SkillV2 } from "../skill" -import { State } from "../state" -import { ToolRegistry } from "../tool/registry" +import { ApplyPatchTool } from "../tool/apply-patch" +import { EditTool } from "../tool/edit" +import { GlobTool } from "../tool/glob" +import { GrepTool } from "../tool/grep" +import { QuestionTool } from "../tool/question" +import { ReadToolFileSystem } from "../tool/read-filesystem" +import { ReadTool } from "../tool/read" +import { ShellTool } from "../tool/shell" +import { SkillTool } from "../tool/skill" +import { SubagentTool } from "../tool/subagent" +import { TodoWriteTool } from "../tool/todowrite" import { Tools } from "../tool/tools" +import { WebFetchTool } from "../tool/webfetch" +import { WebSearchTool } from "../tool/websearch" +import { WriteTool } from "../tool/write" import { VcsBackends } from "../vcs/backends" -import { HttpClient } from "effect/unstable/http" import { AgentPlugin } from "./agent" import { CommandPlugin } from "./command" import { ModelsDevPlugin } from "./models-dev" import { ProviderPlugins } from "./provider" -import { SdkPlugins } from "./sdk" +import { PluginRuntime } from "./runtime" import { SkillPlugin } from "./skill" import { VariantPlugin } from "./variant" -import { GlobTool } from "../tool/glob" -import { ShellTool } from "../tool/shell" -import { SubagentTool } from "../tool/subagent" -export type Requirements = - | AgentV2.Service - | Catalog.Service - | CommandV2.Service - | Config.Service - | EventV2.Service - | FileSystem.Service - | FSUtil.Service - | Global.Service - | HttpClient.HttpClient - | Integration.Service - | Location.Service - | LocationMutation.Service - | ModelsDev.Service - | Npm.Service - | PermissionV2.Service - | PluginRuntime.Service - | Reference.Service - | Ripgrep.Service - | Shell.Service - | SkillV2.Service - | Tools.Service - | VcsBackends.Service - -export interface Plugin { - readonly id: string - readonly effect: (context: PluginContext) => Effect.Effect -} - -export function define(plugin: Plugin) { - return plugin -} - -const layer = Layer.effectDiscard( - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const sdkPlugins = yield* SdkPlugins.Service - const services = Context.mergeAll( - Context.make(Catalog.Service, yield* Catalog.Service), - Context.make(CommandV2.Service, yield* CommandV2.Service), - Context.make(Integration.Service, yield* Integration.Service), - Context.make(AgentV2.Service, yield* AgentV2.Service), - Context.make(Config.Service, yield* Config.Service), - Context.make(Location.Service, yield* Location.Service), - Context.make(ModelsDev.Service, yield* ModelsDev.Service), - Context.make(Npm.Service, yield* Npm.Service), - Context.make(EventV2.Service, yield* EventV2.Service), - Context.make(FSUtil.Service, yield* FSUtil.Service), - Context.make(FileSystem.Service, yield* FileSystem.Service), - Context.make(Global.Service, yield* Global.Service), - Context.make(HttpClient.HttpClient, yield* HttpClient.HttpClient), - Context.make(LocationMutation.Service, yield* LocationMutation.Service), - Context.make(PermissionV2.Service, yield* PermissionV2.Service), - Context.make(SkillV2.Service, yield* SkillV2.Service), - Context.make(Reference.Service, yield* Reference.Service), - Context.make(Ripgrep.Service, yield* Ripgrep.Service), - Context.make(Shell.Service, yield* Shell.Service), - Context.make(Tools.Service, yield* Tools.Service), - Context.make(VcsBackends.Service, yield* VcsBackends.Service), - Context.make(PluginRuntime.Service, yield* PluginRuntime.Service), - ) - const add = (input: Plugin) => - plugin.add(PluginV2.ID.make(input.id), (context: PluginContext) => - input.effect(context).pipe(Effect.provide(services)), - ) - - yield* State.batch( - Effect.gen(function* () { - yield* add(ConfigReferencePlugin.Plugin) - yield* add(AgentPlugin.Plugin) - yield* add(CommandPlugin.Plugin) - yield* add(SkillPlugin.Plugin) - yield* add(ModelsDevPlugin) - yield* add(ConfigExternalPlugin.Plugin) - yield* add(GlobTool.Plugin) - yield* add(ShellTool.Plugin) - yield* add(SubagentTool.Plugin) - yield* add(ConfigAgentPlugin.Plugin) - yield* add(ConfigCommandPlugin.Plugin) - yield* add(ConfigSkillPlugin.Plugin) - for (const item of ProviderPlugins) yield* add(item) - yield* add(ConfigProviderPlugin.Plugin) - yield* add(VariantPlugin.Plugin) - // Embedder-contributed plugins are added last so they layer over config. - for (const plugin of sdkPlugins.all()) yield* add(plugin) - }), - ).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true })) - }), -) - -export const node = makeLocationNode({ - name: "plugin-internal", - layer, - deps: [ - Catalog.node, - CommandV2.node, - PluginV2.node, - Integration.node, - AgentV2.node, - Config.node, - Location.node, - LocationMutation.node, - ModelsDev.node, - Npm.node, - EventV2.node, - FSUtil.node, - FileSystem.node, - Global.node, - httpClient, - PermissionV2.node, - SkillV2.node, - Reference.node, - Ripgrep.node, - Shell.node, - ToolRegistry.toolsNode, - VcsBackends.node, - PluginRuntime.node, - SdkPlugins.node, - ], +const services = Effect.fn("PluginInternal.services")(function* () { + const agent = yield* AgentV2.Service + const catalog = yield* Catalog.Service + const command = yield* CommandV2.Service + const config = yield* Config.Service + const events = yield* EventV2.Service + const mutation = yield* FileMutation.Service + const filesystem = yield* FileSystem.Service + const fs = yield* FSUtil.Service + const global = yield* Global.Service + const http = yield* HttpClient.HttpClient + const image = yield* Image.Service + const integration = yield* Integration.Service + const location = yield* Location.Service + const locationMutation = yield* LocationMutation.Service + const models = yield* ModelsDev.Service + const npm = yield* Npm.Service + const permission = yield* PermissionV2.Service + const runtime = yield* PluginRuntime.Service + const form = yield* Form.Service + const read = yield* ReadToolFileSystem.Service + const reference = yield* Reference.Service + const ripgrep = yield* Ripgrep.Service + const instructions = yield* SessionInstructions.Service + const todo = yield* SessionTodo.Service + const shell = yield* Shell.Service + const skill = yield* SkillV2.Service + const tools = yield* Tools.Service + const websearch = yield* WebSearchTool.ConfigService + const vcs = yield* VcsBackends.Service + return Context.mergeAll( + Context.make(AgentV2.Service, agent), + Context.make(Catalog.Service, catalog), + Context.make(CommandV2.Service, command), + Context.make(Config.Service, config), + Context.make(EventV2.Service, events), + Context.make(FileMutation.Service, mutation), + Context.make(FileSystem.Service, filesystem), + Context.make(FSUtil.Service, fs), + Context.make(Global.Service, global), + Context.make(HttpClient.HttpClient, http), + Context.make(Image.Service, image), + Context.make(Integration.Service, integration), + Context.make(Location.Service, location), + Context.make(LocationMutation.Service, locationMutation), + Context.make(ModelsDev.Service, models), + Context.make(Npm.Service, npm), + Context.make(PermissionV2.Service, permission), + Context.make(PluginRuntime.Service, runtime), + Context.make(Form.Service, form), + Context.make(ReadToolFileSystem.Service, read), + Context.make(Reference.Service, reference), + Context.make(Ripgrep.Service, ripgrep), + Context.make(SessionInstructions.Service, instructions), + Context.make(SessionTodo.Service, todo), + Context.make(Shell.Service, shell), + Context.make(SkillV2.Service, skill), + Context.make(Tools.Service, tools), + Context.make(WebSearchTool.ConfigService, websearch), + Context.make(VcsBackends.Service, vcs), + ) +}) + +type ContextServices = A extends Context.Context ? R : never + +export type Requirements = ContextServices>> + +export type InternalPlugin = Plugin + +const pre = [ + AgentPlugin.Plugin, + CommandPlugin.Plugin, + SkillPlugin.Plugin, + ModelsDevPlugin, + ...ProviderPlugins, + ApplyPatchTool.Plugin, + EditTool.Plugin, + GlobTool.Plugin, + GrepTool.Plugin, + QuestionTool.Plugin, + ReadTool.Plugin, + ShellTool.Plugin, + SkillTool.Plugin, + SubagentTool.Plugin, + TodoWriteTool.Plugin, + WebFetchTool.Plugin, + WebSearchTool.Plugin, + WriteTool.Plugin, +] as const satisfies readonly InternalPlugin[] + +const post = [ + ConfigReferencePlugin.Plugin, + ConfigAgentPlugin.Plugin, + ConfigCommandPlugin.Plugin, + ConfigSkillPlugin.Plugin, + ConfigProviderPlugin.Plugin, + VariantPlugin.Plugin, +] as const satisfies readonly InternalPlugin[] + +export const list = Effect.fn("PluginInternal.list")(function* () { + const context = yield* services() + const resolve = (plugins: readonly InternalPlugin[]) => + plugins.map( + (plugin): Plugin => ({ + id: plugin.id, + effect: (host) => plugin.effect(host).pipe(Effect.provide(context)), + }), + ) + return { + pre: resolve(pre), + post: resolve(post), + } }) diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index 2ab445b248..23a166e267 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -1,4 +1,4 @@ -import { define } from "./internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import type { ModelV2Info } from "@opencode-ai/sdk/v2/types" import { Effect, Stream } from "effect" import { EventV2 } from "../event" @@ -197,68 +197,69 @@ function applyModel( } export const ModelsDevPlugin = define({ - id: "models-dev", + id: "opencode.models-dev", effect: Effect.fn(function* (ctx) { const modelsDev = yield* ModelsDev.Service const events = yield* EventV2.Service - yield* ctx.integration.transform( - Effect.fn(function* (integrations) { - const data = yield* modelsDev.get() - for (const item of Object.values(data)) { - if (item.env.length === 0) continue - const integrationID = item.id - integrations.update(integrationID, (integration) => (integration.name = item.name)) - integrations.method.update({ - integrationID, - method: { type: "key" }, - }) - integrations.method.update({ - integrationID, - method: { type: "env", names: [...item.env] }, - }) - } - }), - ) - yield* ctx.catalog.transform( - Effect.fn(function* (catalog) { - const data = yield* modelsDev.get() - for (const item of Object.values(data)) { - const providerID = ProviderV2.ID.make(item.id) - catalog.provider.update(providerID, (provider) => { - provider.name = item.name - provider.api = item.npm - ? { - type: "aisdk", - package: item.npm, - url: item.api, - } - : { - type: "native", - url: item.api, - settings: {}, - } - }) + const loaded = { data: yield* modelsDev.get() } + yield* ctx.integration.transform((integrations) => { + for (const item of Object.values(loaded.data)) { + if (item.env.length === 0) continue + const integrationID = item.id + integrations.update(integrationID, (integration) => (integration.name = item.name)) + integrations.method.update({ + integrationID, + method: { type: "key" }, + }) + integrations.method.update({ + integrationID, + method: { type: "env", names: [...item.env] }, + }) + } + }) + yield* ctx.catalog.transform((catalog) => { + for (const item of Object.values(loaded.data)) { + const providerID = ProviderV2.ID.make(item.id) + catalog.provider.update(providerID, (provider) => { + provider.name = item.name + provider.api = item.npm + ? { + type: "aisdk", + package: item.npm, + url: item.api, + } + : { + type: "native", + url: item.api, + settings: {}, + } + }) - for (const model of Object.values(item.models)) { - const baseCost = cost(model.cost) - const variants = reasoningVariants(item, model) - catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost, variants })) - for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) { - catalog.model.update(providerID, `${model.id}-${mode}`, (draft) => - applyModel(draft, model, { - name: modeName(model, mode), - cost: mergeCost(baseCost, options.cost), - request: options.provider, - variants, - }), - ) - } + for (const model of Object.values(item.models)) { + const baseCost = cost(model.cost) + const variants = reasoningVariants(item, model) + catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost, variants })) + for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) { + catalog.model.update(providerID, `${model.id}-${mode}`, (draft) => + applyModel(draft, model, { + name: modeName(model, mode), + cost: mergeCost(baseCost, options.cost), + request: options.provider, + variants, + }), + ) } } - }), - ) + } + }) yield* events.subscribe(ModelsDev.Event.Refreshed).pipe( - Stream.runForEach(() => ctx.integration.reload().pipe(Effect.andThen(ctx.catalog.reload()))), + Stream.runForEach(() => + modelsDev.get().pipe( + Effect.tap((data) => Effect.sync(() => (loaded.data = data))), + Effect.andThen(ctx.integration.reload()), + Effect.andThen(ctx.catalog.reload()), + ), + ), Effect.forkScoped({ startImmediately: true }), ) }), diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index d0bf82c2a0..bcd3db1323 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -1,16 +1,15 @@ export * as PluginPromise from "./promise" import { define } from "@opencode-ai/plugin/v2/effect" -import type { Plugin, PluginContext, Registration } from "@opencode-ai/plugin/v2/promise" -import { Effect, Scope } from "effect" +import type { Plugin, PluginContext } from "@opencode-ai/plugin/v2/promise" +import { Effect, Scope, Stream } from "effect" -// The Effect host hands back this registration shape; mirror it structurally so -// we do not have to alias the Effect package's `Registration` against the Promise one. type HostRegistration = { readonly dispose: Effect.Effect } +type Registration = { readonly dispose: () => Promise } /** * Adapts a Promise plugin into an Effect plugin so the existing Effect-only - * loader (`PluginV2` / `PluginInternal`) can run it unchanged. + * loader (`PluginV2` / `PluginSupervisor`) can run it unchanged. * * Hook registrations created during the async `setup` attach to the plugin's * scope, so unloading the plugin disposes them. The captured fiber context @@ -31,20 +30,23 @@ export function fromPromise(plugin: Plugin) { dispose: () => Effect.runPromiseWith(context)(registration.dispose), })) - const run = (effect: Effect.Effect) => Effect.runPromiseWith(context)(effect) + const run = (effect: Effect.Effect) => Effect.runPromiseWith(context)(effect) const transform = (domain: { - transform: ( - callback: (draft: Draft) => Effect.Effect | void, - ) => Effect.Effect + transform: (callback: (draft: Draft) => void) => Effect.Effect }) => - (callback: (draft: Draft) => Promise | void) => - register(domain.transform((draft) => Effect.promise(() => Promise.resolve(callback(draft))))) + (callback: (draft: Draft) => void) => + register( + domain.transform((draft) => { + callback(draft) + }), + ) const context2: PluginContext = { options: host.options, agent: { + list: (input) => run(host.agent.list(input)), transform: transform(host.agent), reload: () => run(host.agent.reload()), }, @@ -55,14 +57,33 @@ export function fromPromise(plugin: Plugin) { register(host.aisdk.language((event) => Effect.promise(() => Promise.resolve(callback(event))))), }, catalog: { + provider: { + list: (input) => run(host.catalog.provider.list(input)), + get: (input) => run(host.catalog.provider.get(input)), + }, + model: { + list: (input) => run(host.catalog.model.list(input)), + default: (input) => run(host.catalog.model.default(input)), + }, transform: transform(host.catalog), reload: () => run(host.catalog.reload()), }, command: { + list: (input) => run(host.command.list(input)), transform: transform(host.command), reload: () => run(host.command.reload()), }, + event: { + subscribe: () => Stream.toAsyncIterable(host.event.subscribe()), + }, integration: { + list: (input) => run(host.integration.list(input)), + get: (input) => run(host.integration.get(input)), + connectKey: (input) => run(host.integration.connectKey(input)), + connectOauth: (input) => run(host.integration.connectOauth(input)), + attemptStatus: (input) => run(host.integration.attemptStatus(input)), + attemptComplete: (input) => run(host.integration.attemptComplete(input)), + attemptCancel: (input) => run(host.integration.attemptCancel(input)), transform: transform(host.integration), reload: () => run(host.integration.reload()), connection: { @@ -71,20 +92,25 @@ export function fromPromise(plugin: Plugin) { }, }, plugin: { - add: (input) => { - const child = fromPromise(input) - return run(host.plugin.add(child)) - }, - remove: (id) => run(host.plugin.remove(id)), + list: (input) => run(host.plugin.list(input)), }, reference: { + list: (input) => run(host.reference.list(input)), transform: transform(host.reference), reload: () => run(host.reference.reload()), }, skill: { + list: (input) => run(host.skill.list(input)), transform: transform(host.skill), reload: () => run(host.skill.reload()), }, + session: { + create: (input) => run(host.session.create(input)), + get: (input) => run(host.session.get(input)), + prompt: (input) => run(host.session.prompt(input)), + command: (input) => run(host.session.command(input)), + interrupt: (input) => run(host.session.interrupt(input)), + }, } yield* Effect.promise(() => Promise.resolve(plugin.setup(context2))) diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts index 1749b474ed..7c17bcc4c7 100644 --- a/packages/core/src/plugin/provider.ts +++ b/packages/core/src/plugin/provider.ts @@ -31,9 +31,8 @@ import { VenicePlugin } from "./provider/venice" import { XAIPlugin } from "./provider/xai" import { ZenmuxPlugin } from "./provider/zenmux" import type { PluginInternal } from "./internal" -import type { Scope } from "effect" -export const ProviderPlugins: PluginInternal.Plugin[] = [ +export const ProviderPlugins: PluginInternal.InternalPlugin[] = [ AlibabaPlugin, AmazonBedrockPlugin, AnthropicPlugin, diff --git a/packages/core/src/plugin/provider/alibaba.ts b/packages/core/src/plugin/provider/alibaba.ts index c5c4be0d0b..607e565d4a 100644 --- a/packages/core/src/plugin/provider/alibaba.ts +++ b/packages/core/src/plugin/provider/alibaba.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const AlibabaPlugin = define({ - id: "alibaba", + id: "opencode.provider.alibaba", effect: Effect.fn(function* (ctx) { yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/amazon-bedrock.ts b/packages/core/src/plugin/provider/amazon-bedrock.ts index 0995cf1c17..0bc3ced462 100644 --- a/packages/core/src/plugin/provider/amazon-bedrock.ts +++ b/packages/core/src/plugin/provider/amazon-bedrock.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" import type { LanguageModelV3 } from "@ai-sdk/provider" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { ProviderV2 } from "../../provider" type MantleSDK = { @@ -60,24 +60,22 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) { } export const AmazonBedrockPlugin = define({ - id: "amazon-bedrock", + id: "opencode.provider.amazon-bedrock", effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/amazon-bedrock") continue - evt.provider.update(item.provider.id, (provider) => { - if (provider.api.type !== "aisdk") return - if (typeof provider.request.body.endpoint !== "string") return - // The AI SDK expects a base URL, but users configure Bedrock private/VPC - // endpoints as `endpoint`; move it into the catalog endpoint URL once. - provider.api.url = provider.request.body.endpoint - delete provider.request.body.endpoint - }) - } - }), - ) + yield* ctx.catalog.transform((evt) => { + for (const item of evt.provider.list()) { + if (item.provider.api.type !== "aisdk") continue + if (item.provider.api.package !== "@ai-sdk/amazon-bedrock") continue + evt.provider.update(item.provider.id, (provider) => { + if (provider.api.type !== "aisdk") return + if (typeof provider.request.body.endpoint !== "string") return + // The AI SDK expects a base URL, but users configure Bedrock private/VPC + // endpoints as `endpoint`; move it into the catalog endpoint URL once. + provider.api.url = provider.request.body.endpoint + delete provider.request.body.endpoint + }) + } + }) yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return diff --git a/packages/core/src/plugin/provider/anthropic.ts b/packages/core/src/plugin/provider/anthropic.ts index cf883a0687..3a8a7c8659 100644 --- a/packages/core/src/plugin/provider/anthropic.ts +++ b/packages/core/src/plugin/provider/anthropic.ts @@ -1,21 +1,19 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const AnthropicPlugin = define({ - id: "anthropic", + id: "opencode.provider.anthropic", effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/anthropic") continue - evt.provider.update(item.provider.id, (provider) => { - provider.request.headers["anthropic-beta"] = - "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14" - }) - } - }), - ) + yield* ctx.catalog.transform((evt) => { + for (const item of evt.provider.list()) { + if (item.provider.api.type !== "aisdk") continue + if (item.provider.api.package !== "@ai-sdk/anthropic") continue + evt.provider.update(item.provider.id, (provider) => { + provider.request.headers["anthropic-beta"] = + "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14" + }) + } + }) yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/anthropic") return diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index 2e1f9d9b48..3269c7d2d9 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { ProviderV2 } from "../../provider" function selectLanguage(sdk: any, modelID: string, useChat: boolean) { @@ -11,23 +11,21 @@ function selectLanguage(sdk: any, modelID: string, useChat: boolean) { } export const AzurePlugin = define({ - id: "azure", + id: "opencode.provider.azure", effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/azure") continue - const configured = item.provider.request.body.resourceName - const resourceName = - typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME - if (!resourceName) continue - evt.provider.update(item.provider.id, (provider) => { - provider.request.body.resourceName = resourceName - }) - } - }), - ) + yield* ctx.catalog.transform((evt) => { + for (const item of evt.provider.list()) { + if (item.provider.api.type !== "aisdk") continue + if (item.provider.api.package !== "@ai-sdk/azure") continue + const configured = item.provider.request.body.resourceName + const resourceName = + typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME + if (!resourceName) continue + evt.provider.update(item.provider.id, (provider) => { + provider.request.body.resourceName = resourceName + }) + } + }) yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/azure") return @@ -56,22 +54,20 @@ export const AzurePlugin = define({ }) export const AzureCognitiveServicesPlugin = define({ - id: "azure-cognitive-services", + id: "opencode.provider.azure-cognitive-services", effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME - if (!resourceName) return - for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue - if (!item.provider.id.includes("azure-cognitive-services")) continue - evt.provider.update(item.provider.id, (provider) => { - provider.request.body.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai` - }) - } - }), - ) + yield* ctx.catalog.transform((evt) => { + const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME + if (!resourceName) return + for (const item of evt.provider.list()) { + if (item.provider.api.type !== "aisdk") continue + if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue + if (!item.provider.id.includes("azure-cognitive-services")) continue + evt.provider.update(item.provider.id, (provider) => { + provider.request.body.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai` + }) + } + }) yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return diff --git a/packages/core/src/plugin/provider/cerebras.ts b/packages/core/src/plugin/provider/cerebras.ts index 0fd651160f..20e724d707 100644 --- a/packages/core/src/plugin/provider/cerebras.ts +++ b/packages/core/src/plugin/provider/cerebras.ts @@ -1,20 +1,18 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const CerebrasPlugin = define({ - id: "cerebras", + id: "opencode.provider.cerebras", effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/cerebras") continue - evt.provider.update(item.provider.id, (provider) => { - provider.request.headers["X-Cerebras-3rd-Party-Integration"] = "opencode" - }) - } - }), - ) + yield* ctx.catalog.transform((evt) => { + for (const item of evt.provider.list()) { + if (item.provider.api.type !== "aisdk") continue + if (item.provider.api.package !== "@ai-sdk/cerebras") continue + evt.provider.update(item.provider.id, (provider) => { + provider.request.headers["X-Cerebras-3rd-Party-Integration"] = "opencode" + }) + } + }) yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/cerebras") return diff --git a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts index d416f6f19d..afab0c36df 100644 --- a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts +++ b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts @@ -1,10 +1,10 @@ import os from "os" import { InstallationVersion } from "../../installation/version" import { Effect, Option, Schema } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const CloudflareAIGatewayPlugin = define({ - id: "cloudflare-ai-gateway", + id: "opencode.provider.cloudflare-ai-gateway", effect: Effect.fn(function* (ctx) { yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index 1a1c533eb5..b2db49e4db 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -1,26 +1,24 @@ import os from "os" import { InstallationVersion } from "../../installation/version" import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { ProviderV2 } from "../../provider" const providerID = ProviderV2.ID.make("cloudflare-workers-ai") export const CloudflareWorkersAIPlugin = define({ - id: "cloudflare-workers-ai", + id: "opencode.provider.cloudflare-workers-ai", effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - const item = evt.provider.get(providerID) - if (!item) return - evt.provider.update(item.provider.id, (provider) => { - if (provider.api.type !== "aisdk") return - if (provider.api.url) return - const accountId = resolveAccountId(provider.request.body) - if (accountId) provider.api.url = workersEndpoint(accountId) - }) - }), - ) + yield* ctx.catalog.transform((evt) => { + const item = evt.provider.get(providerID) + if (!item) return + evt.provider.update(item.provider.id, (provider) => { + if (provider.api.type !== "aisdk") return + if (provider.api.url) return + const accountId = resolveAccountId(provider.request.body) + if (accountId) provider.api.url = workersEndpoint(accountId) + }) + }) yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.model.providerID !== providerID) return diff --git a/packages/core/src/plugin/provider/cohere.ts b/packages/core/src/plugin/provider/cohere.ts index 0ca0708577..8b0831604a 100644 --- a/packages/core/src/plugin/provider/cohere.ts +++ b/packages/core/src/plugin/provider/cohere.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const CoherePlugin = define({ - id: "cohere", + id: "opencode.provider.cohere", effect: Effect.fn(function* (ctx) { yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/deepinfra.ts b/packages/core/src/plugin/provider/deepinfra.ts index 1b23e08ba4..e8316012ad 100644 --- a/packages/core/src/plugin/provider/deepinfra.ts +++ b/packages/core/src/plugin/provider/deepinfra.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const DeepInfraPlugin = define({ - id: "deepinfra", + id: "opencode.provider.deepinfra", effect: Effect.fn(function* (ctx) { yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/dynamic.ts b/packages/core/src/plugin/provider/dynamic.ts index c84a6ed51f..2e51674ba0 100644 --- a/packages/core/src/plugin/provider/dynamic.ts +++ b/packages/core/src/plugin/provider/dynamic.ts @@ -1,10 +1,10 @@ import { Effect } from "effect" import { pathToFileURL } from "url" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { Npm } from "../../npm" export const DynamicProviderPlugin = define({ - id: "dynamic-provider", + id: "opencode.provider.dynamic", effect: Effect.fn(function* (ctx) { const npm = yield* Npm.Service yield* ctx.aisdk.sdk( diff --git a/packages/core/src/plugin/provider/gateway.ts b/packages/core/src/plugin/provider/gateway.ts index f097dcaca3..07249391a0 100644 --- a/packages/core/src/plugin/provider/gateway.ts +++ b/packages/core/src/plugin/provider/gateway.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const GatewayPlugin = define({ - id: "gateway", + id: "opencode.provider.gateway", effect: Effect.fn(function* (ctx) { yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index 682579d7a9..2a4881edc0 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" import { ModelV2 } from "../../model" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { ProviderV2 } from "../../provider" function shouldUseResponses(modelID: string) { @@ -12,19 +12,17 @@ function shouldUseResponses(modelID: string) { } export const GithubCopilotPlugin = define({ - id: "github-copilot", + id: "opencode.provider.github-copilot", effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - const item = evt.provider.get(ProviderV2.ID.githubCopilot) - if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return - evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => { - // This chat-only alias conflicts with the Copilot GPT-5 Responses route, - // so hide it only for Copilot rather than for every provider catalog. - model.enabled = false - }) - }), - ) + yield* ctx.catalog.transform((evt) => { + const item = evt.provider.get(ProviderV2.ID.githubCopilot) + if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return + evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => { + // This chat-only alias conflicts with the Copilot GPT-5 Responses route, + // so hide it only for Copilot rather than for every provider catalog. + model.enabled = false + }) + }) yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/github-copilot") return diff --git a/packages/core/src/plugin/provider/gitlab.ts b/packages/core/src/plugin/provider/gitlab.ts index 8723cdaac2..5cd4b94858 100644 --- a/packages/core/src/plugin/provider/gitlab.ts +++ b/packages/core/src/plugin/provider/gitlab.ts @@ -1,11 +1,11 @@ import os from "os" import { InstallationVersion } from "../../installation/version" import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { ProviderV2 } from "../../provider" export const GitLabPlugin = define({ - id: "gitlab", + id: "opencode.provider.gitlab", effect: Effect.fn(function* (ctx) { yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts index 4e643c9f51..630e353ef0 100644 --- a/packages/core/src/plugin/provider/google-vertex.ts +++ b/packages/core/src/plugin/provider/google-vertex.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { ProviderV2 } from "../../provider" function resolveProject(options: Record) { @@ -55,35 +55,33 @@ function authFetch(fetchWithRuntimeOptions?: unknown) { } export const GoogleVertexPlugin = define({ - id: "google-vertex", + id: "opencode.provider.google-vertex", effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if ( - item.provider.api.package !== "@ai-sdk/google-vertex" && - !( - item.provider.id === ProviderV2.ID.googleVertex && - item.provider.api.package.includes("@ai-sdk/openai-compatible") - ) + yield* ctx.catalog.transform((evt) => { + for (const item of evt.provider.list()) { + if (item.provider.api.type !== "aisdk") continue + if ( + item.provider.api.package !== "@ai-sdk/google-vertex" && + !( + item.provider.id === ProviderV2.ID.googleVertex && + item.provider.api.package.includes("@ai-sdk/openai-compatible") ) - continue - const project = resolveProject(item.provider.request.body) - const location = String(resolveLocation(item.provider.request.body)) - evt.provider.update(item.provider.id, (provider) => { - if (project) provider.request.body.project = project - provider.request.body.location = location - if (provider.api.type === "aisdk" && provider.api.url) { - provider.api.url = replaceVertexVars(provider.api.url, project, location) - } - if (provider.api.type === "aisdk" && provider.api.package.includes("@ai-sdk/openai-compatible")) { - provider.request.body.fetch = authFetch(provider.request.body.fetch) - } - }) - } - }), - ) + ) + continue + const project = resolveProject(item.provider.request.body) + const location = String(resolveLocation(item.provider.request.body)) + evt.provider.update(item.provider.id, (provider) => { + if (project) provider.request.body.project = project + provider.request.body.location = location + if (provider.api.type === "aisdk" && provider.api.url) { + provider.api.url = replaceVertexVars(provider.api.url, project, location) + } + if (provider.api.type === "aisdk" && provider.api.package.includes("@ai-sdk/openai-compatible")) { + provider.request.body.fetch = authFetch(provider.request.body.fetch) + } + }) + } + }) yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) { @@ -113,30 +111,28 @@ export const GoogleVertexPlugin = define({ }) export const GoogleVertexAnthropicPlugin = define({ - id: "google-vertex-anthropic", + id: "opencode.provider.google-vertex-anthropic", effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/google-vertex/anthropic") continue - const project = - item.provider.request.body.project ?? - process.env.GOOGLE_CLOUD_PROJECT ?? - process.env.GCP_PROJECT ?? - process.env.GCLOUD_PROJECT - const location = - item.provider.request.body.location ?? - process.env.GOOGLE_CLOUD_LOCATION ?? - process.env.VERTEX_LOCATION ?? - "global" - evt.provider.update(item.provider.id, (provider) => { - if (project) provider.request.body.project = project - provider.request.body.location = location - }) - } - }), - ) + yield* ctx.catalog.transform((evt) => { + for (const item of evt.provider.list()) { + if (item.provider.api.type !== "aisdk") continue + if (item.provider.api.package !== "@ai-sdk/google-vertex/anthropic") continue + const project = + item.provider.request.body.project ?? + process.env.GOOGLE_CLOUD_PROJECT ?? + process.env.GCP_PROJECT ?? + process.env.GCLOUD_PROJECT + const location = + item.provider.request.body.location ?? + process.env.GOOGLE_CLOUD_LOCATION ?? + process.env.VERTEX_LOCATION ?? + "global" + evt.provider.update(item.provider.id, (provider) => { + if (project) provider.request.body.project = project + provider.request.body.location = location + }) + } + }) yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/google-vertex/anthropic") return diff --git a/packages/core/src/plugin/provider/google.ts b/packages/core/src/plugin/provider/google.ts index 476af5b912..3d2013a523 100644 --- a/packages/core/src/plugin/provider/google.ts +++ b/packages/core/src/plugin/provider/google.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const GooglePlugin = define({ - id: "google", + id: "opencode.provider.google", effect: Effect.fn(function* (ctx) { yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/groq.ts b/packages/core/src/plugin/provider/groq.ts index 0bddb44309..d84ece2151 100644 --- a/packages/core/src/plugin/provider/groq.ts +++ b/packages/core/src/plugin/provider/groq.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const GroqPlugin = define({ - id: "groq", + id: "opencode.provider.groq", effect: Effect.fn(function* (ctx) { yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/kilo.ts b/packages/core/src/plugin/provider/kilo.ts index 6ee6670ee5..d901474ef2 100644 --- a/packages/core/src/plugin/provider/kilo.ts +++ b/packages/core/src/plugin/provider/kilo.ts @@ -1,21 +1,19 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const KiloPlugin = define({ - id: "kilo", + id: "opencode.provider.kilo", effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue - if (item.provider.api.url !== "https://api.kilo.ai/api/gateway") continue - evt.provider.update(item.provider.id, (provider) => { - provider.request.headers["HTTP-Referer"] = "https://opencode.ai/" - provider.request.headers["X-Title"] = "opencode" - }) - } - }), - ) + yield* ctx.catalog.transform((evt) => { + for (const item of evt.provider.list()) { + if (item.provider.api.type !== "aisdk") continue + if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue + if (item.provider.api.url !== "https://api.kilo.ai/api/gateway") continue + evt.provider.update(item.provider.id, (provider) => { + provider.request.headers["HTTP-Referer"] = "https://opencode.ai/" + provider.request.headers["X-Title"] = "opencode" + }) + } + }) }), }) diff --git a/packages/core/src/plugin/provider/llmgateway.ts b/packages/core/src/plugin/provider/llmgateway.ts index eafc5edd6c..68c319b673 100644 --- a/packages/core/src/plugin/provider/llmgateway.ts +++ b/packages/core/src/plugin/provider/llmgateway.ts @@ -1,26 +1,25 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { Integration } from "../../integration" export const LLMGatewayPlugin = define({ - id: "llmgateway", + id: "opencode.provider.llmgateway", effect: Effect.fn(function* (ctx) { const integrations = yield* Integration.Service - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - for (const item of evt.provider.list()) { - if (item.provider.disabled) continue - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue - if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue - if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue - evt.provider.update(item.provider.id, (provider) => { - provider.request.headers["HTTP-Referer"] = "https://opencode.ai/" - provider.request.headers["X-Title"] = "opencode" - provider.request.headers["X-Source"] = "opencode" - }) - } - }), - ) + const configured = new Set((yield* integrations.list()).map((integration) => integration.id)) + yield* ctx.catalog.transform((evt) => { + for (const item of evt.provider.list()) { + if (item.provider.disabled) continue + if (item.provider.api.type !== "aisdk") continue + if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue + if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue + if (!configured.has(Integration.ID.make(item.provider.id))) continue + evt.provider.update(item.provider.id, (provider) => { + provider.request.headers["HTTP-Referer"] = "https://opencode.ai/" + provider.request.headers["X-Title"] = "opencode" + provider.request.headers["X-Source"] = "opencode" + }) + } + }) }), }) diff --git a/packages/core/src/plugin/provider/mistral.ts b/packages/core/src/plugin/provider/mistral.ts index a731975659..92bc09abec 100644 --- a/packages/core/src/plugin/provider/mistral.ts +++ b/packages/core/src/plugin/provider/mistral.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const MistralPlugin = define({ - id: "mistral", + id: "opencode.provider.mistral", effect: Effect.fn(function* (ctx) { yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/nvidia.ts b/packages/core/src/plugin/provider/nvidia.ts index 449599727c..4597f3070a 100644 --- a/packages/core/src/plugin/provider/nvidia.ts +++ b/packages/core/src/plugin/provider/nvidia.ts @@ -1,22 +1,20 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const NvidiaPlugin = define({ - id: "nvidia", + id: "opencode.provider.nvidia", effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue - if (item.provider.api.url !== "https://integrate.api.nvidia.com/v1") continue - evt.provider.update(item.provider.id, (provider) => { - provider.request.headers["HTTP-Referer"] = "https://opencode.ai/" - provider.request.headers["X-Title"] = "opencode" - provider.request.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode" - }) - } - }), - ) + yield* ctx.catalog.transform((evt) => { + for (const item of evt.provider.list()) { + if (item.provider.api.type !== "aisdk") continue + if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue + if (item.provider.api.url !== "https://integrate.api.nvidia.com/v1") continue + evt.provider.update(item.provider.id, (provider) => { + provider.request.headers["HTTP-Referer"] = "https://opencode.ai/" + provider.request.headers["X-Title"] = "opencode" + provider.request.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode" + }) + } + }) }), }) diff --git a/packages/core/src/plugin/provider/openai-compatible.ts b/packages/core/src/plugin/provider/openai-compatible.ts index d602ed0ff9..3854bdcde2 100644 --- a/packages/core/src/plugin/provider/openai-compatible.ts +++ b/packages/core/src/plugin/provider/openai-compatible.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const OpenAICompatiblePlugin = define({ - id: "openai-compatible", + id: "opencode.provider.openai-compatible", effect: Effect.fn(function* (ctx) { yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 82e2319a1b..1b27511e49 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -1,8 +1,7 @@ import { createServer } from "node:http" import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" import { define } from "@opencode-ai/plugin/v2/effect/plugin" -import { Deferred, Effect, Semaphore, Stream } from "effect" -import type { Scope } from "effect" +import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect" import { Credential } from "../../credential" import { EventV2 } from "../../event" import { InstallationVersion } from "../../installation/version" @@ -32,11 +31,16 @@ type TokenResponse = { expires_in?: number } -type Claims = { - chatgpt_account_id?: string - organizations?: Array<{ id: string }> - "https://api.openai.com/auth"?: { chatgpt_account_id?: string } -} +const Claims = Schema.fromJsonString( + Schema.Struct({ + chatgpt_account_id: Schema.optional(Schema.String), + organizations: Schema.optional(Schema.Array(Schema.Struct({ id: Schema.String }))), + "https://api.openai.com/auth": Schema.optional( + Schema.Struct({ chatgpt_account_id: Schema.optional(Schema.String) }), + ), + }), +) +const decodeClaims = Schema.decodeUnknownOption(Claims) const browser = { integrationID: Integration.ID.make("openai"), @@ -154,7 +158,7 @@ const headless = { } satisfies IntegrationOAuthMethodRegistration export const OpenAIPlugin = define({ - id: "openai", + id: "opencode.provider.openai", effect: Effect.fn(function* (ctx) { const events = yield* EventV2.Service const loading = Semaphore.makeUnsafe(1) @@ -172,34 +176,32 @@ export const OpenAIPlugin = define({ draft.method.update(browser) draft.method.update(headless) }) - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/openai") continue - if (!item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) continue - evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => { - // OpenAIPlugin sends OpenAI models through Responses; this alias is a - // chat-completions-only model, so hide it only from OpenAI's catalog. - model.enabled = false - }) - } - if (!chatgpt) return - const item = evt.provider.get(ProviderV2.ID.openai) - if (!item) return - for (const model of item.models.values()) { - // ChatGPT-plan tokens only authorize codex-eligible models, and the - // subscription covers usage, so hide the rest and zero the cost. - evt.model.update(item.provider.id, model.id, (draft) => { - if (!OpenAICodex.eligible(draft.api.id)) { - draft.enabled = false - return - } - draft.cost = [] - }) - } - }), - ) + yield* ctx.catalog.transform((evt) => { + for (const item of evt.provider.list()) { + if (item.provider.api.type !== "aisdk") continue + if (item.provider.api.package !== "@ai-sdk/openai") continue + if (!item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) continue + evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => { + // OpenAIPlugin sends OpenAI models through Responses; this alias is a + // chat-completions-only model, so hide it only from OpenAI's catalog. + model.enabled = false + }) + } + if (!chatgpt) return + const item = evt.provider.get(ProviderV2.ID.openai) + if (!item) return + for (const model of item.models.values()) { + // ChatGPT-plan tokens only authorize codex-eligible models, and the + // subscription covers usage, so hide the rest and zero the cost. + evt.model.update(item.provider.id, model.id, (draft) => { + if (!OpenAICodex.eligible(draft.api.id)) { + draft.enabled = false + return + } + draft.cost = [] + }) + } + }) const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload()))) yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe( @@ -222,7 +224,7 @@ export const OpenAIPlugin = define({ }), ) }), -} satisfies PluginInternal.Plugin) +} satisfies PluginInternal.InternalPlugin) function headers(contentType: string) { return { "Content-Type": contentType, "User-Agent": `opencode/${InstallationVersion}` } @@ -315,14 +317,11 @@ function extractAccountID(tokens: TokenResponse) { function claim(token: string) { const part = token.split(".")[1] if (!part) return - try { - const claims = JSON.parse(Buffer.from(part, "base64url").toString()) as Claims - return ( - claims.chatgpt_account_id ?? - claims["https://api.openai.com/auth"]?.chatgpt_account_id ?? - claims.organizations?.[0]?.id - ) - } catch { - return - } + const claims = Option.getOrUndefined(decodeClaims(Buffer.from(part, "base64url").toString())) + if (!claims) return + return ( + claims.chatgpt_account_id ?? + claims["https://api.openai.com/auth"]?.chatgpt_account_id ?? + claims.organizations?.[0]?.id + ) } diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index bfa84ecd78..1316c3e4d0 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -75,7 +75,7 @@ function oauth(http: HttpClient.HttpClient) { } export const OpencodePlugin = define({ - id: "opencode", + id: "opencode.provider.opencode", effect: Effect.fn(function* (ctx) { const events = yield* EventV2.Service const http = yield* HttpClient.HttpClient diff --git a/packages/core/src/plugin/provider/openrouter.ts b/packages/core/src/plugin/provider/openrouter.ts index 0f295fb095..a255844fdb 100644 --- a/packages/core/src/plugin/provider/openrouter.ts +++ b/packages/core/src/plugin/provider/openrouter.ts @@ -1,30 +1,28 @@ import { Effect } from "effect" import { ModelV2 } from "../../model" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const OpenRouterPlugin = define({ - id: "openrouter", + id: "opencode.provider.openrouter", effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@openrouter/ai-sdk-provider") continue - evt.provider.update(item.provider.id, (provider) => { - provider.request.headers["HTTP-Referer"] = "https://opencode.ai/" - provider.request.headers["X-Title"] = "opencode" + yield* ctx.catalog.transform((evt) => { + for (const item of evt.provider.list()) { + if (item.provider.api.type !== "aisdk") continue + if (item.provider.api.package !== "@openrouter/ai-sdk-provider") continue + evt.provider.update(item.provider.id, (provider) => { + provider.request.headers["HTTP-Referer"] = "https://opencode.ai/" + provider.request.headers["X-Title"] = "opencode" + }) + for (const modelID of [ModelV2.ID.make("gpt-5-chat-latest"), ModelV2.ID.make("openai/gpt-5-chat")]) { + if (!item.models.has(modelID)) continue + evt.model.update(item.provider.id, modelID, (model) => { + // These are OpenRouter-specific OpenAI chat aliases that do not work + // on the generic path. Keep custom providers with matching IDs untouched. + model.enabled = false }) - for (const modelID of [ModelV2.ID.make("gpt-5-chat-latest"), ModelV2.ID.make("openai/gpt-5-chat")]) { - if (!item.models.has(modelID)) continue - evt.model.update(item.provider.id, modelID, (model) => { - // These are OpenRouter-specific OpenAI chat aliases that do not work - // on the generic path. Keep custom providers with matching IDs untouched. - model.enabled = false - }) - } } - }), - ) + } + }) yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@openrouter/ai-sdk-provider") return diff --git a/packages/core/src/plugin/provider/perplexity.ts b/packages/core/src/plugin/provider/perplexity.ts index 44c1ef2fc0..9eb5b1e246 100644 --- a/packages/core/src/plugin/provider/perplexity.ts +++ b/packages/core/src/plugin/provider/perplexity.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const PerplexityPlugin = define({ - id: "perplexity", + id: "opencode.provider.perplexity", effect: Effect.fn(function* (ctx) { yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/sap-ai-core.ts b/packages/core/src/plugin/provider/sap-ai-core.ts index 8c668d8b41..ede4e31d40 100644 --- a/packages/core/src/plugin/provider/sap-ai-core.ts +++ b/packages/core/src/plugin/provider/sap-ai-core.ts @@ -1,11 +1,11 @@ import { Effect } from "effect" import { pathToFileURL } from "url" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { Npm } from "../../npm" import { ProviderV2 } from "../../provider" export const SapAICorePlugin = define({ - id: "sap-ai-core", + id: "opencode.provider.sap-ai-core", effect: Effect.fn(function* (ctx) { const npm = yield* Npm.Service yield* ctx.aisdk.sdk( @@ -19,17 +19,18 @@ export const SapAICorePlugin = define({ const installedPath = evt.package.startsWith("file://") ? evt.package : (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint - if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`) + if (!installedPath) return yield* Effect.die(new Error(`Package ${evt.package} has no import entrypoint`)) - const mod = yield* Effect.promise(async () => { - return (await import( - installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href - )) as Record any> - }).pipe(Effect.orDie) + const mod: Record = yield* Effect.promise( + () => import(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href), + ) const match = Object.keys(mod).find((name) => name.startsWith("create")) - if (!match) throw new Error(`Package ${evt.package} has no provider factory export`) + if (!match) return yield* Effect.die(new Error(`Package ${evt.package} has no provider factory export`)) + const factory = mod[match] + if (typeof factory !== "function") + return yield* Effect.die(new Error(`Package ${evt.package} provider factory export is not callable`)) - evt.sdk = mod[match]( + evt.sdk = factory( serviceKey ? { deploymentId: process.env.AICORE_DEPLOYMENT_ID, resourceGroup: process.env.AICORE_RESOURCE_GROUP } : {}, diff --git a/packages/core/src/plugin/provider/snowflake-cortex.ts b/packages/core/src/plugin/provider/snowflake-cortex.ts index 788ac63eb0..2e6cc9f9b4 100644 --- a/packages/core/src/plugin/provider/snowflake-cortex.ts +++ b/packages/core/src/plugin/provider/snowflake-cortex.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { ProviderV2 } from "../../provider" type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise @@ -65,7 +65,7 @@ export function cortexFetch(upstream: FetchLike = fetch) { } export const SnowflakeCortexPlugin = define({ - id: "snowflake-cortex", + id: "opencode.provider.snowflake-cortex", effect: Effect.fn(function* (ctx) { yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/togetherai.ts b/packages/core/src/plugin/provider/togetherai.ts index 8022e0de66..d9454cfd24 100644 --- a/packages/core/src/plugin/provider/togetherai.ts +++ b/packages/core/src/plugin/provider/togetherai.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const TogetherAIPlugin = define({ - id: "togetherai", + id: "opencode.provider.togetherai", effect: Effect.fn(function* (ctx) { yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/venice.ts b/packages/core/src/plugin/provider/venice.ts index 1a602ffd50..c9d5b163ae 100644 --- a/packages/core/src/plugin/provider/venice.ts +++ b/packages/core/src/plugin/provider/venice.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const VenicePlugin = define({ - id: "venice", + id: "opencode.provider.venice", effect: Effect.fn(function* (ctx) { yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/vercel.ts b/packages/core/src/plugin/provider/vercel.ts index 00f5601430..0df949b1be 100644 --- a/packages/core/src/plugin/provider/vercel.ts +++ b/packages/core/src/plugin/provider/vercel.ts @@ -1,21 +1,19 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const VercelPlugin = define({ - id: "vercel", + id: "opencode.provider.vercel", effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/vercel") continue - evt.provider.update(item.provider.id, (provider) => { - provider.request.headers["http-referer"] = "https://opencode.ai/" - provider.request.headers["x-title"] = "opencode" - }) - } - }), - ) + yield* ctx.catalog.transform((evt) => { + for (const item of evt.provider.list()) { + if (item.provider.api.type !== "aisdk") continue + if (item.provider.api.package !== "@ai-sdk/vercel") continue + evt.provider.update(item.provider.id, (provider) => { + provider.request.headers["http-referer"] = "https://opencode.ai/" + provider.request.headers["x-title"] = "opencode" + }) + } + }) yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/vercel") return diff --git a/packages/core/src/plugin/provider/xai.ts b/packages/core/src/plugin/provider/xai.ts index 8145a3480a..76ec1df1a7 100644 --- a/packages/core/src/plugin/provider/xai.ts +++ b/packages/core/src/plugin/provider/xai.ts @@ -1,9 +1,9 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { ProviderV2 } from "../../provider" export const XAIPlugin = define({ - id: "xai", + id: "opencode.provider.xai", effect: Effect.fn(function* (ctx) { yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/zenmux.ts b/packages/core/src/plugin/provider/zenmux.ts index 29adebc0ee..099a3affca 100644 --- a/packages/core/src/plugin/provider/zenmux.ts +++ b/packages/core/src/plugin/provider/zenmux.ts @@ -1,21 +1,19 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const ZenmuxPlugin = define({ - id: "zenmux", + id: "opencode.provider.zenmux", effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue - if (item.provider.api.url !== "https://zenmux.ai/api/v1") continue - evt.provider.update(item.provider.id, (provider) => { - provider.request.headers["HTTP-Referer"] ??= "https://opencode.ai/" - provider.request.headers["X-Title"] ??= "opencode" - }) - } - }), - ) + yield* ctx.catalog.transform((evt) => { + for (const item of evt.provider.list()) { + if (item.provider.api.type !== "aisdk") continue + if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue + if (item.provider.api.url !== "https://zenmux.ai/api/v1") continue + evt.provider.update(item.provider.id, (provider) => { + provider.request.headers["HTTP-Referer"] ??= "https://opencode.ai/" + provider.request.headers["X-Title"] ??= "opencode" + }) + } + }) }), }) diff --git a/packages/core/src/plugin/runtime.ts b/packages/core/src/plugin/runtime.ts index cb5b40fd17..e79b498bd4 100644 --- a/packages/core/src/plugin/runtime.ts +++ b/packages/core/src/plugin/runtime.ts @@ -31,7 +31,7 @@ export interface Cell { export const makeCell = (): Cell => ({}) -const unavailable = () => Effect.die("Plugin runtime is unavailable") as Effect.Effect +const unavailable = () => Effect.die(new Error("Plugin runtime is unavailable")) as Effect.Effect const require = (cell: Cell, f: (runtime: Interface) => Effect.Effect) => Effect.suspend(() => { const runtime = cell.runtime diff --git a/packages/core/src/plugin/sdk.ts b/packages/core/src/plugin/sdk.ts index 78173a50a9..221fb5fae9 100644 --- a/packages/core/src/plugin/sdk.ts +++ b/packages/core/src/plugin/sdk.ts @@ -14,9 +14,9 @@ const defaultStore = makeStore() /** * Holds the plugins an embedder (the `@opencode-ai/sdk-next` host) contributes, - * so `PluginInternal` can add them on every Location boot through the ordinary - * `ctx.plugin.add` seam — the same path `ConfigExternalPlugin` uses for plugins - * discovered from config. A plugin registered after a Location has booted only + * so `PluginSupervisor` can add them on every Location boot through the ordinary + * generation path that `PluginSupervisor` uses for plugins discovered from + * config. A plugin registered after a Location has booted only * applies to Locations booted afterward, matching config-plugin timing; * embedders register at startup before creating Sessions. * diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index c9eca99118..f6ca2ef4af 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -2,7 +2,7 @@ export * as SkillPlugin from "./skill" -import { define } from "./internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { Effect } from "effect" import { AbsolutePath } from "../schema" import { SkillV2 } from "../skill" @@ -25,7 +25,7 @@ const REPORT_DESCRIPTION = "Use when the user wants to report an opencode issue or bug. Collect standard diagnostics, add user-specific reproduction context, and publish the issue with GitHub CLI." export const Plugin = define({ - id: "skill", + id: "opencode.skill", effect: Effect.fn(function* (ctx) { const reportContent = yield* reportContentWithDiagnostics() yield* ctx.skill.transform((draft) => { diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts new file mode 100644 index 0000000000..4d648a7d6f --- /dev/null +++ b/packages/core/src/plugin/supervisor.ts @@ -0,0 +1,293 @@ +export * as PluginSupervisor from "./supervisor" + +import type { Plugin } from "@opencode-ai/plugin/v2/effect" +import { Event } from "@opencode-ai/schema/config" +import { Context, Effect, Fiber, Layer, Option, Schema, Semaphore, Stream } from "effect" +import path from "path" +import { fileURLToPath, pathToFileURL } from "url" +import { Config } from "../config" +import { ConfigPlugin } from "../config/plugin" +import { EventV2 } from "../event" +import { FSUtil } from "../fs-util" +import { Location } from "../location" +import { Npm } from "../npm" +import { PluginV2 } from "../plugin" +import { PluginPromise } from "../plugin/promise" +import { PluginInternal } from "./internal" +import { SdkPlugins } from "./sdk" + +const PluginModule = Schema.Struct({ + default: Schema.Union([ + Schema.Struct({ + id: Schema.String, + effect: Schema.declare((input): input is Plugin["effect"] => typeof input === "function"), + }), + Schema.Struct({ + id: Schema.String, + setup: Schema.declare[0]["setup"]>( + (input): input is Parameters[0]["setup"] => typeof input === "function", + ), + }), + ]), +}) + +const PluginPackage = Schema.Struct({ + exports: Schema.optional(Schema.Unknown), + main: Schema.optional(Schema.String), + module: Schema.optional(Schema.String), +}) + +type Operation = + | { + readonly type: "add" + readonly target: string + readonly options: Record + readonly mtime?: number + } + | { + readonly type: "remove" + readonly target: string + } + +type Candidate = + | { + readonly type: "definition" + readonly definition: Plugin + } + | { + readonly type: "package" + readonly specifier: string + readonly options: Record + readonly mtime?: number + } + +type ConfiguredPackage = { + readonly operation: Extract + enabled: boolean +} + +function parse(input: ConfigPlugin.Plugin): Operation { + if (typeof input !== "string") { + return { type: "add", target: input.package, options: input.options ?? {} } + } + if (!input.startsWith("-")) return { type: "add", target: input, options: {} } + if (input.length === 1) throw new Error("Plugin remove operation requires a target") + return { type: "remove", target: input.slice(1) } +} + +const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Config.Entry[]) { + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const discovered = yield* Effect.forEach( + entries.filter((entry): entry is Config.Directory => entry.type === "directory"), + (entry) => discoverDirectory(fs, entry.path), + ).pipe(Effect.map((items) => items.flat())) + const configured = entries + .filter((entry): entry is Config.Document => entry.type === "document") + .flatMap((entry) => + (entry.info.plugins ?? []).map(parse).map((operation) => { + const directory = entry.path ? path.dirname(entry.path) : location.directory + const target = operation.target.startsWith("file://") + ? fileURLToPath(operation.target) + : operation.target.startsWith("./") || operation.target.startsWith("../") + ? path.resolve(directory, operation.target) + : operation.target + return operation.type === "add" ? { ...operation, target } : { type: "remove" as const, target } + }), + ) + // Explicit config is applied last so it can remove auto-discovered packages. + return yield* Effect.forEach([...discovered, ...configured], (operation) => { + if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation) + return fs.stat(operation.target).pipe( + Effect.map((info) => ({ + ...operation, + mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(), + })), + Effect.catch(() => Effect.succeed(operation)), + ) + }) +}) + +const resolve = Effect.fn("PluginSupervisor.resolve")(function* ( + pre: readonly Plugin[], + post: readonly Plugin[], + operations: readonly Operation[], +) { + const plan = apply(pre, post, operations) + return yield* load(plan) +}) + +function apply(pre: readonly Plugin[], post: readonly Plugin[], operations: readonly Operation[]) { + const matches = (selector: string, target: string) => + selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target) + const plugins = [...pre, ...post] + const enabled = new Set(plugins.map((plugin) => plugin.id)) + const packages = new Map() + + for (const operation of operations) { + if (operation.type === "remove") { + plugins.filter((plugin) => matches(operation.target, plugin.id)).forEach((plugin) => enabled.delete(plugin.id)) + packages.forEach((item, target) => { + if (matches(operation.target, target)) item.enabled = false + }) + continue + } + + const matched = plugins.filter((plugin) => matches(operation.target, plugin.id)) + const selectsDefinitions = + matched.length > 0 || + operation.target === "*" || + operation.target.endsWith(".*") || + operation.target.startsWith("opencode.") + if (selectsDefinitions) { + matched.forEach((plugin) => enabled.add(plugin.id)) + packages.forEach((item, target) => { + if (matches(operation.target, target)) item.enabled = true + }) + continue + } + + packages.set(operation.target, { operation, enabled: true }) + } + + const definitions: Candidate[] = pre.flatMap((definition) => + enabled.has(definition.id) ? [{ type: "definition", definition }] : [], + ) + const configured: Candidate[] = Array.from(packages.values()).flatMap((item) => + item.enabled + ? [ + { + type: "package", + specifier: item.operation.target, + options: item.operation.options, + ...(item.operation.mtime === undefined ? {} : { mtime: item.operation.mtime }), + }, + ] + : [], + ) + const posts: Candidate[] = post.flatMap((definition) => + enabled.has(definition.id) ? [{ type: "definition", definition }] : [], + ) + return [...definitions, ...configured, ...posts] +} + +const load = Effect.fn("PluginSupervisor.load")(function* (plan: readonly Candidate[]) { + return yield* Effect.forEach(plan, (candidate) => { + if (candidate.type === "definition") return Effect.succeed({ plugin: candidate.definition }) + return Effect.gen(function* () { + const npm = yield* Npm.Service + const entrypoint = path.isAbsolute(candidate.specifier) + ? pathToFileURL(candidate.specifier).href + : (yield* npm.add(candidate.specifier)).entrypoint + if (!entrypoint) return + // Bun currently ignores query parameters when caching file:// imports. + const source = + candidate.mtime === undefined + ? entrypoint + : `${candidate.specifier.replaceAll("\\", "/")}?mtime=${candidate.mtime}` + yield* Effect.log({ msg: "loading plugin", id: candidate.specifier, entrypoint: source }) + const mod = yield* Effect.promise(() => import(source)) + const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default + const plugin = "effect" in value ? value : PluginPromise.fromPromise(value) + return { + plugin: { + id: plugin.id, + effect: (host) => plugin.effect({ ...host, options: candidate.options }), + } satisfies Plugin, + ...(candidate.mtime === undefined ? {} : { version: String(candidate.mtime) }), + } + }).pipe(Effect.catchCause(() => Effect.succeed(undefined))) + }).pipe(Effect.map((plugins) => plugins.filter((plugin) => plugin !== undefined))) +}) + +function discoverDirectory(fs: FSUtil.Interface, directory: string) { + return Effect.gen(function* () { + const files = yield* fs + .glob("{plugin,plugins}/*.{ts,js}", { + cwd: directory, + absolute: true, + include: "file", + dot: true, + symlink: true, + }) + .pipe(Effect.orElseSucceed(() => [])) + const directories = yield* fs + .glob("{plugin,plugins}/*", { + cwd: directory, + absolute: true, + include: "all", + dot: true, + symlink: true, + }) + .pipe( + Effect.flatMap((items) => Effect.filter(items, (item) => fs.isDir(item), { concurrency: "unbounded" })), + Effect.orElseSucceed(() => []), + ) + const packages = yield* Effect.forEach(directories.sort(), (directory) => resolvePackageEntrypoint(fs, directory), { + concurrency: "unbounded", + }).pipe(Effect.map((items) => items.filter((item): item is string => item !== undefined))) + return [...files.sort(), ...packages].map((target): Operation => ({ type: "add", target, options: {} })) + }) +} + +const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string) { + const pkg = yield* fs.readJson(path.join(directory, "package.json")).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(PluginPackage)), + Effect.catch(() => Effect.succeed(undefined)), + ) + const exported = typeof pkg?.exports === "string" ? pkg.exports : undefined + const entries = [exported, pkg?.module, pkg?.main, "index.ts", "index.js"] + + return yield* Effect.forEach(entries, (entry) => { + if (!entry) return Effect.succeed(undefined) + const file = path.resolve(directory, entry) + return fs.isFile(file).pipe(Effect.map((exists) => (exists ? file : undefined))) + }).pipe(Effect.map((items) => items.find((item): item is string => item !== undefined))) +}) + +export interface Interface { + readonly ready: Effect.Effect +} + +export class Service extends Context.Service()("@opencode/PluginSupervisor") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const registry = yield* PluginV2.Service + const sdk = yield* SdkPlugins.Service + const config = yield* Config.Service + const events = yield* EventV2.Service + const lock = Semaphore.makeUnsafe(1) + const reload = Effect.fn("PluginSupervisor.reload")(() => + lock.withPermit( + Effect.gen(function* () { + // Resolve OpenCode's internal plugins with their privileged Location services. + const internal = yield* PluginInternal.list() + // Combine internal plugins with host-contributed SDK plugins in boot order. + const pre = [...internal.pre, ...sdk.all()] + // Read the current layered config before resolving plugin directives and packages. + const entries = yield* config.entries() + const operations = yield* scan(entries) + // Apply config operations and load enabled package plugins into one ordered generation. + const plugins = yield* resolve(pre, internal.post, operations) + // Replace the active generation in one scoped, batched activation. + yield* registry.activate(plugins) + }), + ), + ) + yield* events.subscribe(Event.Updated).pipe( + Stream.runForEach(() => + reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))), + ), + Effect.forkScoped({ startImmediately: true }), + ) + const fiber = yield* reload().pipe( + Effect.withSpan("PluginSupervisor.boot"), + Effect.forkScoped({ startImmediately: true }), + ) + return Service.of({ ready: Fiber.join(fiber) }) + }), +) + +export { layer } diff --git a/packages/core/src/plugin/variant.ts b/packages/core/src/plugin/variant.ts index 7c6e688399..499a2a32bf 100644 --- a/packages/core/src/plugin/variant.ts +++ b/packages/core/src/plugin/variant.ts @@ -2,10 +2,10 @@ export * as VariantPlugin from "./variant" import type { ModelV2Info } from "@opencode-ai/sdk/v2/types" import { Effect } from "effect" -import { define } from "./internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const Plugin = define({ - id: "variant", + id: "opencode.variant", effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((catalog) => { for (const record of catalog.provider.list()) { diff --git a/packages/core/src/policy.ts b/packages/core/src/policy.ts deleted file mode 100644 index 782f182b0e..0000000000 --- a/packages/core/src/policy.ts +++ /dev/null @@ -1,47 +0,0 @@ -export * as Policy from "./policy" - -import { makeLocationNode } from "./effect/app-node" -import { Context, Effect as EffectRuntime, Layer, Schema } from "effect" -import { Wildcard } from "./util/wildcard" -import { Location } from "./location" - -export const Effect = Schema.Literals(["allow", "deny"]).annotate({ identifier: "Policy.Effect" }) -export type Effect = typeof Effect.Type - -export class Info extends Schema.Class("Policy.Info")({ - action: Schema.String, - effect: Effect, - resource: Schema.String, -}) {} - -export interface Interface { - readonly load: (statements: Info[]) => EffectRuntime.Effect - readonly evaluate: (action: string, resource: string, fallback: Effect) => EffectRuntime.Effect - readonly hasStatements: () => boolean -} - -export class Service extends Context.Service()("@opencode/v2/Policy") {} - -const layer = Layer.effect( - Service, - EffectRuntime.gen(function* () { - let statements: Info[] = [] - yield* Location.Service - - return Service.of({ - load: EffectRuntime.fn("Policy.load")(function* (input) { - statements = input - }), - hasStatements: () => statements.length > 0, - evaluate: EffectRuntime.fn("Policy.evaluate")(function* (action, resource, fallback) { - return ( - statements.findLast( - (statement) => Wildcard.match(action, statement.action) && Wildcard.match(resource, statement.resource), - )?.effect ?? fallback - ) - }), - }) - }), -) - -export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] }) diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index eb47ec83b9..2a4fe89778 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -3,9 +3,11 @@ export * as Project from "./project" import { Context, Effect, Layer, Schema } from "effect" import { ChildProcess } from "effect/unstable/process" +import { asc, desc } from "drizzle-orm" import path from "path" import { AbsolutePath } from "./schema" import { ConfigVcs } from "./config/vcs" +import { Database } from "./database/database" import { FSUtil } from "./fs-util" import { Git } from "./git" import { Global } from "./global" @@ -14,6 +16,7 @@ import { makeGlobalNode } from "./effect/app-node" import { Hash } from "./util/hash" import { ProjectDirectories } from "./project/directories" import { ProjectSchema } from "./project/schema" +import { ProjectTable } from "./project/sql" export const ID = ProjectSchema.ID export type ID = ProjectSchema.ID @@ -27,9 +30,8 @@ export type Current = ProjectSchema.Current export const Directory = ProjectSchema.Directory export type Directory = ProjectSchema.Directory -export class Info extends Schema.Class("Project.Info")({ - id: ID, -}) {} +export const Info = ProjectSchema.Info +export interface Info extends Schema.Schema.Type {} export const DirectoriesInput = ProjectSchema.DirectoriesInput export type DirectoriesInput = typeof DirectoriesInput.Type @@ -56,6 +58,7 @@ export const root = Effect.fn("Project.root")(function* ( }) export interface Interface { + readonly list: () => Effect.Effect> readonly directories: (input: DirectoriesInput) => Effect.Effect readonly resolve: (input: AbsolutePath) => Effect.Effect /** @@ -72,6 +75,31 @@ export interface Interface { export class Service extends Context.Service()("@opencode/ProjectV2") {} +function fromRow(row: typeof ProjectTable.$inferSelect): Info { + const icon = + row.icon_url || row.icon_url_override || row.icon_color + ? { + url: row.icon_url ?? undefined, + override: row.icon_url_override ?? undefined, + color: row.icon_color ?? undefined, + } + : undefined + return { + id: row.id, + worktree: row.worktree, + vcs: row.vcs ?? undefined, + name: row.name ?? undefined, + icon, + commands: row.commands ?? undefined, + time: { + created: row.time_created, + updated: row.time_updated, + initialized: row.time_initialized ?? undefined, + }, + sandboxes: row.sandboxes, + } +} + const layer = Layer.effect( Service, Effect.gen(function* () { @@ -79,8 +107,19 @@ const layer = Layer.effect( const git = yield* Git.Service const global = yield* Global.Service const proc = yield* AppProcess.Service + const db = (yield* Database.Service).db const projectDirectories = yield* ProjectDirectories.Service + const list = Effect.fn("Project.list")(function* () { + const rows = yield* db + .select() + .from(ProjectTable) + .orderBy(desc(ProjectTable.time_updated), asc(ProjectTable.id)) + .all() + .pipe(Effect.orDie) + return rows.map(fromRow) + }) + const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) { return yield* projectDirectories.list(input.projectID) }) @@ -216,12 +255,12 @@ const layer = Layer.effect( yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore) }) - return Service.of({ directories, resolve, commit }) + return Service.of({ list, directories, resolve, commit }) }), ) export const node = makeGlobalNode({ service: Service, layer: layer, - deps: [FSUtil.node, Git.node, Global.node, AppProcess.node, ProjectDirectories.node], + deps: [Database.node, FSUtil.node, Git.node, Global.node, AppProcess.node, ProjectDirectories.node], }) diff --git a/packages/core/src/project/copy.ts b/packages/core/src/project/copy.ts index b42df4045c..9183e26eb9 100644 --- a/packages/core/src/project/copy.ts +++ b/packages/core/src/project/copy.ts @@ -139,7 +139,7 @@ const layer = Layer.effect( }) const canonical = Effect.fnUntraced(function* (input: AbsolutePath) { - const resolved = AbsolutePath.make(FSUtil.resolve(input)) + const resolved = AbsolutePath.make(yield* fs.resolve(input)) if (!(yield* fs.isDir(resolved))) return yield* new DirectoryUnavailableError({ directory: input }) return resolved }) @@ -202,7 +202,8 @@ const layer = Layer.effect( const copyDirectory = yield* canonical(input.directory) const stored = yield* directories.get({ projectID: input.projectID, directory: copyDirectory }) if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: copyDirectory }) - yield* (yield* getStrategy(StrategyID.make(stored.strategy))).remove({ + const strategy = yield* getStrategy(StrategyID.make(stored.strategy)) + yield* strategy.remove({ directory: copyDirectory, force: input.force, }) diff --git a/packages/core/src/project/schema.ts b/packages/core/src/project/schema.ts index 215aebc425..50283f2665 100644 --- a/packages/core/src/project/schema.ts +++ b/packages/core/src/project/schema.ts @@ -13,6 +13,9 @@ export type Current = typeof Current.Type export const Directory = Project.Directory export type Directory = typeof Directory.Type +export const Info = Project.Info +export interface Info extends Schema.Schema.Type {} + export const DirectoriesInput = Project.DirectoriesInput export type DirectoriesInput = typeof DirectoriesInput.Type diff --git a/packages/core/src/project/sql.ts b/packages/core/src/project/sql.ts index ab05fdac4a..6a7e5a9d54 100644 --- a/packages/core/src/project/sql.ts +++ b/packages/core/src/project/sql.ts @@ -1,11 +1,11 @@ import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core" -import * as DatabasePath from "../database/path" +import { absoluteArrayColumn, absoluteColumn } from "../database/path" import { Timestamps } from "../database/schema.sql" import { ProjectSchema } from "./schema" export const ProjectTable = sqliteTable("project", { id: text().$type().primaryKey(), - worktree: DatabasePath.absoluteColumn().notNull(), + worktree: absoluteColumn().notNull(), vcs: text(), name: text(), icon_url: text(), @@ -13,7 +13,7 @@ export const ProjectTable = sqliteTable("project", { icon_color: text(), ...Timestamps, time_initialized: integer(), - sandboxes: DatabasePath.absoluteArrayColumn().notNull(), + sandboxes: absoluteArrayColumn().notNull(), commands: text({ mode: "json" }).$type<{ start?: string }>(), }) @@ -24,7 +24,7 @@ export const ProjectDirectoryTable = sqliteTable( .$type() .notNull() .references(() => ProjectTable.id, { onDelete: "cascade" }), - directory: DatabasePath.absoluteColumn().notNull(), + directory: absoluteColumn().notNull(), type: text().$type<"main" | "root" | "git_worktree">(), strategy: text(), time_created: integer() diff --git a/packages/core/src/pty/pty.bun.ts b/packages/core/src/pty/pty.bun.ts index 1f8ce8e454..b92ba4409b 100644 --- a/packages/core/src/pty/pty.bun.ts +++ b/packages/core/src/pty/pty.bun.ts @@ -1,10 +1,10 @@ -import { spawn as create } from "bun-pty" +import { spawn } from "bun-pty" import type { Opts, Proc } from "./pty" export type { Disp, Exit, Opts, Proc } from "./pty" -export function spawn(file: string, args: string[], opts: Opts): Proc { - const pty = create(file, args, opts) +function spawnPty(file: string, args: string[], opts: Opts): Proc { + const pty = spawn(file, args, opts) return { pid: pty.pid, onData(listener) { @@ -24,3 +24,5 @@ export function spawn(file: string, args: string[], opts: Opts): Proc { }, } } + +export { spawnPty as spawn } diff --git a/packages/core/src/pty/pty.node.ts b/packages/core/src/pty/pty.node.ts index 76f415f4cd..cc775c8fda 100644 --- a/packages/core/src/pty/pty.node.ts +++ b/packages/core/src/pty/pty.node.ts @@ -1,3 +1,4 @@ +// ast-grep-ignore: no-star-import import * as pty from "@lydell/node-pty" import type { Opts, Proc } from "./pty" diff --git a/packages/core/src/pty/ticket.ts b/packages/core/src/pty/ticket.ts index 07838b1415..2f227aa142 100644 --- a/packages/core/src/pty/ticket.ts +++ b/packages/core/src/pty/ticket.ts @@ -32,7 +32,7 @@ function matches(record: Scope, input: Scope) { // Tickets are inserted via Cache.set and removed atomically via invalidateWhen. The lookup is // never invoked; it dies if it ever is, which would signal a misuse of the Service interface. -const noLookup = () => Effect.die("PtyTicket cache must be used via set/invalidateWhen, never get") +const noLookup = () => Effect.die(new Error("PtyTicket cache must be used via set/invalidateWhen, never get")) // Visible for tests so the TTL can be shortened. Production uses `layer` with the default TTL. export const make = (ttl: Duration.Input = DEFAULT_TTL) => diff --git a/packages/core/src/ripgrep.ts b/packages/core/src/ripgrep.ts index ac8ea52d93..7e1056c4d8 100644 --- a/packages/core/src/ripgrep.ts +++ b/packages/core/src/ripgrep.ts @@ -35,6 +35,7 @@ const RawMatch = Schema.Struct({ ), }), }) +const decodeJsonRecord = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString) type RawMatchData = (typeof RawMatch.Type)["data"] @@ -232,10 +233,7 @@ const layer = Layer.effect( parse: (line) => (Buffer.byteLength(line, "utf8") > MAX_RECORD_BYTES ? Effect.fail(failure(`Ripgrep JSON record exceeded ${MAX_RECORD_BYTES} bytes`)) - : Effect.try({ - try: () => JSON.parse(line) as unknown, - catch: (cause) => failure("Invalid ripgrep JSON output", cause), - }) + : decodeJsonRecord(line).pipe(Effect.mapError((cause) => failure("Invalid ripgrep JSON output", cause))) ).pipe( Effect.flatMap((json) => { if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match") diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 9c25bee19b..338255309f 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -43,37 +43,47 @@ export type DeepMutable = T extends string | number | boolean | bigint | symb * Nominal wrapper for scalar types. The class itself is a valid schema — * pass it directly to `Schema.decode`, `Schema.decodeEffect`, etc. * - * Overrides `~type.make` on the derived `Schema.Opaque` so `Schema.Schema.Type` - * of a field using this newtype resolves to `Self` rather than the underlying - * branded phantom. Without that override, passing a class instance to code - * typed against `Schema.Schema.Type` would require a cast even - * though the values are structurally equivalent at runtime. + * The runtime value remains an unwrapped primitive. `Schema.brand` supplies + * the primitive schema behavior and constructor validation, while the class + * supplies the nominal TypeScript identity. + * Apply checks and annotations to the underlying schema before wrapping it; + * schema rebuild operations intentionally return the underlying schema shape. * * @example - * class QuestionID extends Newtype()("QuestionID", Schema.String) { - * static make(id: string): QuestionID { - * return this.make(id) - * } - * } + * class QuestionID extends Newtype()("QuestionID", Schema.String) {} * - * Schema.decodeEffect(QuestionID)(input) + * const id = QuestionID.make("question-1") + * Schema.decodeUnknownEffect(QuestionID)(input) */ +type NewtypeSchema = (abstract new (_: never) => { + readonly _newtype: Tag +}) & + Schema.Bottom< + Self, + S["Encoded"], + S["DecodingServices"], + S["EncodingServices"], + S["ast"], + S["Rebuild"], + S["~type.make.in"], + Self, + S["~type.parameters"], + Self, + S["~type.mutability"], + S["~type.optionality"], + S["~type.constructor.default"], + S["~encoded.mutability"], + S["~encoded.optionality"] + > & + Omit + export function Newtype() { - return (tag: Tag, schema: S) => { + return (tag: Tag, schema: S): NewtypeSchema => { abstract class Base { declare readonly _newtype: Tag - - static make(value: Schema.Schema.Type): Self { - return value as unknown as Self - } } - Object.setPrototypeOf(Base, schema) - - return Base as unknown as (abstract new (_: never) => { readonly _newtype: Tag }) & { - readonly make: (value: Schema.Schema.Type) => Self - } & Omit, "make" | "~type.make"> & { - readonly "~type.make": Self - } + Object.setPrototypeOf(Base, schema.pipe(Schema.brand(tag))) + return Base as unknown as NewtypeSchema } } diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 4f1be4151c..57ae67ce34 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -41,6 +41,9 @@ import type { EventLog } from "@opencode-ai/schema/event-log" import { SkillV2 } from "./skill" import { Job } from "./job" import { CommandV2 } from "./command" +import { Shell } from "./shell" +import { Shell as ShellSchema } from "@opencode-ai/schema/shell" +import { KeyedMutex } from "./effect/keyed-mutex" export const RevertState = Revert.State export type RevertState = Revert.State @@ -106,7 +109,7 @@ export class NotFoundError extends Schema.TaggedErrorClass()("Ses export class OperationUnavailableError extends Schema.TaggedErrorClass()( "Session.OperationUnavailableError", { - operation: Schema.Literals(["move", "shell", "skill", "switchAgent", "compact"]), + operation: Schema.Literals(["move", "skill", "switchAgent", "compact"]), }, ) {} @@ -208,8 +211,7 @@ export interface Interface { id?: EventV2.ID sessionID: SessionSchema.ID command: string - resume?: boolean - }) => Effect.Effect + }) => Effect.Effect readonly skill: (input: { id?: SessionMessage.ID sessionID: SessionSchema.ID @@ -255,6 +257,8 @@ const layer = Layer.effect( const locations = yield* LocationServiceMap.Service const jobs = yield* Job.Service const scope = yield* Scope.Scope + const activeShells = new Set() + const shellLocks = KeyedMutex.makeUnsafe() const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) const isDurableSessionEvent = Schema.is(SessionEvent.Durable) const decode = (row: typeof SessionMessageTable.$inferSelect) => @@ -346,8 +350,7 @@ const layer = Layer.effect( yield* events.publish(SessionEvent.Forked, { sessionID, parentID: parent.id, - messageID: input.messageID, - timestamp: yield* DateTime.now, + from: input.messageID, }) return yield* result.get(sessionID).pipe(Effect.orDie) }), @@ -489,7 +492,10 @@ const layer = Layer.effect( ) if (!SessionInput.equivalent(admitted, expected)) return yield* new PromptConflictError({ sessionID: input.sessionID, messageID }) - if (input.resume !== false) yield* execution.wake(admitted.sessionID) + if (input.resume !== false) { + if (activeShells.has(admitted.sessionID)) return admitted + yield* execution.wake(admitted.sessionID) + } return admitted }), ), @@ -525,21 +531,69 @@ const layer = Layer.effect( resume: input.resume, }) }), - shell: Effect.fn("V2Session.shell")(function* () { - return yield* new OperationUnavailableError({ operation: "shell" }) + shell: Effect.fn("V2Session.shell")(function* (input) { + const session = yield* result.get(input.sessionID) + yield* shellLocks.withLock(input.sessionID)( + Effect.gen(function* () { + activeShells.add(input.sessionID) + if ((yield* execution.active).has(input.sessionID)) yield* execution.awaitIdle(input.sessionID) + const started = yield* Effect.gen(function* () { + const shell = yield* Shell.Service + return yield* shell.create({ command: input.command, cwd: session.location.directory }) + }) + .pipe(Effect.provide(locations.get(session.location))) + yield* events.publish( + SessionEvent.Shell.Started, + { + sessionID: input.sessionID, + shell: started, + }, + { id: input.id }, + ) + const completed = yield* Effect.gen(function* () { + const shell = yield* Shell.Service + const terminal = yield* shell.wait(started.id).pipe( + Effect.map((info) => ({ info, retained: true as const })), + Effect.catchTag("Shell.NotFoundError", () => + Effect.succeed({ info: synthesizeTerminalShellInfo(started), retained: false as const }), + ), + ) + const output = terminal.retained + ? yield* shell + .output(started.id, { limit: SHELL_MAX_CAPTURE_BYTES }) + .pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(missingShellOutput()))) + : missingShellOutput() + return { shell: terminal.info, output } + }).pipe(Effect.provide(locations.get(session.location))) + yield* events.publish(SessionEvent.Shell.Ended, { + sessionID: input.sessionID, + shell: completed.shell, + output: completed.output, + }) + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + activeShells.delete(input.sessionID) + yield* execution.wake(input.sessionID) + }), + ), + ), + ) }), skill: Effect.fn("V2Session.skill")(function* (input) { const session = yield* result.get(input.sessionID) const skills = yield* SkillV2.Service.pipe(Effect.provide(locations.get(session.location))) const skill = (yield* skills.list()).find((item) => item.name === input.skill) if (!skill) return yield* new SkillNotFoundError({ skill: input.skill }) - yield* events.publish(SessionEvent.Skill.Activated, { - sessionID: input.sessionID, - messageID: input.id ?? SessionMessage.ID.create(), - timestamp: yield* DateTime.now, - name: skill.name, - text: skill.content, - }) + yield* events.publish( + SessionEvent.Skill.Activated, + { + sessionID: input.sessionID, + name: skill.name, + text: skill.content, + }, + { id: input.id ? EventV2.ID.make(input.id.replace(/^msg_/, "evt_")) : undefined }, + ) if (input.resume !== false) yield* execution .resume(input.sessionID) @@ -547,10 +601,8 @@ const layer = Layer.effect( }), switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { yield* result.get(input.sessionID) - yield* events.publish(SessionEvent.AgentSwitched, { + yield* events.publish(SessionEvent.AgentSelected, { sessionID: input.sessionID, - messageID: SessionMessage.ID.create(), - timestamp: yield* DateTime.now, agent: input.agent, }) }), @@ -562,10 +614,8 @@ const layer = Layer.effect( (session.model.variant ?? "default") === (input.model.variant ?? "default") ) return - yield* events.publish(SessionEvent.ModelSwitched, { + yield* events.publish(SessionEvent.ModelSelected, { sessionID: input.sessionID, - messageID: SessionMessage.ID.create(), - timestamp: yield* DateTime.now, model: input.model, }) }), @@ -573,7 +623,6 @@ const layer = Layer.effect( yield* result.get(input.sessionID) yield* events.publish(SessionEvent.Renamed, { sessionID: input.sessionID, - timestamp: yield* DateTime.now, title: input.title, }) }), @@ -621,8 +670,6 @@ const layer = Layer.effect( yield* result.get(input.sessionID) yield* events.publish(SessionEvent.Synthetic, { sessionID: input.sessionID, - messageID: SessionMessage.ID.create(), - timestamp: yield* DateTime.now, text: input.text, description: input.description, metadata: input.metadata, @@ -665,6 +712,26 @@ const layer = Layer.effect( }), ) +function missingShellOutput() { + const output = "Shell command output is no longer available." + return { + output, + cursor: Buffer.byteLength(output), + size: Buffer.byteLength(output), + truncated: false, + } +} + +function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Info { + return { + ...started, + // The Shell record was removed before waiters could observe it; publish a terminal + // boundary instead of leaving the Session shell message permanently running. + status: "killed", + time: { ...started.time, completed: Date.now() }, + } +} + const resolvePrompt = (input: PromptInput.Prompt) => Prompt.make({ text: input.text, @@ -679,6 +746,9 @@ const resolvePrompt = (input: PromptInput.Prompt) => }), }) +// Mirrors the shell tool's in-memory preview safety limit. +const SHELL_MAX_CAPTURE_BYTES = 1024 * 1024 + export const node = makeGlobalNode({ service: Service, layer: layer.pipe(Layer.orDie), diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 9ff953be82..9ad9ddc81a 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -2,14 +2,12 @@ export * as SessionCompaction from "./compaction" import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm" import { Context, DateTime, Effect, Layer, Stream } from "effect" -import type { Config } from "../config" -import { Config as ConfigV2 } from "../config" -import type { EventV2 } from "../event" -import { EventV2 as EventV2Service } from "../event" +import { Config } from "../config" +import { EventV2 } from "../event" import { makeLocationNode } from "../effect/app-node" import { llmClient } from "../effect/app-node-platform" import { SessionEvent } from "./event" -import { SessionMessage } from "./message" +import type { SessionMessage } from "./message" import { SessionRunnerModel } from "./runner/model" import { SessionSchema } from "./schema" import { Token } from "../util/token" @@ -131,7 +129,7 @@ const serialize = (message: SessionMessage.Message) => { if (message.type === "system") return `[System update]: ${message.text}` if (message.type === "synthetic") return `[Synthetic context]: ${message.text}` if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}` - if (message.type === "shell") return `[Shell]: ${message.command}\n${truncate(message.output)}` + if (message.type === "shell") return `[Shell]: ${message.shell.command}\n${truncate(message.output?.output ?? "")}` return "" } @@ -208,11 +206,8 @@ const make = (dependencies: Dependencies) => { const summaryPrompt = buildPrompt({ previousSummary: input.previousSummary, context: input.context }) const summaryOutput = Math.min(output || SUMMARY_OUTPUT_TOKENS, SUMMARY_OUTPUT_TOKENS) if (Token.estimate(summaryPrompt) > context - summaryOutput) return false - const messageID = SessionMessage.ID.create() yield* dependencies.events.publish(SessionEvent.Compaction.Started, { sessionID: input.sessionID, - messageID, - timestamp: yield* DateTime.now, reason: input.reason, }) @@ -240,8 +235,6 @@ const make = (dependencies: Dependencies) => { if (!summarized || failed || !summary.trim()) return false yield* dependencies.events.publish(SessionEvent.Compaction.Ended, { sessionID: input.sessionID, - messageID, - timestamp: yield* DateTime.now, reason: input.reason, text: summary, recent: input.recent, @@ -311,9 +304,9 @@ const make = (dependencies: Dependencies) => { export const layer = Layer.effect( Service, Effect.gen(function* () { - const events = yield* EventV2Service.Service + const events = yield* EventV2.Service const llm = yield* LLMClient.Service - const config = yield* ConfigV2.Service + const config = yield* Config.Service const models = yield* SessionRunnerModel.Service const compaction = make({ events, llm, config: yield* config.entries() }) @@ -336,5 +329,5 @@ export const layer = Layer.effect( export const node = makeLocationNode({ service: Service, layer, - deps: [EventV2Service.node, llmClient, ConfigV2.node, SessionRunnerModel.node], + deps: [EventV2.node, llmClient, Config.node, SessionRunnerModel.node], }) diff --git a/packages/core/src/session/context-checkpoint.ts b/packages/core/src/session/context-checkpoint.ts index c2036e726a..977514e8ca 100644 --- a/packages/core/src/session/context-checkpoint.ts +++ b/packages/core/src/session/context-checkpoint.ts @@ -1,13 +1,12 @@ export * as SessionContextCheckpoint from "./context-checkpoint" import { eq } from "drizzle-orm" -import { DateTime, Effect, Option, Schema } from "effect" +import { Effect, Option, Schema } from "effect" import type { Database } from "../database/database" import { EventV2 } from "../event" import { SystemContext } from "../system-context/index" import { SessionEvent } from "./event" import { SessionHistory } from "./history" -import { SessionMessage } from "./message" import { SessionSchema } from "./schema" import { SessionContextCheckpointTable } from "./sql" @@ -19,7 +18,7 @@ const decodeApplied = Schema.decodeUnknownOption(SystemContext.Applied) * Loads or creates the session's durable context checkpoint, narrating any * drift since the model was last told as a chronological update. Completed * compaction rebaselines; nothing else rewrites the baseline. Runs before - * input promotion so a blocked first turn leaves pending inputs untouched. + * input promotion so a blocked first step leaves pending inputs untouched. */ export const prepare = Effect.fn("SessionContextCheckpoint.prepare")(function* ( db: DatabaseService, @@ -50,7 +49,7 @@ export const prepare = Effect.fn("SessionContextCheckpoint.prepare")(function* ( yield* events.publish( SessionEvent.ContextUpdated, - { sessionID, messageID: SessionMessage.ID.create(), timestamp: yield* DateTime.now, text: result.text }, + { sessionID, text: result.text }, { commit: () => advance(db, sessionID, result.applied).pipe(Effect.orDie) }, ) return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } @@ -112,7 +111,7 @@ const rewrite = Effect.fnUntraced(function* ( .returning({ sessionID: SessionContextCheckpointTable.session_id }) .get() .pipe(Effect.orDie) - if (!updated) return yield* Effect.die("Context checkpoint not found") + if (!updated) return yield* Effect.die(new Error("Context checkpoint not found")) }) const advance = Effect.fnUntraced(function* ( @@ -127,5 +126,5 @@ const advance = Effect.fnUntraced(function* ( .returning({ sessionID: SessionContextCheckpointTable.session_id }) .get() .pipe(Effect.orDie) - if (!updated) return yield* Effect.die("Context checkpoint not found") + if (!updated) return yield* Effect.die(new Error("Context checkpoint not found")) }) diff --git a/packages/core/src/session/execution/local.ts b/packages/core/src/session/execution/local.ts index 8609162c82..4e68e6c3d9 100644 --- a/packages/core/src/session/execution/local.ts +++ b/packages/core/src/session/execution/local.ts @@ -19,8 +19,8 @@ const layer = Layer.effect( const coordinator = yield* SessionRunCoordinator.make({ drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) { const session = yield* store.get(sessionID) - if (!session) return yield* Effect.die(`Session not found: ${sessionID}`) - return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force })).pipe( + if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`)) + return yield* SessionRunner.Service.use((runner) => runner.drain({ sessionID, force })).pipe( Effect.provide(locations.get(session.location)), Effect.tapCause((cause) => Cause.hasInterruptsOnly(cause) @@ -36,7 +36,6 @@ const layer = Layer.effect( Exit.isFailure(exit) && !Cause.hasInterrupts(exit.cause) ? Cause.squash(exit.cause) : undefined yield* events.publish(SessionEvent.ExecutionSettled, { sessionID, - timestamp: yield* DateTime.now, outcome: Exit.isSuccess(exit) ? "success" : Cause.hasInterrupts(exit.cause) ? "interrupted" : "failure", error: failure !== undefined diff --git a/packages/core/src/session/history.ts b/packages/core/src/session/history.ts index a704a631f7..9a6a75fe2f 100644 --- a/packages/core/src/session/history.ts +++ b/packages/core/src/session/history.ts @@ -34,7 +34,7 @@ const messageRows = Effect.fnUntraced(function* ( and( eq(SessionMessageTable.session_id, sessionID), // Keep system updates visible in the gap between a completed compaction - // and the next prepared turn's rebaseline, when their content is not yet + // and the next prepared step's rebaseline, when their content is not yet // folded into a new baseline. compaction ? or( diff --git a/packages/core/src/session/input.ts b/packages/core/src/session/input.ts index 2c6aeda460..1d0dfc71c3 100644 --- a/packages/core/src/session/input.ts +++ b/packages/core/src/session/input.ts @@ -50,19 +50,17 @@ export const admit = Effect.fn("SessionInput.admit")(function* ( ) { const existing = yield* find(db, input.id) if (existing !== undefined) return existing - const timestamp = yield* DateTime.now return yield* events .publish(SessionEvent.PromptAdmitted, { - messageID: input.id, + inputID: input.id, sessionID: input.sessionID, - timestamp, prompt: input.prompt, delivery: input.delivery, }) .pipe( Effect.flatMap((event) => event.durable === undefined - ? Effect.die("Prompt admission event is missing aggregate sequence") + ? Effect.die(new Error("Prompt admission event is missing aggregate sequence")) : Effect.succeed( Admitted.make({ admittedSeq: event.durable.seq, @@ -70,7 +68,7 @@ export const admit = Effect.fn("SessionInput.admit")(function* ( sessionID: input.sessionID, prompt: input.prompt, delivery: input.delivery, - timeCreated: timestamp, + timeCreated: event.created, }), ), ), @@ -115,14 +113,11 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio if (!stored) return yield* Effect.die(new LifecycleConflict({ id: input.id })) }) -export const projectPrompted = Effect.fn("SessionInput.projectPrompted")(function* ( +export const projectPromptPromoted = Effect.fn("SessionInput.projectPromptPromoted")(function* ( db: DatabaseService, input: { readonly id: SessionMessage.ID readonly sessionID: SessionSchema.ID - readonly prompt: Prompt - readonly delivery: Delivery - readonly timeCreated: DateTime.Utc readonly promotedSeq: number }, ) { @@ -141,15 +136,16 @@ export const projectPrompted = Effect.fn("SessionInput.projectPrompted")(functio .pipe(Effect.orDie) if (updated) { const stored = fromRow(updated) - if (!matchesProjection(stored, input)) return yield* Effect.die(new LifecycleConflict({ id: input.id })) - return + if (stored.sessionID !== input.sessionID) return yield* Effect.die(new LifecycleConflict({ id: input.id })) + return stored } - // Every Prompted event is published from an admitted inbox row, so a missing or + // Every PromptPromoted event is published from an admitted inbox row, so a missing or // divergent row on replay is an invariant violation. const stored = yield* find(db, input.id) - if (!stored || !matchesProjection(stored, input) || stored.promotedSeq !== input.promotedSeq) + if (!stored || stored.sessionID !== input.sessionID || stored.promotedSeq !== input.promotedSeq) return yield* Effect.die(new LifecycleConflict({ id: input.id })) + return stored }) export const hasPending = Effect.fn("SessionInput.hasPending")(function* ( @@ -206,12 +202,9 @@ const publish = Effect.fn("SessionInput.publish")(function* ( for (const row of rows) { const id = SessionMessage.ID.make(row.id) yield* events - .publish(SessionEvent.Prompted, { + .publish(SessionEvent.PromptPromoted, { sessionID, - timestamp: DateTime.makeUnsafe(row.time_created), - messageID: id, - prompt: decodePrompt(row.prompt), - delivery: row.delivery, + inputID: id, }) .pipe( Effect.catchDefect((defect) => diff --git a/packages/core/src/session/instructions.ts b/packages/core/src/session/instructions.ts index 3e2eb27147..22f003e5ea 100644 --- a/packages/core/src/session/instructions.ts +++ b/packages/core/src/session/instructions.ts @@ -35,10 +35,10 @@ const layer = Layer.effect( // Resolved once for the Location layer; the synthetic text and dedup ledger keep // absolute paths, but the human-facing description shows paths relative to the project // root so opening a subdirectory still describes paths from the project root. - const root = FSUtil.resolve(location.project.directory) - // Same-turn parallel reads settle concurrently, so an in-memory claim guards each + const root = yield* fs.resolve(location.project.directory) + // Same-step parallel reads settle concurrently, so an in-memory claim guards each // Session/path pair before any filesystem work. The durable history check below covers - // paths injected in earlier turns after this Location layer was reopened. + // paths injected in earlier steps after this Location layer was reopened. const injected = yield* Ref.make>>(new Map()) const load = Effect.fn("SessionInstructions.load")(function* (input: { @@ -60,9 +60,9 @@ const layer = Layer.effect( const files = yield* Effect.forEach( toInject, (path) => - fs.readFileStringSafe(path).pipe( - Effect.map((content) => (content === undefined ? undefined : { path, content })), - ), + fs + .readFileStringSafe(path) + .pipe(Effect.map((content) => (content === undefined ? undefined : { path, content }))), { concurrency: "unbounded" }, ) const readable = files.filter((file): file is { path: string; content: string } => file !== undefined) @@ -74,8 +74,6 @@ const layer = Layer.effect( // metadata so it survives across Location layer restarts. yield* events.publish(SessionEvent.Synthetic, { sessionID: input.sessionID, - messageID: SessionMessage.ID.create(), - timestamp: yield* DateTime.now, text: readable.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n"), description: `Loaded ${readable.map((file) => describePath(root, file.path)).join(", ")}`, metadata: { instruction: { paths: readable.map((file) => file.path) } }, diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 9ee8f5f739..2a8ade7b1a 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -8,23 +8,37 @@ export type MemoryState = { } export interface Adapter { - readonly getCurrentAssistant: () => Effect.Effect - readonly getAssistant: (messageID: SessionMessage.ID) => Effect.Effect - readonly getCurrentShell: (callID: string) => Effect.Effect - readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect - readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect - readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect + readonly getModel: () => Effect.Effect + readonly getCurrentAssistant: () => Effect.Effect + readonly getAssistant: ( + messageID: SessionMessage.ID, + ) => Effect.Effect + readonly getShell: ( + shellID: SessionMessage.Shell["shell"]["id"], + ) => Effect.Effect + readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect + readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect + readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect } export function memory(state: MemoryState): Adapter { const assistantIndex = (messageID: SessionMessage.ID) => state.messages.findLastIndex((message) => message.id === messageID) - // A newer turn supersedes stale incomplete rows; never resume an older assistant projection. + const shellIndex = (messageID: SessionMessage.ID) => + state.messages.findLastIndex((message) => message.id === messageID) + // A newer step supersedes stale incomplete rows; never resume an older assistant projection. const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant") - const activeShellIndex = (callID: string) => - state.messages.findLastIndex((message) => message.type === "shell" && message.callID === callID) return { + getModel() { + return Effect.sync( + () => + state.messages.findLast( + (message): message is SessionMessage.ModelSelected | SessionMessage.Assistant => + message.type === "model-switched" || message.type === "assistant", + )?.model, + ) + }, getCurrentAssistant() { return Effect.sync(() => { const index = latestAssistantIndex() @@ -41,12 +55,11 @@ export function memory(state: MemoryState): Adapter { return assistant?.type === "assistant" ? assistant : undefined }) }, - getCurrentShell(callID) { + getShell(shellID) { return Effect.sync(() => { - const index = activeShellIndex(callID) - if (index < 0) return - const shell = state.messages[index] - return shell?.type === "shell" ? shell : undefined + return state.messages.find((message): message is SessionMessage.Shell => { + return message.type === "shell" && message.shell.id === shellID + }) }) }, updateAssistant(assistant) { @@ -60,7 +73,7 @@ export function memory(state: MemoryState): Adapter { }, updateShell(shell) { return Effect.sync(() => { - const index = activeShellIndex(shell.callID) + const index = shellIndex(shell.id) if (index < 0) return const current = state.messages[index] if (current?.type !== "shell") return @@ -100,112 +113,103 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { return Effect.gen(function* () { yield* SessionEvent.All.match(event, { - "session.next.agent.switched": (event) => { + "session.agent.selected": (event) => { return adapter.appendMessage( - SessionMessage.AgentSwitched.make({ - id: event.data.messageID, + SessionMessage.AgentSelected.make({ + id: SessionMessage.ID.fromEvent(event.id), type: "agent-switched", metadata: event.metadata, agent: event.data.agent, - time: { created: event.data.timestamp }, + time: { created: event.created }, }), ) }, - "session.next.model.switched": (event) => { - return adapter.appendMessage( - SessionMessage.ModelSwitched.make({ - id: event.data.messageID, - type: "model-switched", - metadata: event.metadata, - model: event.data.model, - time: { created: event.data.timestamp }, - }), - ) + "session.model.selected": (event) => { + return Effect.gen(function* () { + const previous = yield* adapter.getModel() + yield* adapter.appendMessage( + SessionMessage.ModelSelected.make({ + id: SessionMessage.ID.fromEvent(event.id), + type: "model-switched", + metadata: event.metadata, + model: event.data.model, + previous, + time: { created: event.created }, + }), + ) + }) }, - "session.next.moved": () => Effect.void, - "session.next.renamed": () => Effect.void, - "session.next.forked": () => Effect.void, - "session.next.prompted": (event) => { - return adapter.appendMessage( - SessionMessage.User.make({ - id: event.data.messageID, - type: "user", - metadata: event.metadata, - text: event.data.prompt.text, - files: event.data.prompt.files, - agents: event.data.prompt.agents, - time: { created: event.data.timestamp }, - }), - ) - }, - "session.next.prompt.admitted": () => Effect.void, - "session.next.execution.settled": () => Effect.void, - "session.next.context.updated": (event) => + "session.moved": () => Effect.void, + "session.renamed": () => Effect.void, + "session.forked": () => Effect.void, + "session.prompt.promoted": () => Effect.void, + "session.prompt.admitted": () => Effect.void, + "session.execution.settled": () => Effect.void, + "session.context.updated": (event) => adapter.appendMessage( SessionMessage.System.make({ - id: event.data.messageID, + id: SessionMessage.ID.fromEvent(event.id), type: "system", text: event.data.text, - time: { created: event.data.timestamp }, + time: { created: event.created }, }), ), - "session.next.synthetic": (event) => { + "session.synthetic": (event) => { return adapter.appendMessage( SessionMessage.Synthetic.make({ sessionID: event.data.sessionID, text: event.data.text, description: event.data.description, metadata: event.data.metadata, - id: event.data.messageID, + id: SessionMessage.ID.fromEvent(event.id), type: "synthetic", - time: { created: event.data.timestamp }, + time: { created: event.created }, }), ) }, - "session.next.skill.activated": (event) => { + "session.skill.activated": (event) => { return adapter.appendMessage( SessionMessage.Skill.make({ - id: event.data.messageID, + id: SessionMessage.ID.fromEvent(event.id), type: "skill", name: event.data.name, text: event.data.text, - time: { created: event.data.timestamp }, + time: { created: event.created }, }), ) }, - "session.next.shell.started": (event) => { + "session.shell.started": (event) => { return adapter.appendMessage( SessionMessage.Shell.make({ - id: event.data.messageID, + id: SessionMessage.ID.fromEvent(event.id), type: "shell", metadata: event.metadata, - callID: event.data.callID, - command: event.data.command, - output: "", - time: { created: event.data.timestamp }, + shell: event.data.shell, + time: { created: event.created }, }), ) }, - "session.next.shell.ended": (event) => { + "session.shell.ended": (event) => { return Effect.gen(function* () { - const currentShell = yield* adapter.getCurrentShell(event.data.callID) + const currentShell = yield* adapter.getShell(event.data.shell.id) if (currentShell) { yield* adapter.updateShell( produce(currentShell, (draft) => { + draft.shell = castDraft(event.data.shell) draft.output = event.data.output - draft.time.completed = event.data.timestamp + draft.time.completed = event.created }), ) } }) }, - "session.next.step.started": (event) => { + "session.step.started": (event) => { return Effect.gen(function* () { const currentAssistant = yield* adapter.getCurrentAssistant() if (currentAssistant) { yield* adapter.updateAssistant( produce(currentAssistant, (draft) => { - draft.time.completed = event.data.timestamp + draft.time.completed = event.created }), ) } @@ -215,16 +219,16 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { type: "assistant", agent: event.data.agent, model: event.data.model, - time: { created: event.data.timestamp }, + time: { created: event.created }, content: [], snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, }), ) }) }, - "session.next.step.ended": (event) => { + "session.step.ended": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { - draft.time.completed = event.data.timestamp + draft.time.completed = event.created draft.finish = event.data.finish draft.cost = event.data.cost draft.tokens = event.data.tokens @@ -236,33 +240,33 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { } }) }, - "session.next.step.failed": (event) => { + "session.step.failed": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { - draft.time.completed = event.data.timestamp + draft.time.completed = event.created draft.finish = "error" draft.error = event.data.error }) }, - "session.next.text.started": (event) => { + "session.text.started": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { draft.content.push( castDraft(SessionMessage.AssistantText.make({ type: "text", id: event.data.textID, text: "" })), ) }) }, - "session.next.text.delta": (event) => { + "session.text.delta": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestText(draft, event.data.textID) if (match) match.text += event.data.delta }) }, - "session.next.text.ended": (event) => { + "session.text.ended": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestText(draft, event.data.textID) if (match) match.text = event.data.text }) }, - "session.next.tool.input.started": (event) => { + "session.tool.input.started": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { draft.content.push( castDraft( @@ -270,26 +274,26 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { type: "tool", id: event.data.callID, name: event.data.name, - time: { created: event.data.timestamp }, + time: { created: event.created }, state: SessionMessage.ToolStatePending.make({ status: "pending", input: "" }), }), ), ) }) }, - "session.next.tool.input.delta": () => Effect.void, - "session.next.tool.input.ended": (event) => { + "session.tool.input.delta": () => Effect.void, + "session.tool.input.ended": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestTool(draft, event.data.callID) if (match && match.state.status === "pending") match.state.input = event.data.text }) }, - "session.next.tool.called": (event) => { + "session.tool.called": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestTool(draft, event.data.callID) if (match) { match.provider = event.data.provider - match.time.ran = event.data.timestamp + match.time.ran = event.created match.state = castDraft( SessionMessage.ToolStateRunning.make({ status: "running", @@ -301,7 +305,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { } }) }, - "session.next.tool.progress": (event) => { + "session.tool.progress": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestTool(draft, event.data.callID) if (match && match.state.status === "running") { @@ -310,7 +314,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { } }) }, - "session.next.tool.success": (event) => { + "session.tool.success": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestTool(draft, event.data.callID) if (match && match.state.status === "running") { @@ -319,7 +323,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { metadata: match.provider?.metadata, resultMetadata: event.data.provider.metadata, } - match.time.completed = event.data.timestamp + match.time.completed = event.created match.state = castDraft( SessionMessage.ToolStateCompleted.make({ status: "completed", @@ -333,7 +337,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { } }) }, - "session.next.tool.failed": (event) => { + "session.tool.failed": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestTool(draft, event.data.callID) if (match && (match.state.status === "pending" || match.state.status === "running")) { @@ -342,7 +346,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { metadata: match.provider?.metadata, resultMetadata: event.data.provider.metadata, } - match.time.completed = event.data.timestamp + match.time.completed = event.created match.state = castDraft( SessionMessage.ToolStateError.make({ status: "error", @@ -356,7 +360,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { } }) }, - "session.next.reasoning.started": (event) => { + "session.reasoning.started": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { draft.content.push( castDraft( @@ -365,47 +369,47 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { id: event.data.reasoningID, text: "", providerMetadata: event.data.providerMetadata, - time: { created: event.data.timestamp }, + time: { created: event.created }, }), ), ) }) }, - "session.next.reasoning.delta": (event) => { + "session.reasoning.delta": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestReasoning(draft, event.data.reasoningID) if (match) match.text += event.data.delta }) }, - "session.next.reasoning.ended": (event) => { + "session.reasoning.ended": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestReasoning(draft, event.data.reasoningID) if (match) { match.text = event.data.text - match.time = { created: match.time?.created ?? event.data.timestamp, completed: event.data.timestamp } + match.time = { created: match.time?.created ?? event.created, completed: event.created } if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata } }) }, - "session.next.retried": () => Effect.void, - "session.next.compaction.started": () => Effect.void, - "session.next.compaction.delta": () => Effect.void, - "session.next.compaction.ended": (event) => { + "session.retried": () => Effect.void, + "session.compaction.started": () => Effect.void, + "session.compaction.delta": () => Effect.void, + "session.compaction.ended": (event) => { return adapter.appendMessage( SessionMessage.Compaction.make({ - id: event.data.messageID, + id: SessionMessage.ID.fromEvent(event.id), type: "compaction", metadata: event.metadata, reason: event.data.reason, summary: event.data.text, recent: event.data.recent, - time: { created: event.data.timestamp }, + time: { created: event.created }, }), ) }, - "session.next.revert.staged": () => Effect.void, - "session.next.revert.cleared": () => Effect.void, - "session.next.revert.committed": () => Effect.void, + "session.revert.staged": () => Effect.void, + "session.revert.cleared": () => Effect.void, + "session.revert.committed": () => Effect.void, }) }) } diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 8f4cc4e5df..f265aa6fc9 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -5,6 +5,7 @@ import { DateTime, Effect, Layer, Schema } from "effect" import { Database } from "../database/database" import { EventV2 } from "../event" import { makeGlobalNode } from "../effect/app-node" +import { ModelV2 } from "../model" import { SessionEvent } from "./event" import { SessionV1 } from "../v1/session" import { WorkspaceTable } from "../control-plane/workspace.sql" @@ -25,7 +26,7 @@ import type { DeepMutable } from "../schema" import { Slug } from "../util/slug" type DatabaseService = Database.Interface["db"] -type MessageEvent = Exclude +type MessageEvent = Exclude const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) const encodeMessage = Schema.encodeSync(SessionMessage.Message) @@ -157,22 +158,19 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( .where(eq(SessionTable.id, event.data.parentID)) .get() .pipe(Effect.orDie) - if (!parent) return yield* Effect.die(`Fork parent session not found: ${event.data.parentID}`) - const boundary = event.data.messageID + if (!parent) return yield* Effect.die(new Error(`Fork parent session not found: ${event.data.parentID}`)) + const boundary = event.data.from ? yield* db .select({ seq: SessionMessageTable.seq }) .from(SessionMessageTable) .where( - and( - eq(SessionMessageTable.session_id, event.data.parentID), - eq(SessionMessageTable.id, event.data.messageID), - ), + and(eq(SessionMessageTable.session_id, event.data.parentID), eq(SessionMessageTable.id, event.data.from)), ) .get() .pipe(Effect.orDie) : undefined - if (event.data.messageID && !boundary) - return yield* Effect.die(`Fork boundary message not found: ${event.data.messageID}`) + if (event.data.from && !boundary) + return yield* Effect.die(new Error(`Fork boundary message not found: ${event.data.from}`)) const copied = yield* db .select({ seq: SessionMessageTable.seq }) .from(SessionMessageTable) @@ -208,8 +206,8 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( tokens_reasoning: 0, tokens_cache_read: 0, tokens_cache_write: 0, - time_created: DateTime.toEpochMillis(event.data.timestamp), - time_updated: DateTime.toEpochMillis(event.data.timestamp), + time_created: DateTime.toEpochMillis(event.created), + time_updated: DateTime.toEpochMillis(event.created), }) .onConflictDoNothing() .returning({ sessionID: SessionTable.id }) @@ -341,7 +339,8 @@ function run(db: DatabaseService, event: MessageEvent) { const decodeRow = (row: typeof SessionMessageTable.$inferSelect) => decodeMessage({ ...row.data, id: row.id, type: row.type }) const updateMessage = (message: SessionMessage.Message) => { - if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence") + if (event.durable === undefined) + return Effect.die(new Error("Durable Session event is missing aggregate sequence")) const encoded = encodeMessage(message) const { id, type, ...data } = encoded return db @@ -358,9 +357,20 @@ function run(db: DatabaseService, event: MessageEvent) { } const appendMessage = (message: SessionMessage.Message) => insertMessage(db, event, message) const adapter: SessionMessageUpdater.Adapter = { + getModel() { + return db + .select({ model: SessionTable.model }) + .from(SessionTable) + .where(eq(SessionTable.id, event.data.sessionID)) + .get() + .pipe( + Effect.orDie, + Effect.map((row) => (row?.model ? Schema.decodeUnknownSync(ModelV2.Ref)(row.model) : undefined)), + ) + }, getCurrentAssistant() { return Effect.gen(function* () { - // A newer turn supersedes stale incomplete rows; never resume an older assistant projection. + // A newer step supersedes stale incomplete rows; never resume an older assistant projection. const row = yield* db .select() .from(SessionMessageTable) @@ -395,18 +405,25 @@ function run(db: DatabaseService, event: MessageEvent) { return message.type === "assistant" ? message : undefined }) }, - getCurrentShell(callID) { + getShell(shellID) { return Effect.gen(function* () { - const rows = yield* db + const row = yield* db .select() .from(SessionMessageTable) - .where(and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "shell"))) + .where( + and( + eq(SessionMessageTable.session_id, event.data.sessionID), + eq(SessionMessageTable.type, "shell"), + sql`json_extract(${SessionMessageTable.data}, '$.shell.id') = ${shellID}`, + ), + ) .orderBy(desc(SessionMessageTable.seq)) - .all() + .limit(1) + .get() .pipe(Effect.orDie) - return rows - .map(decodeRow) - .find((message): message is SessionMessage.Shell => message.type === "shell" && message.callID === callID) + if (!row) return + const message = decodeRow(row) + return message.type === "shell" ? message : undefined }) }, updateAssistant: updateMessage, @@ -417,8 +434,8 @@ function run(db: DatabaseService, event: MessageEvent) { }) } -function insertMessage(db: DatabaseService, event: SessionEvent.Event, message: SessionMessage.Message) { - if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence") +function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, message: SessionMessage.Message) { + if (event.durable === undefined) return Effect.die(new Error("Durable Session event is missing aggregate sequence")) const encoded = encodeMessage(message) const { id, type, ...data } = encoded return db @@ -438,7 +455,7 @@ function insertMessage(db: DatabaseService, event: SessionEvent.Event, message: const layer = Layer.effectDiscard( Effect.gen(function* () { const events = yield* EventV2.Service - const { db } = yield* Database.Service + const db = (yield* Database.Service).db yield* events.project(SessionV1.Event.Created, (event) => Effect.gen(function* () { const stored = yield* db @@ -473,9 +490,9 @@ const layer = Layer.effectDiscard( .update(SessionTable) .set({ directory: event.data.location.directory, - path: event.data.subdirectory, + path: event.data.subpath, workspace_id: event.data.location.workspaceID ? WorkspaceV2.ID.make(event.data.location.workspaceID) : null, - time_updated: DateTime.toEpochMillis(event.data.timestamp), + time_updated: DateTime.toEpochMillis(event.created), }) .where(eq(SessionTable.id, event.data.sessionID)) .run() @@ -555,58 +572,65 @@ const layer = Layer.effectDiscard( if (next) yield* applyUsage(db, sessionID, next) }), ) - yield* events.project(SessionEvent.AgentSwitched, (event) => + yield* events.project(SessionEvent.AgentSelected, (event) => db .update(SessionTable) - .set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) + .set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.created) }) .where(eq(SessionTable.id, event.data.sessionID)) .run() .pipe(Effect.orDie, Effect.andThen(run(db, event))), ) - yield* events.project(SessionEvent.ModelSwitched, (event) => + yield* events.project(SessionEvent.ModelSelected, (event) => Effect.gen(function* () { + yield* run(db, event) yield* db .update(SessionTable) - .set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) + .set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.created) }) .where(eq(SessionTable.id, event.data.sessionID)) .run() .pipe(Effect.orDie) - yield* run(db, event) }), ) yield* events.project(SessionEvent.Renamed, (event) => db .update(SessionTable) - .set({ title: event.data.title, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) + .set({ title: event.data.title, time_updated: DateTime.toEpochMillis(event.created) }) .where(eq(SessionTable.id, event.data.sessionID)) .run() .pipe(Effect.orDie), ) yield* events.project(SessionEvent.Forked, (event) => projectFork(db, event)) - yield* events.project(SessionEvent.Prompted, (event) => + yield* events.project(SessionEvent.PromptPromoted, (event) => Effect.gen(function* () { - if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence") - yield* SessionInput.projectPrompted(db, { - id: event.data.messageID, + if (event.durable === undefined) + return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence")) + const input = yield* SessionInput.projectPromptPromoted(db, { + id: event.data.inputID, sessionID: event.data.sessionID, - prompt: event.data.prompt, - delivery: event.data.delivery, - timeCreated: event.data.timestamp, promotedSeq: event.durable.seq, }) - yield* run(db, event) + yield* insertMessage(db, event, { + id: input.id, + type: "user", + metadata: event.metadata, + text: input.prompt.text, + files: input.prompt.files, + agents: input.prompt.agents, + time: { created: event.created }, + }) }), ) yield* events.project(SessionEvent.PromptAdmitted, (event) => Effect.gen(function* () { - if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence") + if (event.durable === undefined) + return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence")) yield* SessionInput.projectAdmitted(db, { admittedSeq: event.durable.seq, - id: event.data.messageID, + id: event.data.inputID, sessionID: event.data.sessionID, prompt: event.data.prompt, delivery: event.data.delivery, - timeCreated: event.data.timestamp, + timeCreated: event.created, }) }), ) @@ -614,11 +638,11 @@ const layer = Layer.effectDiscard( yield* events.project(SessionEvent.Synthetic, (event) => run(db, event)) yield* events.project(SessionEvent.Skill.Activated, (event) => insertMessage(db, event, { - id: event.data.messageID, + id: SessionMessage.ID.fromEvent(event.id), type: "skill", name: event.data.name, text: event.data.text, - time: { created: event.data.timestamp }, + time: { created: event.created }, }), ) yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event)) @@ -643,7 +667,7 @@ const layer = Layer.effectDiscard( .update(SessionTable) .set({ revert: { ...event.data.revert, files: event.data.revert.files ? [...event.data.revert.files] : undefined }, - time_updated: DateTime.toEpochMillis(event.data.timestamp), + time_updated: DateTime.toEpochMillis(event.created), }) .where(eq(SessionTable.id, event.data.sessionID)) .run() @@ -652,7 +676,7 @@ const layer = Layer.effectDiscard( yield* events.project(SessionEvent.RevertEvent.Cleared, (event) => db .update(SessionTable) - .set({ revert: null, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) + .set({ revert: null, time_updated: DateTime.toEpochMillis(event.created) }) .where(eq(SessionTable.id, event.data.sessionID)) .run() .pipe(Effect.orDie, Effect.asVoid), @@ -670,7 +694,7 @@ const layer = Layer.effectDiscard( ) .get() .pipe(Effect.orDie) - if (!boundary) return yield* Effect.die(`Revert boundary message not found: ${event.data.messageID}`) + if (!boundary) return yield* Effect.die(new Error(`Revert boundary message not found: ${event.data.messageID}`)) yield* db .delete(SessionMessageTable) .where( @@ -690,7 +714,7 @@ const layer = Layer.effectDiscard( .pipe(Effect.orDie) yield* db .update(SessionTable) - .set({ revert: null, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) + .set({ revert: null, time_updated: DateTime.toEpochMillis(event.created) }) .where(eq(SessionTable.id, event.data.sessionID)) .run() .pipe(Effect.orDie) diff --git a/packages/core/src/session/revert.ts b/packages/core/src/session/revert.ts index 9999d5da5a..8c0b8bbf7b 100644 --- a/packages/core/src/session/revert.ts +++ b/packages/core/src/session/revert.ts @@ -89,7 +89,6 @@ export const stage = Effect.fn("SessionRevert.stage")(function* (input: { } satisfies SessionSchema.Info["revert"] yield* events.publish(SessionEvent.RevertEvent.Staged, { sessionID: input.session.id, - timestamp: yield* DateTime.now, revert, }) return revert @@ -106,7 +105,6 @@ export const clear = Effect.fn("SessionRevert.clear")(function* (session: Sessio const events = yield* EventV2.Service yield* events.publish(SessionEvent.RevertEvent.Cleared, { sessionID: session.id, - timestamp: yield* DateTime.now, }) }) @@ -116,6 +114,5 @@ export const commit = Effect.fn("SessionRevert.commit")(function* (session: Sess yield* events.publish(SessionEvent.RevertEvent.Committed, { sessionID: session.id, messageID: session.revert.messageID, - timestamp: yield* DateTime.now, }) }) diff --git a/packages/core/src/session/runner/index.ts b/packages/core/src/session/runner/index.ts index 11141c3447..7c2a6f463d 100644 --- a/packages/core/src/session/runner/index.ts +++ b/packages/core/src/session/runner/index.ts @@ -9,16 +9,12 @@ import type { SystemContext } from "../../system-context/index" import type { ToolOutputStore } from "../../tool-output-store" export type RunError = - | LLMError - | SessionRunnerModel.Error - | MessageDecodeError - | SystemContext.InitializationBlocked - | ToolOutputStore.Error + LLMError | SessionRunnerModel.Error | MessageDecodeError | SystemContext.InitializationBlocked | ToolOutputStore.Error /** Runs one local continuation from already-recorded Session history. */ export interface Interface { - /** Drains eligible durable work. Explicit runs perform one provider attempt even when no work is eligible. */ - readonly run: (input: { + /** Drains eligible durable work. Explicit runs perform one physical attempt even when no work is eligible. */ + readonly drain: (input: { readonly sessionID: SessionSchema.ID readonly force: boolean }) => Effect.Effect diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 42e9674904..d4b2c5250c 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -1,3 +1,5 @@ +export * as SessionRunnerLLM from "./llm" + import { LLM, LLMClient, @@ -14,7 +16,6 @@ import { Config } from "../../config" import { Database } from "../../database/database" import { EventV2 } from "../../event" import { Location } from "../../location" -import { QuestionV2 } from "../../question" import { SystemContext } from "../../system-context/index" import { SystemContextBuiltIns } from "../../system-context/builtins" import { InstructionContext } from "../../instruction-context" @@ -22,6 +23,7 @@ import { SkillGuidance } from "../../skill/guidance" import { ReferenceGuidance } from "../../reference/guidance" import { McpGuidance } from "../../mcp/guidance" import { SessionContextEntry } from "../context-entry" +import { QuestionTool } from "../../tool/question" import { ToolRegistry } from "../../tool/registry" import { ToolOutputStore } from "../../tool-output-store" import { SessionContextCheckpoint } from "../context-checkpoint" @@ -59,11 +61,11 @@ import { llmClient } from "../../effect/app-node-platform" * - Runtime context assembly * - Track V1 runtime-context parity canonically in `specs/v2/session.md`. * - * - One provider turn + * - One step * - [x] Translate every projected V2 Session message variant into canonical * `@opencode-ai/llm` messages. * - [ ] Resolve policy-filtered built-in, MCP, plugin, and structured-output tool definitions. - * - [x] Stream exactly one `llm.stream(request)` provider turn. + * - [x] Stream exactly one `llm.stream(request)` physical attempt. * - [x] Persist assistant text and usage events incrementally as they arrive. * - [ ] Persist snapshots, patches, and retry notices incrementally as they arrive. * - [x] Persist reasoning, provider errors, and tool-call events incrementally as they arrive. @@ -75,8 +77,8 @@ import { llmClient } from "../../effect/app-node-platform" * - [x] Start each recorded local call eagerly and await all settlements before continuation. * - [ ] Add scoped runtime context, progress updates, attachment normalization, * plugins, and cancellation settlement. - * - [x] Reload projected history and start the next explicit provider turn after local tool results. - * - [x] Continue for durable user steering accepted during an active provider turn. + * - [x] Reload projected history and start the next explicit step after local tool results. + * - [x] Continue for durable user steering accepted during an active step. * - [ ] Continue for compaction or another continuation condition when required. * * - Post-run maintenance @@ -84,12 +86,12 @@ import { llmClient } from "../../effect/app-node-platform" * - [ ] Coalesce streamed deltas and add covering projected-history indexes. * - [ ] Update title, summaries, compaction state, and cleanup in bounded background work. * - * Use `llm.stream(request)` for each provider turn. Keep tool execution and continuation here. + * Use `llm.stream(request)` for each physical attempt. Keep tool execution and continuation here. * Durable continuation recovery remains a separate future slice with an explicit retry policy. * * The current slice loads V2 history, translates it, resolves a model through a core service, and persists one - * provider turn. Registry definitions are advertised, local tool calls are settled durably, and an - * explicit loop starts the next provider turn after local settlement. Configured agent step limits bound the loop. + * step. Registry definitions are advertised, local tool calls are settled durably, and an + * explicit loop starts the next step after local settlement. Configured agent step limits bound the loop. */ const layer = Layer.effect( @@ -112,14 +114,14 @@ const layer = Layer.effect( const db = (yield* Database.Service).db const compaction = yield* SessionCompaction.Service const title = yield* SessionTitle.Service - // Title generation is a side effect of the first turn; it must not delay turn continuation. + // Title generation is a side effect of the first step; it must not delay step continuation. // Tracked per process so repeated wakes before the second user message arrives don't // re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history. const titleAttempted = new Set() const forkTitle = yield* FiberSet.makeRuntime() const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) { const session = yield* store.get(sessionID) - if (!session) return yield* Effect.die(`Session not found: ${sessionID}`) + if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`)) return session }) @@ -132,7 +134,6 @@ const layer = Layer.effect( if (tool.type !== "tool" || (tool.state.status !== "pending" && tool.state.status !== "running")) continue yield* events.publish(SessionEvent.Tool.Failed, { sessionID, - timestamp: yield* DateTime.now, assistantMessageID: message.id, callID: tool.id, error: { type: "unknown", message: "Tool execution interrupted" }, @@ -148,9 +149,8 @@ const layer = Layer.effect( const awaitToolFibers = (fibers: FiberSet.FiberSet) => Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers)) - // Match V1: dismissing a question halts the loop instead of becoming model-facing tool output. - const isQuestionRejected = (cause: Cause.Cause) => - cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError) + const isQuestionCancelled = (cause: Cause.Cause) => + cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionTool.CancelledError) const loadSystemContext = (agent: AgentV2.Selection, sessionID: SessionSchema.ID) => Effect.all( @@ -165,7 +165,7 @@ const layer = Layer.effect( { concurrency: "unbounded" }, ).pipe(Effect.map(SystemContext.combine)) - const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* ( + const attemptStep = Effect.fn("SessionRunner.attemptStep")(function* ( sessionID: SessionSchema.ID, promotion: SessionInput.Delivery | undefined, step: number, @@ -176,7 +176,7 @@ const layer = Layer.effect( return yield* Effect.interrupt const agent = yield* agents.select(session.agent) // Establish what the model knows before admitting what the user said, so - // a blocked first turn leaves pending inputs untouched. + // a blocked first step leaves pending inputs untouched. const checkpoint = yield* SessionContextCheckpoint.prepare( db, events, @@ -230,7 +230,7 @@ const layer = Layer.effect( snapshot: startSnapshot, }) const publication = Semaphore.makeUnsafe(1) - // Durable publishes are serialized so tool fibers and turn settlement never interleave + // Durable publishes are serialized so tool fibers and step settlement never interleave // mid-event. const serialized = (effect: Effect.Effect) => publication.withPermit(effect) const publish = (event: LLMEvent, outputPaths: ReadonlyArray = []) => @@ -281,7 +281,7 @@ const layer = Layer.effect( Effect.ensuring(serialized(publisher.flush())), ) - // Captures the end snapshot, diffs it against the turn's start, and durably ends the + // Captures the end snapshot, diffs it against the step's start, and durably ends the // assistant step. const publishStepEnd = (settlement: NonNullable>) => Effect.gen(function* () { @@ -295,7 +295,6 @@ const layer = Layer.effect( yield* serialized( events.publish(SessionEvent.Step.Ended, { sessionID: session.id, - timestamp: yield* DateTime.now, assistantMessageID: yield* publisher.startAssistant(), finish: settlement.finish, cost: 0, @@ -316,7 +315,7 @@ const layer = Layer.effect( const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause) // A context overflow before any assistant output is recoverable: compact and - // restart the turn instead of surfacing the provider error. + // restart the step instead of surfacing the provider error. if ( recoverOverflow && !publisher.hasAssistantStarted() && @@ -325,7 +324,7 @@ const layer = Layer.effect( ) return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const - // An unrecovered held-back overflow becomes the turn's durable provider error. A + // An unrecovered held-back overflow becomes the step's durable provider error. A // thrown LLM failure fails hosted tool calls and the assistant unless a provider // error was already recorded from the stream. if (overflowFailure) yield* publish(overflowFailure) @@ -341,17 +340,16 @@ const layer = Layer.effect( if (streamInterrupted) yield* FiberSet.clear(toolFibers) const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit) const toolsInterrupted = settled._tag === "Failure" && Cause.hasInterrupts(settled.cause) - const questionDismissed = settled._tag === "Failure" && isQuestionRejected(settled.cause) + const questionCancelled = settled._tag === "Failure" && isQuestionCancelled(settled.cause) - if (questionDismissed || streamInterrupted || toolsInterrupted) { + if (questionCancelled || streamInterrupted || toolsInterrupted) { yield* FiberSet.clear(toolFibers) yield* serialized(publisher.failUnsettledTools("Tool execution interrupted")) - yield* serialized(publisher.failAssistant("Provider turn interrupted")) - // Match V1: dismissing a question halts the loop like an interruption. - if (questionDismissed) return yield* Effect.interrupt + yield* serialized(publisher.failAssistant("Step interrupted")) + if (questionCancelled) return yield* Effect.interrupt } // A settled tool fiber failure is one of two things. A defect from a tool - // implementation becomes a failed tool call the model can read, and the turn still + // implementation becomes a failed tool call the model can read, and the step still // settles so the model may recover. A typed infrastructure failure (tool output // could not be persisted) also fails the assistant and then fails the drain. const settledFailure = settled._tag === "Failure" && !toolsInterrupted ? settled.cause : undefined @@ -387,7 +385,7 @@ const layer = Layer.effect( ) }, Effect.scoped) - const runTurn = Effect.fnUntraced(function* ( + const runStep = Effect.fnUntraced(function* ( sessionID: SessionSchema.ID, promotion: SessionInput.Delivery | undefined, step: number, @@ -399,7 +397,7 @@ const layer = Layer.effect( let currentPromotion = promotion let currentStep = step while (true) { - const attempt = yield* runTurnAttempt(sessionID, currentPromotion, currentStep, recoverOverflow) + const attempt = yield* attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow) if (attempt._tag === "Completed") return { needsContinuation: attempt.needsContinuation, step: attempt.step } if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined yield* Effect.yieldNow @@ -410,7 +408,7 @@ const layer = Layer.effect( // ExecutionSettled is published per execution (busy period) by SessionExecution, not per // drain here. - const run = Effect.fn("SessionRunner.run")(function* (input: { + const drain = Effect.fn("SessionRunner.drain")(function* (input: { readonly sessionID: SessionSchema.ID readonly force: boolean }) { @@ -423,9 +421,13 @@ const layer = Layer.effect( while (shouldRun) { let needsContinuation = true let step = 1 + // Repeat steps while continuation is needed. A step needs continuation only + // when it recorded local tool calls whose results the model has not yet seen; + // a provider error suppresses it. Pending steers also continue the loop so + // interjections are answered before the session goes idle. while (needsContinuation) { - const result = yield* runTurn(input.sessionID, promotion, step) - // Steer/queue promotion inside runTurn has already made the pending input a visible + const result = yield* runStep(input.sessionID, promotion, step) + // Steer/queue promotion inside runStep has already made the pending input a visible // user message by this point, so the first-user-message check below is reliable. if (!titleAttempted.has(input.sessionID)) { titleAttempted.add(input.sessionID) @@ -441,7 +443,7 @@ const layer = Layer.effect( } }) - return Service.of({ run }) + return Service.of({ drain }) }), ) diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index b21492d18d..2b81e609ea 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -2,8 +2,11 @@ export * as SessionRunnerModel from "./model" import { makeLocationNode } from "../../effect/app-node" import { type Model } from "@opencode-ai/llm" +// ast-grep-ignore: no-star-import import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages" +// ast-grep-ignore: no-star-import import * as OpenAICompatibleChat from "@opencode-ai/llm/protocols/openai-compatible-chat" +// ast-grep-ignore: no-star-import import * as OpenAIResponses from "@opencode-ai/llm/protocols/openai-responses" import { Auth, type AnyRoute } from "@opencode-ai/llm/route" import { Context, Effect, Layer, Schema } from "effect" diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 33652a618c..9a334a0a6d 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -50,7 +50,7 @@ const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): return { structured: record(settled.structured), content: settled.content } } -/** Persist one provider turn without executing tools or starting a continuation turn. */ +/** Persist one step without executing tools or starting a continuation step. */ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) => { const tools = new Map< string, @@ -78,14 +78,13 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* events.publish(SessionEvent.Step.Started, { ...input, assistantMessageID, - timestamp: yield* timestamp, snapshot: input.snapshot, }) return assistantMessageID }) const currentAssistantMessageID = () => assistantMessageID === undefined - ? Effect.die("Tool event before assistant step start") + ? Effect.die(new Error("Tool event before assistant step start")) : Effect.succeed(assistantMessageID) const fragments = ( @@ -95,20 +94,20 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) const chunks = new Map() const start = (id: string) => Effect.suspend(() => { - if (chunks.has(id)) return Effect.die(`Duplicate ${name} start: ${id}`) + if (chunks.has(id)) return Effect.die(new Error(`Duplicate ${name} start: ${id}`)) chunks.set(id, []) return Effect.void }) const append = (id: string, value: string) => Effect.suspend(() => { const current = chunks.get(id) - if (!current) return Effect.die(`${name} delta before start: ${id}`) + if (!current) return Effect.die(new Error(`${name} delta before start: ${id}`)) current.push(value) return Effect.void }) const end = Effect.fnUntraced(function* (id: string, providerMetadata?: ProviderMetadata) { const current = chunks.get(id) - if (!current) return yield* Effect.die(`${name} end before start: ${id}`) + if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`)) yield* ended(id, current.join(""), providerMetadata) chunks.delete(id) }) @@ -123,7 +122,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* events.publish(SessionEvent.Text.Ended, { sessionID: input.sessionID, assistantMessageID: yield* currentAssistantMessageID(), - timestamp: yield* timestamp, textID, text: value, }) @@ -134,7 +132,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* events.publish(SessionEvent.Reasoning.Ended, { sessionID: input.sessionID, assistantMessageID: yield* currentAssistantMessageID(), - timestamp: yield* timestamp, reasoningID, text: value, providerMetadata, @@ -144,10 +141,9 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) const toolInput = fragments("tool input", (callID, value) => Effect.gen(function* () { const tool = tools.get(callID) - if (!tool) return yield* Effect.die(`Tool input end before start: ${callID}`) + if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${callID}`)) yield* events.publish(SessionEvent.Tool.Input.Ended, { sessionID: input.sessionID, - timestamp: yield* timestamp, assistantMessageID: tool.assistantMessageID, callID, text: value, @@ -163,7 +159,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) }) const startToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) { - if (tools.has(event.id)) return yield* Effect.die(`Duplicate tool input start: ${event.id}`) + if (tools.has(event.id)) return yield* Effect.die(new Error(`Duplicate tool input start: ${event.id}`)) const assistantMessageID = yield* startAssistant() tools.set(event.id, { assistantMessageID, @@ -176,7 +172,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* toolInput.start(event.id) yield* events.publish(SessionEvent.Tool.Input.Started, { sessionID: input.sessionID, - timestamp: yield* timestamp, assistantMessageID, callID: event.id, name: event.name, @@ -185,10 +180,10 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) const endToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) { const tool = tools.get(event.id) - if (!tool) return yield* Effect.die(`Tool input end before start: ${event.id}`) + if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${event.id}`)) if (tool.name !== event.name) - return yield* Effect.die(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`) - if (tool.inputEnded) return yield* Effect.die(`Duplicate tool input end: ${event.id}`) + return yield* Effect.die(new Error(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`)) + if (tool.inputEnded) return yield* Effect.die(new Error(`Duplicate tool input end: ${event.id}`)) yield* toolInput.end(event.id) }) @@ -204,7 +199,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) assistantFailed = true yield* events.publish(SessionEvent.Step.Failed, { sessionID: input.sessionID, - timestamp: yield* timestamp, assistantMessageID, error: { type: "unknown", message }, }) @@ -219,7 +213,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) tool.settled = true yield* events.publish(SessionEvent.Tool.Failed, { sessionID: input.sessionID, - timestamp: yield* timestamp, assistantMessageID: tool.assistantMessageID, callID, error: { type: "unknown", message }, @@ -233,7 +226,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) const assistantMessageIDForTool = (callID: string) => { const tool = tools.get(callID) - return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(`Unknown tool call: ${callID}`) + return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${callID}`)) } const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* ( @@ -248,7 +241,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* events.publish(SessionEvent.Text.Started, { sessionID: input.sessionID, assistantMessageID: yield* startAssistant(), - timestamp: yield* timestamp, textID: event.id, }) return @@ -257,7 +249,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* events.publish(SessionEvent.Text.Delta, { sessionID: input.sessionID, assistantMessageID: yield* currentAssistantMessageID(), - timestamp: yield* timestamp, textID: event.id, delta: event.text, }) @@ -270,7 +261,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* events.publish(SessionEvent.Reasoning.Started, { sessionID: input.sessionID, assistantMessageID: yield* startAssistant(), - timestamp: yield* timestamp, reasoningID: event.id, providerMetadata: event.providerMetadata, }) @@ -280,7 +270,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* events.publish(SessionEvent.Reasoning.Delta, { sessionID: input.sessionID, assistantMessageID: yield* currentAssistantMessageID(), - timestamp: yield* timestamp, reasoningID: event.id, delta: event.text, }) @@ -293,14 +282,13 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) return case "tool-input-delta": { const tool = tools.get(event.id) - if (!tool) return yield* Effect.die(`Tool input delta before start: ${event.id}`) + if (!tool) return yield* Effect.die(new Error(`Tool input delta before start: ${event.id}`)) if (tool.name !== event.name) - return yield* Effect.die(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`) - if (tool.inputEnded) return yield* Effect.die(`Tool input delta after end: ${event.id}`) + return yield* Effect.die(new Error(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`)) + if (tool.inputEnded) return yield* Effect.die(new Error(`Tool input delta after end: ${event.id}`)) yield* toolInput.append(event.id, event.text) yield* events.publish(SessionEvent.Tool.Input.Delta, { sessionID: input.sessionID, - timestamp: yield* timestamp, assistantMessageID: tool.assistantMessageID, callID: event.id, delta: event.text, @@ -315,14 +303,13 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) const tool = tools.get(event.id)! if (!tool.inputEnded) yield* endToolInput(event) if (tool.name !== event.name) - return yield* Effect.die(`Tool call name changed for ${event.id}: ${tool.name} -> ${event.name}`) - if (tool.called) return yield* Effect.die(`Duplicate tool call: ${event.id}`) + return yield* Effect.die(new Error(`Tool call name changed for ${event.id}: ${tool.name} -> ${event.name}`)) + if (tool.called) return yield* Effect.die(new Error(`Duplicate tool call: ${event.id}`)) tool.called = true tool.providerExecuted = event.providerExecuted === true tool.providerMetadata = event.providerMetadata yield* events.publish(SessionEvent.Tool.Called, { sessionID: input.sessionID, - timestamp: yield* timestamp, assistantMessageID: tool.assistantMessageID, callID: event.id, tool: event.name, @@ -336,12 +323,12 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) } case "tool-result": { const tool = tools.get(event.id) - if (!tool?.called) return yield* Effect.die(`Tool result before call: ${event.id}`) + if (!tool?.called) return yield* Effect.die(new Error(`Tool result before call: ${event.id}`)) if (tool.name !== event.name) - return yield* Effect.die(`Tool result name changed for ${event.id}: ${tool.name} -> ${event.name}`) + return yield* Effect.die(new Error(`Tool result name changed for ${event.id}: ${tool.name} -> ${event.name}`)) if (tool.settled) { if (event.result.type === "error") return - return yield* Effect.die(`Duplicate tool result: ${event.id}`) + return yield* Effect.die(new Error(`Duplicate tool result: ${event.id}`)) } tool.settled = true const result = settledOutput(event.output, event.result) @@ -352,7 +339,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) if ("error" in result) { yield* events.publish(SessionEvent.Tool.Failed, { sessionID: input.sessionID, - timestamp: yield* timestamp, assistantMessageID: tool.assistantMessageID, callID: event.id, error: result.error, @@ -363,7 +349,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) } yield* events.publish(SessionEvent.Tool.Success, { sessionID: input.sessionID, - timestamp: yield* timestamp, assistantMessageID: tool.assistantMessageID, callID: event.id, ...result, @@ -375,14 +360,13 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) } case "tool-error": { const tool = tools.get(event.id) - if (!tool?.called) return yield* Effect.die(`Tool error before call: ${event.id}`) + if (!tool?.called) return yield* Effect.die(new Error(`Tool error before call: ${event.id}`)) if (tool.name !== event.name) - return yield* Effect.die(`Tool error name changed for ${event.id}: ${tool.name} -> ${event.name}`) - if (tool.settled) return yield* Effect.die(`Duplicate tool error: ${event.id}`) + return yield* Effect.die(new Error(`Tool error name changed for ${event.id}: ${tool.name} -> ${event.name}`)) + if (tool.settled) return yield* Effect.die(new Error(`Duplicate tool error: ${event.id}`)) tool.settled = true yield* events.publish(SessionEvent.Tool.Failed, { sessionID: input.sessionID, - timestamp: yield* timestamp, assistantMessageID: tool.assistantMessageID, callID: event.id, error: { type: "unknown", message: event.message }, @@ -396,7 +380,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) case "step-finish": yield* flush() assistantActive = false - if (stepSettlement) return yield* Effect.die("Duplicate step finish") + if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish")) stepSettlement = { finish: event.reason, tokens: tokens(event.usage) } return case "finish": diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index 8ddd7cce5c..e6a91a9ef1 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -7,6 +7,7 @@ import { type Model, type ProviderMetadata, } from "@opencode-ai/llm" +import { Option, Schema } from "effect" import { SessionMessage } from "../message" import type { FileAttachment } from "../prompt" @@ -18,14 +19,12 @@ const media = (file: FileAttachment): ContentPart => ({ metadata: file.description === undefined ? undefined : { description: file.description }, }) -const toolInput = (tool: SessionMessage.AssistantTool) => { - if (tool.state.status !== "pending") return tool.state.input - try { - return JSON.parse(tool.state.input) as unknown - } catch { - return tool.state.input - } -} +const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) + +const toolInput = (tool: SessionMessage.AssistantTool) => + tool.state.status === "pending" + ? Option.getOrElse(decodeToolInput(tool.state.input), () => tool.state.input) + : tool.state.input const toolCall = (tool: SessionMessage.AssistantTool, providerMetadata: ProviderMetadata | undefined): ContentPart => ToolCallPart.make({ @@ -140,7 +139,7 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] Message.make({ id: message.id, role: "user", - content: `Shell command: ${message.command}\n\n${message.output}`, + content: `Shell command: ${message.shell.command}\n\n${message.output?.output ?? ""}`, metadata: message.metadata, }), ] diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index d1e923b40f..739d3f1888 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -1,5 +1,5 @@ import { sqliteTable, text, integer, index, primaryKey, real, uniqueIndex } from "drizzle-orm/sqlite-core" -import * as DatabasePath from "../database/path" +import { directoryColumn, pathColumn } from "../database/path" import { ProjectTable } from "../project/sql" import type { SessionMessage } from "./message" import type { Prompt } from "./prompt" @@ -31,8 +31,8 @@ export const SessionTable = sqliteTable( workspace_id: text().$type(), parent_id: text().$type(), slug: text().notNull(), - directory: DatabasePath.directoryColumn().notNull(), - path: DatabasePath.pathColumn(), + directory: directoryColumn().notNull(), + path: pathColumn(), title: text().notNull(), version: text().notNull(), share_url: text(), diff --git a/packages/core/src/session/title.ts b/packages/core/src/session/title.ts index abed5ce33c..8e7b5857b7 100644 --- a/packages/core/src/session/title.ts +++ b/packages/core/src/session/title.ts @@ -42,9 +42,10 @@ const make = (dependencies: Dependencies) => { if (!firstUser) return const agent = yield* dependencies.agents.get(AgentV2.ID.make("title")) if (!agent) return - const resolved = yield* (agent.model - ? dependencies.models.resolve({ ...session, model: agent.model }) - : dependencies.models.resolve(session) + const resolved = yield* ( + agent.model + ? dependencies.models.resolve({ ...session, model: agent.model }) + : dependencies.models.resolve(session) ).pipe(Effect.catch(() => Effect.succeed(undefined))) if (!resolved) return const chunks: string[] = [] @@ -76,7 +77,6 @@ const make = (dependencies: Dependencies) => { if (!title) return yield* dependencies.events.publish(SessionEvent.Renamed, { sessionID: session.id, - timestamp: yield* DateTime.now, title: truncate(title), }) }) diff --git a/packages/core/src/shell/select.ts b/packages/core/src/shell/select.ts index 110697421d..311fb7c9a1 100644 --- a/packages/core/src/shell/select.ts +++ b/packages/core/src/shell/select.ts @@ -4,7 +4,7 @@ import path from "path" import { spawn, type ChildProcess } from "child_process" import { readFile } from "fs/promises" import { statSync } from "fs" -import { setTimeout as sleep } from "node:timers/promises" +import { setTimeout } from "node:timers/promises" import { Flag } from "../flag/flag" import { FSUtil } from "../fs-util" import { which } from "../util/which" @@ -46,13 +46,13 @@ export async function killTree(proc: ChildProcess, opts?: { exited?: () => boole try { process.kill(-pid, "SIGTERM") - await sleep(SIGKILL_TIMEOUT_MS) + await setTimeout(SIGKILL_TIMEOUT_MS) if (!opts?.exited?.()) { process.kill(-pid, "SIGKILL") } } catch { proc.kill("SIGTERM") - await sleep(SIGKILL_TIMEOUT_MS) + await setTimeout(SIGKILL_TIMEOUT_MS) if (!opts?.exited?.()) { proc.kill("SIGKILL") } diff --git a/packages/core/src/skill.ts b/packages/core/src/skill.ts index c448eae549..511e02af87 100644 --- a/packages/core/src/skill.ts +++ b/packages/core/src/skill.ts @@ -3,7 +3,7 @@ export * as SkillV2 from "./skill" import { makeLocationNode } from "./effect/app-node" import path from "path" import { Context, Effect, Layer, Schema, Stream, Types } from "effect" -import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher" +import { FileSystem } from "@opencode-ai/schema/filesystem" import { Skill } from "@opencode-ai/schema/skill" import { AgentV2 } from "./agent" import { ConfigMarkdown } from "./config/markdown" @@ -153,7 +153,7 @@ const layer = Layer.effect( yield* events.publish(Event.Updated, {}).pipe(Effect.asVoid) }) - yield* events.subscribe(FileSystemWatcher.Event.Updated).pipe( + yield* events.subscribe(FileSystem.Event.Changed).pipe( Stream.runForEach((event) => invalidate(event.data.file)), Effect.forkScoped({ startImmediately: true }), ) diff --git a/packages/core/src/snapshot.ts b/packages/core/src/snapshot.ts index 79a2b6cda8..9843e69226 100644 --- a/packages/core/src/snapshot.ts +++ b/packages/core/src/snapshot.ts @@ -224,7 +224,7 @@ const layer = Layer.effect( }) return Service.of({ capture, files, diff, preview, restore, checkout }) - }), + }).pipe(Effect.withSpan("Snapshot.boot")), ) export const node = makeLocationNode({ diff --git a/packages/core/src/state.ts b/packages/core/src/state.ts index ab3457fc18..93dcdb4e5d 100644 --- a/packages/core/src/state.ts +++ b/packages/core/src/state.ts @@ -26,21 +26,32 @@ export interface Transformable { readonly reload: Reload } -const CurrentBatch = Context.Reference | undefined>("@opencode/State/CurrentBatch", { +type Batch = { + active: boolean + readonly reloads: Set +} + +const CurrentBatch = Context.Reference("@opencode/State/CurrentBatch", { defaultValue: () => undefined, }) export function batch(effect: Effect.Effect) { return Effect.gen(function* () { const current = yield* CurrentBatch - if (current) return yield* effect - const reloads = new Set() - const result = yield* effect.pipe(Effect.provideService(CurrentBatch, reloads)) - yield* Effect.forEach(reloads, (reload) => reload(), { discard: true }) - return result + if (current?.active) return yield* effect + const batch: Batch = { active: true, reloads: new Set() } + const exit = yield* effect.pipe(Effect.provideService(CurrentBatch, batch), Effect.exit) + batch.active = false + yield* Effect.forEach(batch.reloads, (reload) => reload(), { discard: true }) + return yield* exit }) } +export const inherit = Effect.fnUntraced(function* () { + const batch = yield* CurrentBatch + return (effect: Effect.Effect) => Effect.provideService(effect, CurrentBatch, batch) +}) + export interface Options { /** Creates the base value for initial state and every scoped-transform reload. */ readonly initial: () => State @@ -100,8 +111,8 @@ export function create(options: Options): Inte transforms = transforms.filter((item) => item !== transform) return Effect.gen(function* () { const batch = yield* CurrentBatch - if (batch) { - batch.add(reload) + if (batch?.active) { + batch.reloads.add(reload) return } yield* materialize() @@ -116,7 +127,7 @@ export function create(options: Options): Inte ) yield* Scope.addFinalizer(scope, dispose) const batch = yield* CurrentBatch - if (batch) batch.add(reload) + if (batch?.active) batch.reloads.add(reload) else yield* reload() return { dispose } }), diff --git a/packages/core/src/system-context/index.ts b/packages/core/src/system-context/index.ts index ce6aaae454..d86efceb91 100644 --- a/packages/core/src/system-context/index.ts +++ b/packages/core/src/system-context/index.ts @@ -12,7 +12,7 @@ import { Effect, Option, Schema } from "effect" * The durable `Applied` record tracks what the model was last told, per source: * it is the model's current belief. Interpreters uphold one invariant — * `reconcile` never rewrites the baseline; it only narrates drift as update - * text. Only `rebaseline` (compaction) and `initialize` (first turn) produce + * text. Only `rebaseline` (compaction) and `initialize` (first step) produce * baseline text. * * Returning `unavailable` means observation failed temporarily. It differs from diff --git a/packages/core/src/tool/AGENTS.md b/packages/core/src/tool/AGENTS.md index 3719322fa1..4b427f9835 100644 --- a/packages/core/src/tool/AGENTS.md +++ b/packages/core/src/tool/AGENTS.md @@ -28,7 +28,8 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe ## Registration -Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`. +Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`. Registrations may provide a +group, which flattens direct model names to `_`, and may be deferred from direct model exposure. Registrations are scoped: diff --git a/packages/core/src/tool/apply-patch.ts b/packages/core/src/tool/apply-patch.ts index 3d7105a31a..6700e6e1ba 100644 --- a/packages/core/src/tool/apply-patch.ts +++ b/packages/core/src/tool/apply-patch.ts @@ -1,18 +1,16 @@ export * as ApplyPatchTool from "./apply-patch" +import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import { ToolFailure } from "@opencode-ai/llm" import { FileDiff } from "@opencode-ai/schema/file-diff" import { createTwoFilesPatch, diffLines } from "diff" -import { Effect, Layer, Schema } from "effect" -import { makeLocationNode } from "../effect/app-node" +import { Effect, Schema } from "effect" import { FileMutation } from "../file-mutation" import { FSUtil } from "../fs-util" import { LocationMutation } from "../location-mutation" import { Patch } from "../patch" import { PermissionV2 } from "../permission" -import { ToolRegistry } from "./registry" import { Tool } from "./tool" -import { Tools } from "./tools" export const name = "apply_patch" @@ -56,15 +54,15 @@ type Prepared = readonly after: string }) -const layer = Layer.effectDiscard( - Effect.gen(function* () { - const tools = yield* Tools.Service +export const Plugin = { + id: "opencode.tool.apply-patch", + effect: Effect.fn("ApplyPatchTool.Plugin")(function* (ctx: PluginContext) { const mutation = yield* LocationMutation.Service const files = yield* FileMutation.Service const fs = yield* FSUtil.Service const permission = yield* PermissionV2.Service - yield* tools + yield* ctx.tool .register({ [name]: Tool.withPermission( Tool.make({ @@ -194,13 +192,7 @@ const layer = Layer.effectDiscard( }) .pipe(Effect.orDie) }), -) - -export const node = makeLocationNode({ - name: "tool/apply-patch", - layer, - deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node], -}) +} function patchFile(change: Prepared): typeof FileDiff.Info.Type { const counts = diffLines(change.before, change.after).reduce( diff --git a/packages/core/src/tool/builtins.ts b/packages/core/src/tool/builtins.ts deleted file mode 100644 index c72d07d0e7..0000000000 --- a/packages/core/src/tool/builtins.ts +++ /dev/null @@ -1,51 +0,0 @@ -export * as BuiltInTools from "./builtins" - -import { makeLocationNode } from "../effect/app-node" -import { Context, Layer } from "effect" -import { ApplyPatchTool } from "./apply-patch" -import { EditTool } from "./edit" -import { GrepTool } from "./grep" -import { QuestionTool } from "./question" -import { ReadTool } from "./read" -import { ReadToolFileSystem } from "./read-filesystem" -import { SkillTool } from "./skill" -import { TodoWriteTool } from "./todowrite" -import { WebFetchTool } from "./webfetch" -import { WebSearchTool } from "./websearch" -import { WriteTool } from "./write" - -export class Service extends Context.Service>()("@opencode/v2/BuiltInTools") {} - -/** - * Composes only the shipped Location-scoped built-in tool transforms. - * Each tool retains its implementation and focused tests independently. Dynamic - * MCP and plugin tools later use separate scoped canonical registrations, while - * provider/model filtering belongs to a future materialization phase rather - * than this static list. The caller intentionally supplies shared Location - * services once to this merged set. - * - * TODO: Port the remaining launch-follow-up leaves deliberately: edit fuzzy - * parity, task, LSP, - * repo_clone, repo_overview, plan_exit, and Rune/code mode. Keep MCP and plugin - * transforms separate from this static built-in list. - */ -const layer = Layer.succeed(Service, Service.of({})) - -export const node = makeLocationNode({ - service: Service, - layer, - deps: [ - ApplyPatchTool.node, - EditTool.node, - GrepTool.node, - QuestionTool.node, - ReadTool.node, - ReadToolFileSystem.node, - SkillTool.node, - TodoWriteTool.node, - WebFetchTool.node, - WebSearchTool.node, - WebSearchTool.configNode, - WriteTool.node, - ], -}) diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index f0bdb488a0..bb9bb4dee9 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -6,18 +6,16 @@ */ export * as EditTool from "./edit" +import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import { ToolFailure } from "@opencode-ai/llm" import { FileDiff } from "@opencode-ai/schema/file-diff" import { createTwoFilesPatch, diffLines } from "diff" -import { Effect, Layer, Schema } from "effect" -import { makeLocationNode } from "../effect/app-node" +import { Effect, Schema } from "effect" import { FileMutation } from "../file-mutation" import { FSUtil } from "../fs-util" import { LocationMutation } from "../location-mutation" import { PermissionV2 } from "../permission" -import { ToolRegistry } from "./registry" import { Tool } from "./tool" -import { Tools } from "./tools" export const name = "edit" @@ -87,15 +85,15 @@ export const toModelOutput = (output: Output, oldString: string, newString: stri // TODO: Add snapshots / undo after design exists. // TODO: Add LSP notification and diagnostics after V2 LSP runtime exists. -const layer = Layer.effectDiscard( - Effect.gen(function* () { - const tools = yield* Tools.Service +export const Plugin = { + id: "opencode.tool.edit", + effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) { const mutation = yield* LocationMutation.Service const files = yield* FileMutation.Service const fs = yield* FSUtil.Service const permission = yield* PermissionV2.Service - yield* tools + yield* ctx.tool .register({ [name]: Tool.withPermission( Tool.make({ @@ -214,10 +212,4 @@ const layer = Layer.effectDiscard( }) .pipe(Effect.orDie) }), -) - -export const node = makeLocationNode({ - name: "tool/edit", - layer, - deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node], -}) +} diff --git a/packages/core/src/tool/execute.ts b/packages/core/src/tool/execute.ts new file mode 100644 index 0000000000..f5bd776e5a --- /dev/null +++ b/packages/core/src/tool/execute.ts @@ -0,0 +1,202 @@ +export * as ExecuteTool from "./execute" + +import { CodeMode, Tool, toolError } from "@opencode-ai/codemode" +import { ToolOutput } from "@opencode-ai/llm" +import { Effect, Ref, Schema } from "effect" +import { definition, make, settle, type AnyTool } from "./tool" + +const ExecuteFile = Schema.Struct({ + data: Schema.String, + mime: Schema.String, + name: Schema.optionalKey(Schema.String), +}) + +const ExecuteCall = Schema.Struct({ + tool: Schema.String, + status: Schema.Literals(["running", "completed", "error"]), + input: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), +}) + +type ExecuteCall = typeof ExecuteCall.Type + +const ExecuteMetadata = Schema.Struct({ + toolCalls: Schema.Array(ExecuteCall), + error: Schema.optionalKey(Schema.Literal(true)), +}) + +const ExecuteOutput = Schema.Struct({ + output: Schema.String, + toolCalls: Schema.Array(ExecuteCall), + error: Schema.optionalKey(Schema.Literal(true)), + files: Schema.Array(ExecuteFile), +}) + +type CollectedFiles = { + readonly index: number + readonly files: Array +} + +export interface Registration { + readonly identity: object + readonly tool: AnyTool + readonly name: string + readonly group?: string +} + +export const create = (options: { + readonly registrations: ReadonlyMap + readonly current: (name: string) => Registration | undefined +}) => { + const runtime = ( + invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect, + hooks?: CodeMode.ToolCallHooks, + ) => { + const tools: Record | Record>> = {} + for (const [name, registration] of options.registrations) { + const child = definition(name, registration.tool) + const value = Tool.make({ + description: child.description, + input: child.inputSchema, + output: child.outputSchema, + run: (input) => invoke(name, registration, input), + }) + if (registration.group === undefined) { + const path = registration.name + if (Object.hasOwn(tools, path)) throw new TypeError(`Deferred tool namespace conflict: ${path}`) + tools[path] = value + continue + } + const path = registration.name + const namespace = registration.group + const group = tools[namespace] + if (group && Tool.isDefinition(group)) throw new TypeError(`Deferred tool namespace conflict: ${namespace}`) + if (group) { + if (Object.hasOwn(group, path)) throw new TypeError(`Deferred tool namespace conflict: ${namespace}.${path}`) + group[path] = value + continue + } + const entries: Record> = {} + entries[path] = value + tools[namespace] = entries + } + return CodeMode.make({ tools, ...hooks }) + } + const discovery = runtime(() => Effect.fail(toolError("Execute context is unavailable"))) + return make({ + description: discovery.instructions(), + input: CodeMode.Input, + output: ExecuteOutput, + structured: ExecuteMetadata, + toStructuredOutput: ({ output }) => ({ + toolCalls: output.toolCalls, + ...(output.error ? { error: true as const } : {}), + }), + toModelOutput: ({ output }) => [ + { type: "text" as const, text: output.output }, + ...output.files.map((file) => ({ + type: "file" as const, + data: file.data, + mime: file.mime, + ...(file.name === undefined ? {} : { name: file.name }), + })), + ], + execute: ({ code }, context) => + Effect.gen(function* () { + const callIndex = yield* Ref.make(0) + const files = yield* Ref.make>([]) + const calls = yield* Ref.make>([]) + // TODO: Publish live call-list updates once V2 has a generic tool progress API. + const finalCalls = Ref.get(calls).pipe( + Effect.map((items) => + items.map((call) => (call.status === "running" ? { ...call, status: "error" as const } : call)), + ), + ) + const result = yield* runtime( + (name, registration, input) => + Effect.gen(function* () { + const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1) + const current = options.current(name) + if (!current || current.identity !== registration.identity) + return yield* Effect.fail(toolError(`Stale tool call: ${name}`)) + const output = yield* settle( + current.tool, + { type: "tool-call", id: context.toolCallID, name, input }, + { + sessionID: context.sessionID, + agent: context.agent, + assistantMessageID: context.assistantMessageID, + toolCallID: context.toolCallID, + }, + ).pipe(Effect.mapError((failure) => toolError(failure.message, failure))) + const outputFileParts = outputFiles(output) + if (outputFileParts.length > 0) + yield* Ref.update(files, (items) => [...items, { index, files: outputFileParts }]) + return output.structured + }), + { + onToolCallStart: ({ index, name, input }) => + Effect.gen(function* () { + const shown = displayInput(input) + yield* Ref.update(calls, (items) => { + const next = [...items] + next[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) } + return next + }) + }), + onToolCallEnd: ({ index, outcome }) => + Ref.update(calls, (items) => { + const current = items[index] + if (!current) return items + const next = [...items] + next[index] = { ...current, status: outcome === "success" ? "completed" : "error" } + return next + }), + }, + ).execute(code) + const toolCalls = yield* finalCalls + const collected = (yield* Ref.get(files)) + .toSorted((left, right) => left.index - right.index) + .flatMap((item) => item.files) + const output = formatResult(result) + return { output, toolCalls, files: collected, ...(result.ok ? {} : { error: true as const }) } + }), + }) +} + +function displayInput(input: unknown): Record | undefined { + if (input === null || input === undefined) return + if (typeof input !== "object" || Array.isArray(input)) return { input } + if (Object.keys(input).length === 0) return + return input as Record +} + +function formatResult(result: CodeMode.Result) { + const output = result.ok + ? formatValue(result.value) + : [result.error.message, ...(result.error.suggestions ?? []).filter((hint) => !result.error.message.includes(hint))] + .join("\n") + .trim() + if (!result.logs || result.logs.length === 0) return output + const logs = `Logs:\n${result.logs.join("\n")}` + return output === "" ? logs : `${output}\n\n${logs}` +} + +function formatValue(value: CodeMode.DataValue) { + if (typeof value === "string") return value + return JSON.stringify(value, null, 2) ?? String(value) +} + +function outputFiles(output: ToolOutput): Array { + return output.content.flatMap((part) => { + if (part.type !== "file") return [] + const prefix = `data:${part.mime};base64,` + if (!part.uri.startsWith(prefix)) return [] + return [ + { + data: part.uri.slice(prefix.length), + mime: part.mime, + ...(part.name === undefined ? {} : { name: part.name }), + }, + ] + }) +} diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index d4412ae302..753e36cab5 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -5,6 +5,7 @@ import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import { Effect, Schema } from "effect" import path from "path" import { FileSystem } from "../filesystem" +import { FSUtil } from "../fs-util" import { Location } from "../location" import { Ripgrep } from "../ripgrep" import { RelativePath } from "../schema" @@ -34,8 +35,9 @@ export const toModelOutput = (output: ModelOutput) => { /** Glob leaf that defaults its filesystem root to the active Location. */ export const Plugin = { - id: "core-glob-tool", + id: "opencode.tool.glob", effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) { + const fs = yield* FSUtil.Service const ripgrep = yield* Ripgrep.Service const location = yield* Location.Service const permission = yield* PermissionV2.Service @@ -71,6 +73,13 @@ export const Plugin = { source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, }) const cwd = path.resolve(location.directory, input.path ?? ".") + yield* fs + .stat(cwd) + .pipe( + Effect.catchReason("PlatformError", "NotFound", () => + Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })), + ), + ) return yield* ripgrep .glob({ cwd, @@ -88,7 +97,11 @@ export const Plugin = { ), ) }).pipe( - Effect.mapError(() => new ToolFailure({ message: `Unable to find files matching ${input.pattern}` })), + Effect.mapError((error) => + error instanceof ToolFailure + ? error + : new ToolFailure({ message: `Unable to find files matching ${input.pattern}` }), + ), ), }), }) diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index f455bd4c8a..685925aa04 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -1,18 +1,16 @@ export * as GrepTool from "./grep" +import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import { ToolFailure } from "@opencode-ai/llm" -import { Effect, Layer, Schema } from "effect" +import { Effect, Schema } from "effect" import path from "path" -import { makeLocationNode } from "../effect/app-node" import { FileSystem } from "../filesystem" import { FSUtil } from "../fs-util" import { Location } from "../location" import { PermissionV2 } from "../permission" import { Ripgrep } from "../ripgrep" import { RelativePath } from "../schema" -import { ToolRegistry } from "./registry" import { Tool } from "./tool" -import { Tools } from "./tools" export const name = "grep" @@ -50,15 +48,15 @@ export const toModelOutput = (output: ModelOutput) => { } /** Grep leaf that defaults its filesystem root to the active Location. */ -const layer = Layer.effectDiscard( - Effect.gen(function* () { - const tools = yield* Tools.Service +export const Plugin = { + id: "opencode.tool.grep", + effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) { const fs = yield* FSUtil.Service const ripgrep = yield* Ripgrep.Service const location = yield* Location.Service const permission = yield* PermissionV2.Service - yield* tools + yield* ctx.tool .register({ [name]: Tool.make({ description: @@ -93,7 +91,13 @@ const layer = Layer.effectDiscard( source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, }) const target = path.resolve(location.directory, input.path ?? ".") - const info = yield* fs.stat(target).pipe(Effect.catch(() => Effect.succeed(undefined))) + const info = yield* fs + .stat(target) + .pipe( + Effect.catchReason("PlatformError", "NotFound", () => + Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })), + ), + ) return yield* ripgrep .grep({ cwd: info?.type === "Directory" ? target : path.dirname(target), @@ -123,15 +127,15 @@ const layer = Layer.effectDiscard( ), ), ) - }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to grep for ${input.pattern}` }))), + }).pipe( + Effect.mapError((error) => + error instanceof ToolFailure + ? error + : new ToolFailure({ message: `Unable to grep for ${input.pattern}` }), + ), + ), }), }) .pipe(Effect.orDie) }), -) - -export const node = makeLocationNode({ - name: "tool/grep", - layer, - deps: [ToolRegistry.node, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node], -}) +} diff --git a/packages/core/src/tool/mcp.ts b/packages/core/src/tool/mcp.ts index 571b747bab..7503e4e94e 100644 --- a/packages/core/src/tool/mcp.ts +++ b/packages/core/src/tool/mcp.ts @@ -1,101 +1,132 @@ export * as McpTool from "./mcp" -import { createHash } from "node:crypto" import { ToolFailure } from "@opencode-ai/llm" import { McpEvent } from "@opencode-ai/schema/mcp-event" import { Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect" import { makeLocationNode } from "../effect/app-node" import { EventV2 } from "../event" +import { Flag } from "../flag/flag" import { MCP } from "../mcp" +import { PermissionV2 } from "../permission" import { Tool } from "./tool" import { Tools } from "./tools" import { ToolRegistry } from "./registry" -const MAX_NAME_LENGTH = 64 -const HASH_LENGTH = 8 - -const sanitize = (value: string) => value.replace(/[^A-Za-z0-9_-]/g, "_") - -// Deterministic short suffix used to keep overlong or colliding names unique and stable across restarts. -const hashSuffix = (raw: string) => "_" + createHash("sha1").update(raw).digest("hex").slice(0, HASH_LENGTH) - -const fit = (base: string, raw: string) => base.slice(0, MAX_NAME_LENGTH - HASH_LENGTH - 1) + hashSuffix(raw) - /** - * Registry/permission action name for an MCP tool: V1-compatible `_` so existing deny - * rules keep working. Sanitized to a valid tool name, prefixed when it would not start with a letter, - * and hashed down when it would exceed the 64-char limit. + * Registry group and permission action names for MCP tools. */ -export const name = (server: string, tool: string) => { - const joined = sanitize(server) + "_" + sanitize(tool) - const base = /^[A-Za-z]/.test(joined) ? joined : "mcp_" + joined - return base.length > MAX_NAME_LENGTH ? fit(base, `${server}\u0000${tool}`) : base -} - -const toContent = (part: MCP.ToolResultContent): Tool.Content => - part.type === "text" ? { type: "text", text: part.text } : { type: "file", data: part.data, mime: part.mimeType } - -const errorText = (content: ReadonlyArray) => - content - .flatMap((part) => (part.type === "text" ? [part.text] : [])) - .join("\n") - .trim() +export const group = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, "_") +export const name = (server: string, tool: string) => `${group(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}` export const layer = Layer.effectDiscard( Effect.gen(function* () { const mcp = yield* MCP.Service const tools = yield* Tools.Service const events = yield* EventV2.Service + const permission = yield* PermissionV2.Service const scope = yield* Scope.Scope const lock = Semaphore.makeUnsafe(1) let current: Scope.Closeable | undefined - const make = (server: MCP.ServerName, tool: MCP.Tool) => - Tool.make({ - description: tool.description ?? "", - jsonSchema: (tool.inputSchema as JsonSchema.JsonSchema | undefined) ?? { type: "object", properties: {} }, - execute: (input) => - Effect.gen(function* () { - const result = yield* mcp.callTool({ server, name: tool.name, args: (input ?? {}) as Record }).pipe( - Effect.catchTags({ - "MCP.NotFoundError": (error) => new ToolFailure({ message: `MCP server "${error.server}" is not available` }), - "MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }), - }), - ) - if (result.isError) - return yield* new ToolFailure({ message: errorText(result.content) || "MCP tool returned an error" }) - return { structured: result.structured ?? {}, content: result.content.map(toContent) } - }), - }) - // Register the current tool set under a fresh child scope, then close the previous one so the // registry never has a gap where MCP tools disappear mid-swap. const reconcile = lock.withPermit( Effect.gen(function* () { - const used = new Set() - const record: Record = {} + const groups = new Map>() for (const tool of yield* mcp.tools()) { - const initial = name(tool.server, tool.name) - const key = used.has(initial) ? fit(initial, `${tool.server}\u0000${tool.name}`) : initial - used.add(key) - record[key] = make(tool.server, tool) + const group = groups.get(tool.server) ?? {} + const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema + group[tool.name] = Tool.withPermission( + Tool.make({ + description: tool.description ?? "", + jsonSchema: { + ...schema, + type: "object", + properties: schema.properties ?? {}, + additionalProperties: false, + }, + outputSchema: tool.outputSchema as JsonSchema.JsonSchema | undefined, + execute: (input, context) => + Effect.gen(function* () { + yield* permission.assert({ + action: name(tool.server, tool.name), + resources: ["*"], + save: ["*"], + metadata: {}, + sessionID: context.sessionID, + agent: context.agent, + source: { + type: "tool", + messageID: context.assistantMessageID, + callID: context.toolCallID, + }, + }) + const result = yield* mcp + .callTool({ + server: tool.server, + name: tool.name, + args: (input ?? {}) as Record, + }) + .pipe( + Effect.catchTags({ + "MCP.NotFoundError": (error) => + new ToolFailure({ message: `MCP server "${error.server}" is not available` }), + "MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }), + }), + ) + if (result.isError) + return yield* new ToolFailure({ + message: + result.content + .flatMap((part) => (part.type === "text" ? [part.text] : [])) + .join("\n") + .trim() || "MCP tool returned an error", + }) + const content = result.content.map((part) => + part.type === "text" + ? { type: "text" as const, text: part.text } + : { type: "file" as const, data: part.data, mime: part.mimeType }, + ) + const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n") + return { + structured: result.structured ?? (text === "" ? null : text), + content, + } + }).pipe( + Effect.mapError((error) => + error instanceof ToolFailure + ? error + : new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }), + ), + ), + }), + name(tool.server, tool.name), + ) + groups.set(tool.server, group) } const next = yield* Scope.fork(scope) - yield* tools.register(record).pipe(Scope.provide(next), Effect.orDie) + yield* Effect.forEach( + groups, + ([group, record]) => tools.register(record, { group, deferred: Flag.CODEMODE_ENABLED }), + { + discard: true, + }, + ).pipe(Scope.provide(next), Effect.orDie) if (current) yield* Scope.close(current, Exit.void) current = next }), ) yield* reconcile.pipe(Effect.forkScoped) - yield* events - .subscribe(McpEvent.ToolsChanged) - .pipe(Stream.runForEach(() => reconcile), Effect.forkScoped({ startImmediately: true })) + yield* events.subscribe(McpEvent.ToolsChanged).pipe( + Stream.runForEach(() => reconcile), + Effect.forkScoped({ startImmediately: true }), + ) }), ) export const node = makeLocationNode({ name: "mcp-tools", layer, - deps: [ToolRegistry.toolsNode, MCP.node, EventV2.node], + deps: [ToolRegistry.toolsNode, MCP.node, EventV2.node, PermissionV2.node], }) diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index e5ae0d7426..dbabb01747 100644 --- a/packages/core/src/tool/question.ts +++ b/packages/core/src/tool/question.ts @@ -1,13 +1,12 @@ export * as QuestionTool from "./question" +import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import { ToolFailure } from "@opencode-ai/llm" -import { Effect, Layer, Schema } from "effect" -import { makeLocationNode } from "../effect/app-node" +import { Effect, Schema } from "effect" +import { Form } from "../form" import { PermissionV2 } from "../permission" import { QuestionV2 } from "../question" -import { ToolRegistry } from "./registry" import { Tool } from "./tool" -import { Tools } from "./tools" export const name = "question" @@ -31,6 +30,12 @@ export const Output = Schema.Struct({ }) export type Output = typeof Output.Type +export class CancelledError extends Schema.TaggedErrorClass()("QuestionTool.CancelledError", {}) { + override get message() { + return "The user dismissed this question" + } +} + export const toModelOutput = ( questions: ReadonlyArray, answers: ReadonlyArray, @@ -44,13 +49,13 @@ export const toModelOutput = ( return `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.` } -const layer = Layer.effectDiscard( - Effect.gen(function* () { - const tools = yield* Tools.Service - const question = yield* QuestionV2.Service +export const Plugin = { + id: "opencode.tool.question", + effect: Effect.fn("QuestionTool.Plugin")(function* (ctx: PluginContext) { + const forms = yield* Form.Service const permission = yield* PermissionV2.Service - yield* tools + yield* ctx.tool .register({ [name]: Tool.make({ description, @@ -71,24 +76,45 @@ const layer = Layer.effectDiscard( .pipe( Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })), Effect.andThen( - question + forms .ask({ sessionID: context.sessionID, - questions: input.questions, - tool: { messageID: context.assistantMessageID, callID: context.toolCallID }, + metadata: { + kind: "question", + tool: { messageID: context.assistantMessageID, callID: context.toolCallID }, + }, + mode: "form", + fields: input.questions.map( + (question, index): Form.Field => ({ + key: `q${index}`, + title: question.header, + description: question.question, + type: question.multiple === true ? "multiselect" : "string", + options: question.options.map((option) => ({ + value: option.label, + label: option.label, + description: option.description, + })), + custom: true, + }), + ), }) .pipe(Effect.orDie), ), - Effect.map((answers) => ({ answers })), + Effect.flatMap((state) => { + if (state.status === "cancelled") return Effect.die(new CancelledError()) + return Effect.succeed({ + answers: input.questions.map((_, index): QuestionV2.Answer => { + const value = state.answer[`q${index}`] + if (value === undefined) return [] + if (typeof value === "object") return Array.from(value) + return [String(value)] + }), + }) + }), ), }), }) .pipe(Effect.orDie) }), -) - -export const node = makeLocationNode({ - name: "tool/question", - layer, - deps: [ToolRegistry.node, PermissionV2.node, QuestionV2.node], -}) +} diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index c94418f94c..de03a97974 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -1,9 +1,9 @@ export * as ReadTool from "./read" +import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import { dirname } from "path" import { ToolFailure } from "@opencode-ai/llm" -import { Effect, Layer, Schema } from "effect" -import { makeLocationNode } from "../effect/app-node" +import { Effect, Schema } from "effect" import { FileSystem } from "../filesystem" import { FSUtil } from "../fs-util" import { Image } from "../image" @@ -13,9 +13,7 @@ import { PermissionV2 } from "../permission" import { SessionInstructions } from "../session/instructions" import { AbsolutePath } from "../schema" import { ReadToolFileSystem } from "./read-filesystem" -import { ToolRegistry } from "./registry" import { Tool } from "./tool" -import { Tools } from "./tools" export const name = "read" const FILENAME = "AGENTS.md" @@ -32,9 +30,9 @@ const LocationInput = Schema.Struct({ const Input = LocationInput const Output = Schema.Union([FileSystem.Content, ReadToolFileSystem.TextPage, ReadToolFileSystem.ListPage]) -const layer = Layer.effectDiscard( - Effect.gen(function* () { - const tools = yield* Tools.Service +export const Plugin = { + id: "opencode.tool.read", + effect: Effect.fn("ReadTool.Plugin")(function* (ctx: PluginContext) { const reader = yield* ReadToolFileSystem.Service const mutation = yield* LocationMutation.Service const image = yield* Image.Service @@ -43,7 +41,7 @@ const layer = Layer.effectDiscard( const fs = yield* FSUtil.Service const location = yield* Location.Service - yield* tools + yield* ctx.tool .register({ [name]: Tool.make({ description: @@ -99,8 +97,8 @@ const layer = Layer.effectDiscard( // skipped, and discovery failures never fail the read. yield* Effect.gen(function* () { if (target.externalDirectory !== undefined) return - const resolved = FSUtil.resolve(target.canonical) - const root = FSUtil.resolve(location.directory) + const resolved = yield* fs.resolve(target.canonical) + const root = yield* fs.resolve(location.directory) // up() searches its stop directory, so the Location-root AGENTS.md (already // supplied by the core/instructions baseline) is dropped by the dirname filter. const discovered = yield* fs.up({ @@ -108,10 +106,13 @@ const layer = Layer.effectDiscard( start: type === "directory" ? resolved : dirname(resolved), stop: root, }) - const candidates = discovered.map(FSUtil.resolve).filter((file) => dirname(file) !== root) + const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter((file) => dirname(file) !== root) if (candidates.length === 0) return yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates }) - }).pipe(Effect.catch(() => Effect.void), Effect.catchDefect(() => Effect.void)) + }).pipe( + Effect.catch(() => Effect.void), + Effect.catchDefect(() => Effect.void), + ) if ("encoding" in content && content.encoding === "base64" && SUPPORTED_IMAGE_MIMES.has(content.mime)) { return yield* image .normalize(resource, { ...content, encoding: "base64" }) @@ -137,19 +138,4 @@ const layer = Layer.effectDiscard( }) .pipe(Effect.orDie) }), -) - -export const node = makeLocationNode({ - name: "tool/read", - layer, - deps: [ - ToolRegistry.node, - ReadToolFileSystem.node, - LocationMutation.node, - Image.node, - PermissionV2.node, - SessionInstructions.node, - FSUtil.node, - Location.node, - ], -}) +} diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 1dc7478c11..de11c10e6f 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -3,12 +3,14 @@ export * as ToolRegistry from "./registry" import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/llm" import { Context, Effect, Layer, Scope } from "effect" import type { AgentV2 } from "../agent" +import { Flag } from "../flag/flag" import { PermissionV2 } from "../permission" import { SessionMessage } from "../session/message" import { SessionSchema } from "../session/schema" import { ToolOutputStore } from "../tool-output-store" import { Wildcard } from "../util/wildcard" -import { definition, permission, registrationEntries, settle, type AnyTool, type RegistrationError } from "./tool" +import { ExecuteTool } from "./execute" +import { definition, permission, registrationEntries, RegistrationError, settle, type AnyTool } from "./tool" import { Tools } from "./tools" import { ToolHooks } from "./hooks" import { makeLocationNode } from "../effect/app-node" @@ -23,7 +25,10 @@ export type ExecuteInput = { export interface Interface { readonly materialize: (input: MaterializeInput) => Effect.Effect /** Internal registration capability exposed publicly only through Tools.Service. */ - readonly register: (tools: Readonly>) => Effect.Effect + readonly register: ( + tools: Readonly>, + options?: Tools.RegisterOptions, + ) => Effect.Effect } export interface MaterializeInput { @@ -49,21 +54,17 @@ const registryLayer = Layer.effect( Effect.gen(function* () { const resources = yield* ToolOutputStore.Service const toolHooks = yield* ToolHooks.Service - type Registration = { readonly identity: object; readonly tool: AnyTool } + type Registration = { + readonly identity: object + readonly tool: AnyTool + readonly name: string + readonly group?: string + readonly deferred: boolean + } const local = new Map>() - const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised?: object) { - const registration = local.get(input.call.name)?.at(-1)?.registration - if (!registration) - return { - result: { - type: "error" as const, - value: advertised ? `Stale tool call: ${input.call.name}` : `Unknown tool: ${input.call.name}`, - }, - } - if (advertised && registration.identity !== advertised) - return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } } - // Hooks fire only for hosted/local tools; provider-executed calls never reach settleWith. + const settleTool = Effect.fn("ToolRegistry.settleTool")(function* (input: ExecuteInput, tool: AnyTool) { + // Hooks fire only for hosted/local tools; provider-executed calls never reach settleTool. const beforeEvent: ToolHooks.BeforeEvent = { tool: input.call.name, sessionID: input.sessionID, @@ -73,12 +74,16 @@ const registryLayer = Layer.effect( input: input.call.input, } yield* toolHooks.runBefore(beforeEvent) - const pending = yield* settle(registration.tool, { ...input.call, input: beforeEvent.input }, { - sessionID: input.sessionID, - agent: input.agent, - assistantMessageID: input.assistantMessageID, - toolCallID: input.call.id, - }).pipe( + const pending = yield* settle( + tool, + { ...input.call, input: beforeEvent.input }, + { + sessionID: input.sessionID, + agent: input.agent, + assistantMessageID: input.assistantMessageID, + toolCallID: input.call.id, + }, + ).pipe( Effect.map((output) => ({ output })), Effect.catchTag("LLM.ToolFailure", (failure) => Effect.succeed({ result: { type: "error" as const, value: failure.message } }), @@ -88,7 +93,11 @@ const registryLayer = Layer.effect( if ("result" in pending) { settlement = pending } else { - const bounded = yield* resources.bound({ sessionID: input.sessionID, toolCallID: input.call.id, output: pending.output }) + const bounded = yield* resources.bound({ + sessionID: input.sessionID, + toolCallID: input.call.id, + output: pending.output, + }) const result = ToolOutput.toResultValue(bounded.output) settlement = result.type === "error" @@ -118,21 +127,53 @@ const registryLayer = Layer.effect( } }) + const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised: object) { + const registration = local.get(input.call.name)?.at(-1)?.registration + if (!registration) + return { + result: { + type: "error" as const, + value: `Stale tool call: ${input.call.name}`, + }, + } + if (registration.identity !== advertised) + return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } } + return yield* settleTool(input, registration.tool) + }) + return Service.of({ - register: Effect.fn("ToolRegistry.register")(function* (tools) { - const entries = registrationEntries(tools) + register: Effect.fn("ToolRegistry.register")(function* (tools, options) { + const entries = registrationEntries(tools, options?.group) if (entries.length === 0) return + const reserved = options?.deferred ? undefined : entries.find((entry) => entry.key === "execute") + if (reserved) + return yield* Effect.fail( + new RegistrationError({ name: reserved.key, message: 'Tool name "execute" is reserved for CodeMode' }), + ) yield* Effect.uninterruptible( Effect.gen(function* () { const token = {} - for (const [name, tool] of entries) - local.set(name, [...(local.get(name) ?? []), { token, registration: { identity: {}, tool } }]) + for (const entry of entries) + local.set(entry.key, [ + ...(local.get(entry.key) ?? []), + { + token, + registration: { + identity: {}, + tool: entry.tool, + name: entry.name, + group: entry.group, + deferred: options?.deferred ?? false, + }, + }, + ]) yield* Effect.addFinalizer(() => Effect.sync(() => { - for (const [name] of entries) { - const registrations = local.get(name)?.filter((registration) => registration.token !== token) ?? [] - if (registrations.length > 0) local.set(name, registrations) - else local.delete(name) + for (const entry of entries) { + const registrations = + local.get(entry.key)?.filter((registration) => registration.token !== token) ?? [] + if (registrations.length > 0) local.set(entry.key, registrations) + else local.delete(entry.key) } }), ) @@ -149,13 +190,30 @@ const registryLayer = Layer.effect( const usePatch = input.model.provider.toLowerCase() === "openai" || input.model.id.toLowerCase().includes("gpt") for (const [name, registration] of registrations) { const wrongEditTool = name === "apply_patch" ? !usePatch : (name === "edit" || name === "write") && usePatch - if (wrongEditTool || whollyDisabled(permission(registration.tool, name), input.permissions ?? [])) + if ( + wrongEditTool || + (registration.deferred && !Flag.CODEMODE_ENABLED) || + whollyDisabled(permission(registration.tool, name), input.permissions ?? []) + ) registrations.delete(name) } + const direct = new Map(Array.from(registrations).filter(([, registration]) => !registration.deferred)) + const deferred = new Map(Array.from(registrations).filter(([, registration]) => registration.deferred)) + const execute = + deferred.size > 0 && !whollyDisabled("execute", input.permissions ?? []) + ? ExecuteTool.create({ + registrations: deferred, + current: (name) => local.get(name)?.at(-1)?.registration, + }) + : undefined return { - definitions: Array.from(registrations, ([name, registration]) => definition(name, registration.tool)), + definitions: [ + ...Array.from(direct, ([name, registration]) => definition(name, registration.tool)), + ...(execute ? [definition("execute", execute)] : []), + ], settle: (input) => { - const registration = registrations.get(input.call.name) + if (input.call.name === "execute" && execute) return settleTool(input, execute) + const registration = direct.get(input.call.name) if (registration) return settleWith(input, registration.identity) return Effect.succeed({ result: { type: "error", value: `Unknown tool: ${input.call.name}` } }) }, diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index e86f960ba5..cd0c428a61 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -18,8 +18,9 @@ export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000 export const MAX_TIMEOUT_MS = 10 * 60 * 1_000 export const MAX_CAPTURE_BYTES = 1024 * 1024 -const BACKGROUND_STARTED = - "The command has not completed; it is now running in the background." +const BACKGROUND_STARTED = "The command was moved to the background." +const BACKGROUND_INSTRUCTION = + "You will be notified automatically when the command finishes. DO NOT sleep, poll, or proactively check on its progress." export const Input = Schema.Struct({ command: Schema.String.annotate({ description: "Shell command string to execute" }), @@ -54,10 +55,11 @@ const Output = Schema.Struct({ type Output = typeof Output.Type const modelOutput = (output: Output): string | undefined => { - if (output.status === "running") return undefined const warnings = output.warnings?.length ? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}` : "" + if (output.status === "running") + return `${warnings.trimStart()}${warnings ? "\n\n" : ""}${BACKGROUND_INSTRUCTION}` if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.` return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.` } @@ -80,20 +82,24 @@ const modelOutput = (output: Output): string | undefined => { const shellTokens = (command: string) => command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [] const unquote = (value: string) => value.replace(/^(['"])(.*)\1$/, "$2") -const externalCommandDirectories = (command: string, cwd: string) => { +const externalCommandDirectories = Effect.fn("ShellTool.externalCommandDirectories")(function* ( + fs: FSUtil.Interface, + command: string, + cwd: string, +) { const directories = new Set() for (const token of shellTokens(command)) { const value = unquote(token).replace(/[;,|&]+$/, "") if (!path.isAbsolute(value)) continue - const resolved = FSUtil.resolve(value) + const resolved = yield* fs.resolve(value) if (FSUtil.contains(cwd, resolved)) continue - directories.add(FSUtil.resolve(path.dirname(resolved))) + directories.add(yield* fs.resolve(path.dirname(resolved))) } return [...directories] -} +}) export const Plugin = { - id: "core-shell-tool", + id: "opencode.tool.shell", effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) { const runtime = yield* PluginRuntime.Service const scope = yield* Scope.Scope @@ -168,7 +174,7 @@ export const Plugin = { agent: context.agent, source, }) - const warnings = externalCommandDirectories(input.command, target.canonical).map( + const warnings = (yield* externalCommandDirectories(fsUtil, input.command, target.canonical)).map( (directory) => `Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Shell runs with host-user filesystem, process, and network authority; this scan is advisory only.`, ) diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index 1f8b122903..669e36c4c8 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -1,15 +1,13 @@ export * as SkillTool from "./skill" +import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import path from "path" import { ToolFailure } from "@opencode-ai/llm" -import { Effect, Layer, Schema } from "effect" -import { makeLocationNode } from "../effect/app-node" +import { Effect, Schema } from "effect" import { FSUtil } from "../fs-util" import { SkillV2 } from "../skill" import { PermissionV2 } from "../permission" -import { ToolRegistry } from "./registry" import { Tool } from "./tool" -import { Tools } from "./tools" export const name = "skill" const FILE_LIMIT = 10 @@ -54,13 +52,13 @@ export const toModelOutput = (skill: SkillV2.Info, files: ReadonlyArray) const unableToLoad = (name: string, error?: unknown) => new ToolFailure({ message: `Unable to load skill ${name}`, error }) -const layer = Layer.effectDiscard( - Effect.gen(function* () { - const tools = yield* Tools.Service +export const Plugin = { + id: "opencode.tool.skill", + effect: Effect.fn("SkillTool.Plugin")(function* (ctx: PluginContext) { const fs = yield* FSUtil.Service const skills = yield* SkillV2.Service const permission = yield* PermissionV2.Service - yield* tools + yield* ctx.tool .register({ [name]: Tool.make({ description, @@ -100,10 +98,4 @@ const layer = Layer.effectDiscard( }) .pipe(Effect.orDie) }), -) - -export const node = makeLocationNode({ - name: "tool/skill", - layer, - deps: [ToolRegistry.node, FSUtil.node, SkillV2.node, PermissionV2.node], -}) +} diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index 517d1ce65f..50db674ef2 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -38,7 +38,7 @@ export const description = [ ].join("\n") export const Plugin = { - id: "core-subagent-tool", + id: "opencode.tool.subagent", effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) { const runtime = yield* PluginRuntime.Service const agents = yield* AgentV2.Service diff --git a/packages/core/src/tool/todowrite.ts b/packages/core/src/tool/todowrite.ts index bc1ba1fbb3..279792e38a 100644 --- a/packages/core/src/tool/todowrite.ts +++ b/packages/core/src/tool/todowrite.ts @@ -1,13 +1,11 @@ export * as TodoWriteTool from "./todowrite" +import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import { ToolFailure } from "@opencode-ai/llm" -import { Effect, Layer, Schema } from "effect" -import { makeLocationNode } from "../effect/app-node" +import { Effect, Schema } from "effect" import { PermissionV2 } from "../permission" import { SessionTodo } from "../session/todo" -import { ToolRegistry } from "./registry" import { Tool } from "./tool" -import { Tools } from "./tools" export const name = "todowrite" @@ -22,13 +20,13 @@ export type Output = typeof Output.Type export const toModelOutput = (output: Output) => JSON.stringify(output.todos, null, 2) -const layer = Layer.effectDiscard( - Effect.gen(function* () { - const tools = yield* Tools.Service +export const Plugin = { + id: "opencode.tool.todowrite", + effect: Effect.fn("TodoWriteTool.Plugin")(function* (ctx: PluginContext) { const todos = yield* SessionTodo.Service const permission = yield* PermissionV2.Service - yield* tools + yield* ctx.tool .register({ [name]: Tool.make({ description: @@ -53,10 +51,4 @@ const layer = Layer.effectDiscard( }) .pipe(Effect.orDie) }), -) - -export const node = makeLocationNode({ - name: "tool/todowrite", - layer, - deps: [ToolRegistry.node, PermissionV2.node, SessionTodo.node], -}) +} diff --git a/packages/core/src/tool/tools.ts b/packages/core/src/tool/tools.ts index 0939ce957b..06b920a484 100644 --- a/packages/core/src/tool/tools.ts +++ b/packages/core/src/tool/tools.ts @@ -3,9 +3,12 @@ export * as Tools from "./tools" import { Context, Effect, Scope } from "effect" import { Tool } from "./tool" +export type RegisterOptions = Tool.RegisterOptions + export interface Interface { readonly register: ( tools: Readonly>, + options?: Tool.RegisterOptions, ) => Effect.Effect } diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index d3889d6a7a..b729fb43f7 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -1,17 +1,14 @@ export * as WebFetchTool from "./webfetch" +import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import { ToolFailure } from "@opencode-ai/llm" -import { Duration, Effect, Layer, Schema } from "effect" +import { Duration, Effect, Schema } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { Parser } from "htmlparser2" import TurndownService from "turndown" -import { makeLocationNode } from "../effect/app-node" -import { LayerNodePlatform } from "../effect/app-node-platform" import { PermissionV2 } from "../permission" import { collectBoundedResponseBody } from "./http-body" -import { ToolRegistry } from "./registry" import { Tool } from "./tool" -import { Tools } from "./tools" export const name = "webfetch" export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024 @@ -115,13 +112,13 @@ const convert = (content: string, contentType: string, format: Format) => { return content } -const layer = Layer.effectDiscard( - Effect.gen(function* () { - const tools = yield* Tools.Service +export const Plugin = { + id: "opencode.tool.webfetch", + effect: Effect.fn("WebFetchTool.Plugin")(function* (ctx: PluginContext) { const http = yield* HttpClient.HttpClient const permission = yield* PermissionV2.Service - yield* tools + yield* ctx.tool .register({ [name]: Tool.make({ description, @@ -178,13 +175,7 @@ const layer = Layer.effectDiscard( }) .pipe(Effect.orDie) }), -) - -export const node = makeLocationNode({ - name: "tool/webfetch", - layer, - deps: [ToolRegistry.node, PermissionV2.node, LayerNodePlatform.httpClient], -}) +} export function extractTextFromHTML(html: string) { let text = "" diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index 6d62236316..d0c984b89a 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -1,19 +1,17 @@ export * as WebSearchTool from "./websearch" +import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import { ToolFailure } from "@opencode-ai/llm" import { Context, Duration, Effect, Layer, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { makeLocationNode } from "../effect/app-node" -import { LayerNodePlatform } from "../effect/app-node-platform" import { truthy } from "../flag/flag" import { InstallationVersion } from "../installation/version" import { PositiveInt } from "../schema" import { PermissionV2 } from "../permission" import { Tool } from "./tool" -import { Tools } from "./tools" import { collectBoundedResponseBody } from "./http-body" import { checksum } from "../util/encode" -import { ToolRegistry } from "./registry" export const name = "websearch" export const NO_RESULTS = "No search results found. Please try a different query." @@ -189,14 +187,14 @@ const Output = Schema.Struct({ text: Schema.String, }) -const layer = Layer.effectDiscard( - Effect.gen(function* () { - const tools = yield* Tools.Service +export const Plugin = { + id: "opencode.tool.websearch", + effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) { const http = yield* HttpClient.HttpClient const config = yield* ConfigService const permission = yield* PermissionV2.Service - yield* tools + yield* ctx.tool .register({ [name]: Tool.make({ description, @@ -251,10 +249,4 @@ const layer = Layer.effectDiscard( }) .pipe(Effect.orDie) }), -) - -export const node = makeLocationNode({ - name: "tool/websearch", - layer, - deps: [ToolRegistry.node, PermissionV2.node, LayerNodePlatform.httpClient, configNode], -}) +} diff --git a/packages/core/src/tool/write.ts b/packages/core/src/tool/write.ts index 39ad0b20fb..ca89665be4 100644 --- a/packages/core/src/tool/write.ts +++ b/packages/core/src/tool/write.ts @@ -6,15 +6,13 @@ */ export * as WriteTool from "./write" +import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import { ToolFailure } from "@opencode-ai/llm" -import { Effect, Layer, Schema } from "effect" -import { makeLocationNode } from "../effect/app-node" +import { Effect, Schema } from "effect" import { FileMutation } from "../file-mutation" import { LocationMutation } from "../location-mutation" import { PermissionV2 } from "../permission" -import { ToolRegistry } from "./registry" import { Tool } from "./tool" -import { Tools } from "./tools" export const name = "write" @@ -44,14 +42,14 @@ export const toModelOutput = (output: Output) => // TODO: Add snapshots / undo after design exists. // TODO: Add LSP notification and diagnostics after V2 LSP runtime exists. -const layer = Layer.effectDiscard( - Effect.gen(function* () { - const tools = yield* Tools.Service +export const Plugin = { + id: "opencode.tool.write", + effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) { const mutation = yield* LocationMutation.Service const files = yield* FileMutation.Service const permission = yield* PermissionV2.Service - yield* tools + yield* ctx.tool .register({ [name]: Tool.withPermission( Tool.make({ @@ -92,10 +90,4 @@ const layer = Layer.effectDiscard( }) .pipe(Effect.orDie) }), -) - -export const node = makeLocationNode({ - name: "tool/write", - layer, - deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, PermissionV2.node], -}) +} diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 2e773f71e2..d32af99812 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -2,7 +2,6 @@ export * as ConfigV1 from "./config" import { Schema } from "effect" import { NonNegativeInt, PositiveInt, type DeepMutable } from "../../schema" -import { ConfigExperimental } from "../../config/experimental" import { ConfigReference } from "../../config/reference" import { ConfigAgentV1 } from "./agent" import { ConfigAttachmentV1 } from "./attachment" @@ -179,9 +178,6 @@ export const Info = Schema.Struct({ mcp_timeout: Schema.optional(PositiveInt).annotate({ description: "Timeout in milliseconds for model context protocol (MCP) requests", }), - policies: Schema.optional(Schema.mutable(Schema.Array(ConfigExperimental.Policy))).annotate({ - description: "Policy statements applied to supported resources, such as provider access", - }), }), ), }).annotate({ identifier: "Config" }) diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 2a9e1c7383..046f29c431 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -78,7 +78,6 @@ export function migrate(info: typeof ConfigV1.Info.Type) { plugins: info.plugin?.map((plugin) => typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] }, ), - experimental: info.experimental?.policies && { policies: info.experimental.policies }, providers: providers(info.provider), } } diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 6c736cde1e..04bc006888 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -8,7 +8,6 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" -import { Policy } from "@opencode-ai/core/policy" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "./fixture/location" @@ -24,7 +23,7 @@ const locationLayer = Layer.succeed( Location.Service.of(location({ directory: AbsolutePath.make("test") })), ) const catalogLayer = AppNodeBuilder.build( - LayerNode.group([Catalog.node, EventV2.node, Credential.node, Integration.node, Policy.node]), + LayerNode.group([Catalog.node, EventV2.node, Credential.node, Integration.node]), [[Location.node, locationLayer]], ) const it = testEffect(catalogLayer) @@ -333,21 +332,4 @@ describe("CatalogV2", () => { expect((yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini") }), ) - - it.effect("removes providers denied by policy after loading", () => - Effect.gen(function* () { - const catalog = yield* Catalog.Service - const policy = yield* Policy.Service - const providerID = ProviderV2.ID.make("blocked") - yield* policy.load([new Policy.Info({ effect: "deny", action: "provider.use", resource: "blocked" })]) - yield* catalog.transform((catalog) => { - catalog.provider.update(providerID, () => {}) - catalog.model.update(providerID, ModelV2.ID.make("model"), () => {}) - }) - - expect(yield* catalog.provider.all()).toEqual([]) - expect(yield* catalog.model.all()).toEqual([]) - expect(yield* catalog.provider.get(providerID)).toBeUndefined() - }), - ) }) diff --git a/packages/core/test/config/command.test.ts b/packages/core/test/config/command.test.ts index 362a8e5da5..a8126d00f6 100644 --- a/packages/core/test/config/command.test.ts +++ b/packages/core/test/config/command.test.ts @@ -1,13 +1,15 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { Effect, Schema } from "effect" +import { Effect, PubSub, Schema, Stream } from "effect" +import { Config as ConfigSchema } from "@opencode-ai/schema/config" import { CommandV2 } from "@opencode-ai/core/command" import { Config } from "@opencode-ai/core/config" import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" +import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { MCP } from "@opencode-ai/core/mcp/index" import { ModelV2 } from "@opencode-ai/core/model" @@ -19,7 +21,7 @@ import { testEffect } from "../lib/effect" import { host } from "../plugin/host" const it = testEffect( - AppNodeBuilder.build(LayerNode.group([CommandV2.node, FSUtil.node]), [ + AppNodeBuilder.build(LayerNode.group([CommandV2.node, EventV2.node, FSUtil.node]), [ [MCP.node, emptyMcpLayer], [Config.node, emptyConfigLayer], [Location.node, testLocationLayer], @@ -53,7 +55,19 @@ Review files`, }) const command = yield* CommandV2.Service - yield* ConfigCommandPlugin.Plugin.effect(host({ command: { ...command, reload: command.reload } })).pipe( + const events = yield* EventV2.Service + const update = yield* events.publish(ConfigSchema.Event.Updated, {}) + const updates = yield* PubSub.unbounded() + yield* ConfigCommandPlugin.Plugin.effect( + host({ + command: { + list: () => Effect.die("unused command.list"), + transform: command.transform, + reload: command.reload, + }, + event: { subscribe: () => Stream.fromPubSub(updates) }, + }), + ).pipe( Effect.provideService( Config.Service, Config.Service.of({ @@ -85,6 +99,15 @@ Review files`, CommandV2.Info.make({ name: "empty", template: "" }), CommandV2.Info.make({ name: "nested/docs", template: "Write docs" }), ]) + + yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "commands", "review.md"), "Review again")) + yield* Effect.sleep("10 millis") + yield* PubSub.publish(updates, update) + for (let attempt = 0; attempt < 100; attempt++) { + if ((yield* command.get("review"))?.template === "Review again") break + yield* Effect.sleep("10 millis") + } + expect((yield* command.get("review"))?.template).toBe("Review again") }), ), ), diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index e46644abae..edf18d5add 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -1,18 +1,20 @@ import path from "path" import fs from "fs/promises" import { describe, expect } from "bun:test" -import { Effect, Layer, Schema } from "effect" +import { Effect, Fiber, Layer, PubSub, Schema, Stream } from "effect" import { FastCheck } from "effect/testing" import { Config } from "@opencode-ai/core/config" +import { Config as ConfigSchema } from "@opencode-ai/schema/config" import { ConfigProvider } from "@opencode-ai/core/config/provider" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { FSUtil } from "@opencode-ai/core/fs-util" +import { Watcher } from "@opencode-ai/core/filesystem/watcher" +import { EventV2 } from "@opencode-ai/core/event" import { Global } from "@opencode-ai/core/global" import { Location } from "@opencode-ai/core/location" -import { Policy } from "@opencode-ai/core/policy" import { Project } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" @@ -26,6 +28,7 @@ function testLayer( globalDirectory = path.join(directory, "global"), projectDirectory = directory, vcs?: Project.Vcs, + watcher?: Layer.Layer, ) { const locationLayer = Layer.succeed( Location.Service, @@ -36,9 +39,10 @@ function testLayer( ), ), ) - return AppNodeBuilder.build(LayerNode.group([Config.node, Policy.node]), [ + return AppNodeBuilder.build(LayerNode.group([Config.node, EventV2.node]), [ [Location.node, locationLayer], [Global.node, Global.layerWith({ config: globalDirectory })], + ...(watcher ? ([[Watcher.node, watcher]] as const) : []), ]) } @@ -52,6 +56,52 @@ const provider = { } describe("Config", () => { + it.live("reloads external config and publishes directory updates", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const global = path.join(tmp.path, "global") + const project = path.join(tmp.path, "project") + const file = path.join(global, "opencode.json") + yield* Effect.promise(async () => { + await fs.mkdir(global, { recursive: true }) + await fs.mkdir(project, { recursive: true }) + await fs.writeFile(file, JSON.stringify({ shell: "first" })) + }) + const updates = yield* PubSub.unbounded() + const watcher = Layer.succeed( + Watcher.Service, + Watcher.Service.of({ + subscribe: () => Stream.fromPubSub(updates), + }), + ) + + return yield* Effect.gen(function* () { + const config = yield* Config.Service + const events = yield* EventV2.Service + const changed = yield* events + .subscribe(ConfigSchema.Event.Updated) + .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + yield* Effect.sleep("10 millis") + + yield* PubSub.publish(updates, { + type: "update", + path: path.join(global, "commands", "review.md"), + } satisfies Watcher.Update) + yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ shell: "second" }))) + yield* PubSub.publish(updates, { type: "update", path: file } satisfies Watcher.Update) + + expect(yield* Fiber.join(changed)).toHaveLength(1) + expect(Config.latest(yield* config.entries(), "shell")).toBe("second") + }).pipe(Effect.provide(testLayer(project, global, project, undefined, watcher))) + }), + ), + ), + ) + it.effect("returns the latest defined scalar from priority-ordered documents", () => Effect.sync(() => { const entries = [ @@ -242,6 +292,72 @@ describe("Config", () => { ), ) + it.live("substitutes environment variables and relative file contents", () => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = { + token: process.env.OPENCODE_TEST_MCP_TOKEN, + missing: process.env.OPENCODE_TEST_MISSING, + } + process.env.OPENCODE_TEST_MCP_TOKEN = "secret" + delete process.env.OPENCODE_TEST_MISSING + return previous + }), + () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + yield* Effect.promise(() => + Promise.all([ + fs.writeFile(path.join(tmp.path, "token.txt"), 'file\n"token"\n'), + fs.writeFile( + path.join(tmp.path, "opencode.jsonc"), + `{ + // Ignored reference: {file:missing.txt} + "username": "user-{env:OPENCODE_TEST_MISSING}", + "mcp": { + "servers": { + "remote": { + "type": "remote", + "url": "https://example.com/mcp", + "headers": { + "Authorization": "Bearer {env:OPENCODE_TEST_MCP_TOKEN}", + "X-Token": "{file:token.txt}" + } + } + } + } + }`, + ), + ]), + ) + + return yield* Effect.gen(function* () { + const config = yield* Config.Service + const document = (yield* config.entries()).find((entry) => entry.type === "document") + expect(document?.info.username).toBe("user-") + const remote = document?.info.mcp?.servers?.remote + expect(remote?.type).toBe("remote") + if (remote?.type !== "remote") return + expect(remote.headers).toEqual({ + Authorization: "Bearer secret", + "X-Token": 'file\n"token"', + }) + }).pipe(Effect.provide(testLayer(tmp.path))) + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + (previous) => + Effect.sync(() => { + if (previous.token === undefined) delete process.env.OPENCODE_TEST_MCP_TOKEN + else process.env.OPENCODE_TEST_MCP_TOKEN = previous.token + if (previous.missing === undefined) delete process.env.OPENCODE_TEST_MISSING + else process.env.OPENCODE_TEST_MISSING = previous.missing + }), + ), + ) + it.live("does not load legacy config.json files", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), @@ -274,7 +390,6 @@ describe("Config", () => { const file = path.join(tmp.path, "opencode.json") const contents = JSON.stringify({ shell: "/bin/zsh", - experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] }, providers: { local: provider }, }) yield* Effect.promise(() => fs.writeFile(file, contents)) @@ -285,11 +400,6 @@ describe("Config", () => { expect(documents[0]?.info.$schema).toBeUndefined() expect(documents[0]?.info.shell).toBe("/bin/zsh") - expect(documents[0]?.info.experimental?.policies?.[0]).toEqual({ - effect: "deny", - action: "provider.use", - resource: "openai", - }) expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe(contents) }).pipe(Effect.provide(testLayer(tmp.path))) }), @@ -723,40 +833,6 @@ describe("Config", () => { ), ) - it.live("loads policy statements in reverse config order", () => - Effect.acquireRelease( - Effect.promise(() => tmpdir()), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe( - Effect.flatMap((tmp) => { - const global = path.join(tmp.path, "global") - return Effect.gen(function* () { - yield* Effect.promise(async () => { - await fs.mkdir(global, { recursive: true }) - await fs.writeFile( - path.join(global, "opencode.json"), - JSON.stringify({ - experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] }, - }), - ) - await fs.writeFile( - path.join(tmp.path, "opencode.json"), - JSON.stringify({ - experimental: { policies: [{ effect: "allow", action: "provider.use", resource: "openai" }] }, - }), - ) - }) - - return yield* Effect.gen(function* () { - const policy = yield* Policy.Service - - expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny") - }).pipe(Effect.provide(testLayer(tmp.path, global))) - }) - }), - ), - ) - it.live("loads global, ancestor, and .opencode configuration up to the project boundary", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts index b72a62df8a..34a69ffbc5 100644 --- a/packages/core/test/config/plugin.test.ts +++ b/packages/core/test/config/plugin.test.ts @@ -1,252 +1,298 @@ +import fs from "fs/promises" import path from "path" +import { pathToFileURL } from "url" import { describe, expect } from "bun:test" -import { Effect, Schema } from "effect" +import { define } from "@opencode-ai/plugin/v2/effect" +import { Config as ConfigSchema } from "@opencode-ai/schema/config" +import { Plugin } from "@opencode-ai/schema/plugin" import { AgentV2 } from "@opencode-ai/core/agent" -import { Config } from "@opencode-ai/core/config" -import { ConfigExternalPlugin } from "@opencode-ai/core/config/plugin/external" -import { FSUtil } from "@opencode-ai/core/fs-util" +import { Catalog } from "@opencode-ai/core/catalog" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" -import { Npm } from "@opencode-ai/core/npm" +import { LocationServiceMap } from "@opencode-ai/core/location-services" import { PluginV2 } from "@opencode-ai/core/plugin" -import { PluginHost } from "@opencode-ai/core/plugin/host" +import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" +import { Effect } from "effect" +import { Database } from "../../src/database/database" +import { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" -import { PluginTestLayer } from "../plugin/fixture" -const it = testEffect(PluginTestLayer) -const decode = Schema.decodeUnknownSync(Config.Info) +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SdkPlugins.node, LocationServiceMap.node])), +) -describe("ConfigExternalPlugin", () => { - it.live("resolves and loads a configured Promise plugin with options", () => - Effect.gen(function* () { - const plugins = yield* PluginV2.Service - const agents = yield* AgentV2.Service - const fs = yield* FSUtil.Service - const location = yield* Location.Service - const npm = yield* Npm.Service - const host = yield* PluginHost.make(plugins) - const document = path.join(import.meta.dir, "opencode.json") +describe("PluginSupervisor config", () => { + it.live("applies selectors in order", () => + withLocation( + { plugins: ["-opencode.provider.*", "opencode.provider.openai"] }, + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + yield* ready() + expect( + (yield* plugins.list()).map((plugin) => plugin.id).filter((id) => id.startsWith("opencode.provider.")), + ).toEqual([Plugin.ID.make("opencode.provider.openai")]) + }), + ), + ) - yield* ConfigExternalPlugin.Plugin.effect(host).pipe( - Effect.provideService(PluginV2.Service, plugins), - Effect.provideService(FSUtil.Service, fs), - Effect.provideService(Location.Service, location), - Effect.provideService(Npm.Service, npm), - Effect.provideService( - Config.Service, - Config.Service.of({ - entries: () => - Effect.succeed([ - new Config.Document({ - type: "document", - path: document, - info: decode({ - plugins: [ - { - package: "../plugin/fixtures/config-promise-plugin.ts", - options: { description: "Loaded from config" }, - }, - ], - }), - }), - ]), + it.live("loads configured Promise plugins with options", () => + withLocation( + { + plugins: [ + "-*", + { + package: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"), + options: { description: "Loaded from config" }, + }, + ], + }, + Effect.gen(function* () { + yield* ready() + const agents = yield* AgentV2.Service + expect(yield* agents.get(AgentV2.ID.make("configured"))).toMatchObject({ + description: "Loaded from config", + mode: "subagent", + }) + }), + ), + ) + + it.live("loads configured Effect plugins with options", () => + withLocation( + { + plugins: [ + "-*", + { + package: path.join(import.meta.dir, "../plugin/fixtures/config-effect-plugin.ts"), + options: { description: "Effect plugin from config" }, + }, + ], + }, + Effect.gen(function* () { + yield* ready() + const agents = yield* AgentV2.Service + expect(yield* agents.get(AgentV2.ID.make("effect-configured"))).toMatchObject({ + description: "Effect plugin from config", + mode: "subagent", + }) + }), + ), + ) + + it.live("ignores invalid packages and continues loading", () => + withLocation( + { + plugins: [ + "-*", + path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"), + path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"), + { + package: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"), + options: { description: "Loaded after invalid plugins" }, + }, + ], + }, + Effect.gen(function* () { + yield* ready() + const agents = yield* AgentV2.Service + expect(yield* agents.get(AgentV2.ID.make("configured"))).toMatchObject({ + description: "Loaded after invalid plugins", + }) + }), + ), + ) + + it.live("loads auto-discovered plugin files and packages", () => + withLocation( + undefined, + Effect.gen(function* () { + yield* ready() + const agents = yield* AgentV2.Service + expect(yield* agents.get(AgentV2.ID.make("directory"))).toMatchObject({ + description: "Loaded from plugin directory", + }) + expect(yield* agents.get(AgentV2.ID.make("folder"))).toMatchObject({ + description: "Loaded from plugin folder", + }) + }), + true, + ), + ) + + it.live("reloads an auto-discovered plugin when its file changes", () => + withLocation( + undefined, + Effect.gen(function* () { + yield* ready() + const agents = yield* AgentV2.Service + const events = yield* EventV2.Service + const location = yield* Location.Service + const plugins = yield* PluginV2.Service + const file = path.join(location.directory, ".opencode", "plugin", "mutable.ts") + const first = (yield* plugins.list()).find((plugin) => plugin.id === "mutable-plugin")?.id + + expect(first).toBeDefined() + expect((yield* agents.get(AgentV2.ID.make("mutable")))?.description).toBe("first") + + yield* Effect.promise(async () => { + await fs.writeFile(file, mutablePlugin("second")) + const modified = new Date(Date.now() + 5_000) + await fs.utimes(file, modified, modified) + }) + yield* events.publish(ConfigSchema.Event.Updated, {}) + yield* waitUntil( + Effect.gen(function* () { + const current = (yield* plugins.list()).find((plugin) => plugin.id === "mutable-plugin")?.id + return current === first && (yield* agents.get(AgentV2.ID.make("mutable")))?.description === "second" }), - ), - ) + ) + }), + false, + async (directory) => { + const plugin = path.join(directory, ".opencode", "plugin") + await fs.mkdir(plugin, { recursive: true }) + await fs.writeFile(path.join(plugin, "mutable.ts"), mutablePlugin("first")) + }, + ), + ) - expect(yield* waitForAgent(agents, "configured")).toMatchObject({ - description: "Loaded from config", - mode: "subagent", - }) + it.live("applies explicit removals after auto-discovery", () => + withLocation( + { plugins: ["-*"] }, + Effect.gen(function* () { + yield* ready() + const agents = yield* AgentV2.Service + expect(yield* agents.get(AgentV2.ID.make("directory"))).toBeUndefined() + expect(yield* agents.get(AgentV2.ID.make("folder"))).toBeUndefined() + }), + true, + ), + ) + + it.live("loads user plugins before internal post plugins", () => + Effect.gen(function* () { + const sdk = yield* SdkPlugins.Service + yield* sdk.register(define({ id: "sdk-order", effect: () => Effect.void })) + yield* withLocation( + { + plugins: [ + path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"), + path.join(import.meta.dir, "../plugin/fixtures/variant-source-plugin.ts"), + ], + }, + Effect.gen(function* () { + yield* ready() + const registry = yield* PluginV2.Service + const ids = (yield* registry.list()).map((plugin) => String(plugin.id)) + expect(ids.indexOf("opencode.agent")).toBeLessThan(ids.indexOf("sdk-order")) + expect(ids.indexOf("sdk-order")).toBeLessThan(ids.indexOf("config-promise-plugin")) + expect(ids.indexOf("config-promise-plugin")).toBeLessThan(ids.indexOf("variant-source")) + expect(ids.indexOf("variant-source")).toBeLessThan(ids.indexOf("opencode.config.provider")) + expect(ids.indexOf("opencode.config.provider")).toBeLessThan(ids.indexOf("opencode.variant")) + + const catalog = yield* Catalog.Service + expect( + (yield* catalog.model.get(ProviderV2.ID.make("configured"), ModelV2.ID.make("glm-5.2")))?.variants, + ).toEqual([ + expect.objectContaining({ id: "high", headers: { custom: "true" } }), + expect.objectContaining({ id: "max", settings: { reasoningEffort: "max" } }), + ]) + }), + ) }), ) - it.live("loads a configured Effect plugin with options", () => - Effect.gen(function* () { - const plugins = yield* PluginV2.Service - const agents = yield* AgentV2.Service - const fs = yield* FSUtil.Service - const location = yield* Location.Service - const npm = yield* Npm.Service - const host = yield* PluginHost.make(plugins) + it.live("allows variant generation to be disabled", () => + withLocation( + { + plugins: [path.join(import.meta.dir, "../plugin/fixtures/variant-source-plugin.ts"), "-opencode.variant"], + }, + Effect.gen(function* () { + yield* ready() + const registry = yield* PluginV2.Service + expect((yield* registry.list()).map((plugin) => String(plugin.id))).not.toContain("opencode.variant") - yield* ConfigExternalPlugin.Plugin.effect(host).pipe( - Effect.provideService(PluginV2.Service, plugins), - Effect.provideService(FSUtil.Service, fs), - Effect.provideService(Location.Service, location), - Effect.provideService(Npm.Service, npm), - Effect.provideService( - Config.Service, - Config.Service.of({ - entries: () => - Effect.succeed([ - new Config.Document({ - type: "document", - path: path.join(import.meta.dir, "opencode.json"), - info: decode({ - plugins: [ - { - package: "../plugin/fixtures/config-effect-plugin.ts", - options: { description: "Effect plugin from config" }, - }, - ], - }), - }), - ]), - }), - ), - ) - - expect(yield* waitForAgent(agents, "effect-configured")).toMatchObject({ - description: "Effect plugin from config", - mode: "subagent", - }) - }), - ) - - it.live("ignores invalid plugins and continues loading", () => - Effect.gen(function* () { - const plugins = yield* PluginV2.Service - const agents = yield* AgentV2.Service - const fs = yield* FSUtil.Service - const location = yield* Location.Service - const npm = yield* Npm.Service - const host = yield* PluginHost.make(plugins) - - yield* ConfigExternalPlugin.Plugin.effect(host).pipe( - Effect.provideService(PluginV2.Service, plugins), - Effect.provideService(FSUtil.Service, fs), - Effect.provideService(Location.Service, location), - Effect.provideService(Npm.Service, npm), - Effect.provideService( - Config.Service, - Config.Service.of({ - entries: () => - Effect.succeed([ - new Config.Document({ - type: "document", - path: path.join(import.meta.dir, "opencode.json"), - info: decode({ - plugins: [ - "../plugin/fixtures/missing-plugin.ts", - "../plugin/fixtures/invalid-plugin.ts", - { - package: "../plugin/fixtures/config-promise-plugin.ts", - options: { description: "Loaded after invalid plugins" }, - }, - ], - }), - }), - ]), - }), - ), - ) - - expect(yield* waitForAgent(agents, "configured")).toMatchObject({ - description: "Loaded after invalid plugins", - }) - }), - ) - - it.live("installs and resolves npm plugin packages", () => - Effect.gen(function* () { - const plugins = yield* PluginV2.Service - const agents = yield* AgentV2.Service - const fs = yield* FSUtil.Service - const location = yield* Location.Service - const host = yield* PluginHost.make(plugins) - let installed: string | undefined - const npm = Npm.Service.of({ - add: (spec) => - Effect.sync(() => { - installed = spec - return { - directory: import.meta.dir, - entrypoint: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"), - } - }), - install: () => Effect.void, - which: () => Effect.succeed(undefined), - }) - - yield* ConfigExternalPlugin.Plugin.effect(host).pipe( - Effect.provideService(PluginV2.Service, plugins), - Effect.provideService(FSUtil.Service, fs), - Effect.provideService(Location.Service, location), - Effect.provideService(Npm.Service, npm), - Effect.provideService( - Config.Service, - Config.Service.of({ - entries: () => - Effect.succeed([ - new Config.Document({ - type: "document", - info: decode({ - plugins: [ - { - package: "example-plugin@1.0.0", - options: { description: "Installed from npm" }, - }, - ], - }), - }), - ]), - }), - ), - ) - - expect(yield* waitForAgent(agents, "configured")).toMatchObject({ - description: "Installed from npm", - }) - expect(installed).toBe("example-plugin@1.0.0") - }), - ) - - it.live("loads plugin files from config directories", () => - Effect.gen(function* () { - const plugins = yield* PluginV2.Service - const agents = yield* AgentV2.Service - const fs = yield* FSUtil.Service - const location = yield* Location.Service - const npm = yield* Npm.Service - const host = yield* PluginHost.make(plugins) - - yield* ConfigExternalPlugin.Plugin.effect(host).pipe( - Effect.provideService(PluginV2.Service, plugins), - Effect.provideService(FSUtil.Service, fs), - Effect.provideService(Location.Service, location), - Effect.provideService(Npm.Service, npm), - Effect.provideService( - Config.Service, - Config.Service.of({ - entries: () => - Effect.succeed([ - new Config.Directory({ - type: "directory", - path: AbsolutePath.make(path.join(import.meta.dir, "fixtures")), - }), - ]), - }), - ), - ) - - expect(yield* waitForAgent(agents, "directory")).toMatchObject({ - description: "Loaded from plugin directory", - mode: "subagent", - }) - expect(yield* waitForAgent(agents, "folder")).toMatchObject({ - description: "Loaded from plugin folder", - mode: "subagent", - }) - }), + const catalog = yield* Catalog.Service + expect( + (yield* catalog.model.get(ProviderV2.ID.make("configured"), ModelV2.ID.make("glm-5.2")))?.variants, + ).toEqual([expect.objectContaining({ id: "high", headers: { custom: "true" } })]) + }), + ), ) }) -const waitForAgent = Effect.fnUntraced(function* (agents: AgentV2.Interface, id: string) { - for (let attempt = 0; attempt < 100; attempt++) { - const agent = yield* agents.get(AgentV2.ID.make(id)) - if (agent) return agent +const ready = Effect.fnUntraced(function* () { + const supervisor = yield* PluginSupervisor.Service + yield* supervisor.ready +}) + +function withLocation( + config: unknown, + effect: Effect.Effect, + fixtures = false, + prepare?: (directory: string) => Promise, +) { + return Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.tap((tmp) => + Effect.promise(async () => { + await prepare?.(tmp.path) + if (fixtures) { + const directory = path.join(tmp.path, ".opencode") + await fs.mkdir(directory, { recursive: true }) + await Promise.all( + ["plugin", "plugins"].map((name) => + fs.symlink(path.join(import.meta.dir, "fixtures", name), path.join(directory, name), "dir"), + ), + ) + } + if (config !== undefined) { + const directory = fixtures ? path.join(tmp.path, ".opencode") : tmp.path + await fs.mkdir(directory, { recursive: true }) + await fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify(config)) + } + }), + ), + Effect.flatMap((tmp) => + effect.pipe( + Effect.scoped, + Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }))), + ), + ), + ) +} + +function mutablePlugin(description: string) { + const plugin = pathToFileURL(path.join(import.meta.dir, "../../../plugin/src/v2/promise/index.ts")).href + return ` +import { define } from ${JSON.stringify(plugin)} + +export default define({ + id: "mutable-plugin", + setup: async (ctx) => { + await ctx.agent.transform((agents) => { + agents.update("mutable", (agent) => { + agent.description = ${JSON.stringify(description)} + agent.mode = "subagent" + }) + }) + }, +}) +` +} + +const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect) { + for (let attempt = 0; attempt < 200; attempt++) { + if (yield* condition) return yield* Effect.sleep("10 millis") } - return yield* Effect.die(`Timed out waiting for agent ${id}`) + return yield* Effect.die("Timed out waiting for plugin reload") }) diff --git a/packages/core/test/config/reload.test.ts b/packages/core/test/config/reload.test.ts new file mode 100644 index 0000000000..e5f8bd10fc --- /dev/null +++ b/packages/core/test/config/reload.test.ts @@ -0,0 +1,108 @@ +import path from "path" +import { describe, expect } from "bun:test" +import { Config as ConfigSchema } from "@opencode-ai/schema/config" +import { AgentV2 } from "@opencode-ai/core/agent" +import { Catalog } from "@opencode-ai/core/catalog" +import { CommandV2 } from "@opencode-ai/core/command" +import { Config } from "@opencode-ai/core/config" +import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent" +import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command" +import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider" +import { ConfigReferencePlugin } from "@opencode-ai/core/config/plugin/reference" +import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill" +import { EventV2 } from "@opencode-ai/core/event" +import { Global } from "@opencode-ai/core/global" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { Reference } from "@opencode-ai/core/reference" +import { SkillV2 } from "@opencode-ai/core/skill" +import { Effect, Schema } from "effect" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "../plugin/fixture" + +const it = testEffect(PluginTestLayer) +const decode = Schema.decodeUnknownSync(Config.Info) +const document = path.join(import.meta.dir, "opencode.json") + +describe("config plugin reloads", () => { + it.live("reloads config-backed domains without reloading external plugins", () => + Effect.gen(function* () { + const agents = yield* AgentV2.Service + const catalog = yield* Catalog.Service + const commands = yield* CommandV2.Service + const events = yield* EventV2.Service + const plugins = yield* PluginV2.Service + const references = yield* Reference.Service + const skills = yield* SkillV2.Service + const host = yield* PluginHost.make(plugins) + let entries: Config.Entry[] = [config("first")] + const service = Config.Service.of({ entries: () => Effect.sync(() => entries) }) + const setup = (effect: Effect.Effect) => + effect.pipe(Effect.provideService(Config.Service, service)) + + yield* setup(ConfigAgentPlugin.Plugin.effect(host)) + yield* setup(ConfigCommandPlugin.Plugin.effect(host)) + yield* setup(ConfigSkillPlugin.Plugin.effect(host)) + yield* setup(ConfigReferencePlugin.Plugin.effect(host)) + yield* setup(ConfigProviderPlugin.Plugin.effect(host)) + + expect((yield* agents.get(AgentV2.ID.make("first")))?.description).toBe("First agent") + expect((yield* commands.get("first"))?.description).toBe("First command") + expect( + (yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"), + ).toBe(true) + expect((yield* references.list()).map((reference) => reference.name)).toEqual(["first"]) + expect(yield* catalog.provider.get(ProviderV2.ID.make("first"))).toBeDefined() + + entries = [config("second")] + yield* events.publish(ConfigSchema.Event.Updated, {}) + yield* waitUntil( + Effect.gen(function* () { + return ( + (yield* agents.get(AgentV2.ID.make("first"))) === undefined && + (yield* agents.get(AgentV2.ID.make("second")))?.description === "Second agent" && + (yield* commands.get("first")) === undefined && + (yield* commands.get("second"))?.description === "Second command" && + (yield* references.list()).some((reference) => reference.name === "second") && + (yield* catalog.provider.get(ProviderV2.ID.make("first"))) === undefined && + (yield* catalog.provider.get(ProviderV2.ID.make("second"))) !== undefined + ) + }), + ) + + expect( + (yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"), + ).toBe(false) + expect( + (yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/second"), + ).toBe(true) + }).pipe(Effect.provideService(Global.Service, Global.Service.of(Global.make()))), + ) +}) + +function config(name: string) { + return new Config.Document({ + type: "document", + path: document, + info: decode({ + agents: { [name]: { description: `${title(name)} agent`, mode: "subagent" } }, + commands: { [name]: { template: `${title(name)} command`, description: `${title(name)} command` } }, + skills: [`/skills/${name}`], + references: { [name]: `/references/${name}` }, + providers: { [name]: { models: { chat: { name: `${title(name)} model` } } } }, + }), + }) +} + +function title(value: string) { + return value.charAt(0).toUpperCase() + value.slice(1) +} + +const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect) { + for (let attempt = 0; attempt < 100; attempt++) { + if (yield* condition) return + yield* Effect.sleep("10 millis") + } + return yield* Effect.die("Timed out waiting for config plugin reloads") +}) diff --git a/packages/core/test/config/skill.test.ts b/packages/core/test/config/skill.test.ts index 0dc5e3b4d7..28ae7956dc 100644 --- a/packages/core/test/config/skill.test.ts +++ b/packages/core/test/config/skill.test.ts @@ -36,7 +36,7 @@ describe("ConfigSkillPlugin.Plugin", () => { yield* ConfigSkillPlugin.Plugin.effect( host({ - skill: { transform, reload: () => Effect.void }, + skill: { list: () => Effect.die("unused skill.list"), transform, reload: () => Effect.void }, }), ).pipe( Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home: "/home/test" })), diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index b381cc7418..b36de1abac 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -255,7 +255,7 @@ describe("DatabaseMigration", () => { ) yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 9)`) yield* db.run( - sql`INSERT INTO event (id, aggregate_id, seq, type, data) VALUES ('event', 'session', 9, 'session.updated.1', '{}')`, + sql`INSERT INTO event (id, aggregate_id, seq, type, data, created) VALUES ('event', 'session', 9, 'session.updated.1', '{}', 1)`, ) yield* db.run( sql`INSERT INTO session_input (id, session_id, prompt, delivery, admitted_seq, time_created) VALUES ('input', 'session', '{}', 'steer', 9, 1)`, diff --git a/packages/core/test/effect/layer-node/node-build.test.ts b/packages/core/test/effect/layer-node/node-build.test.ts index e9ff2fc149..32c9988f16 100644 --- a/packages/core/test/effect/layer-node/node-build.test.ts +++ b/packages/core/test/effect/layer-node/node-build.test.ts @@ -77,6 +77,7 @@ describe("node build", () => { Effect.sync(() => { acquisitions++ return Project.Service.of({ + list: () => Effect.succeed([]), directories: () => Effect.succeed([]), resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }), commit: () => Effect.void, diff --git a/packages/core/test/effect/observability.test.ts b/packages/core/test/effect/observability.test.ts index 4758563f28..c075986364 100644 --- a/packages/core/test/effect/observability.test.ts +++ b/packages/core/test/effect/observability.test.ts @@ -52,6 +52,42 @@ describe("resource", () => { }) }) +test("falls back to local logging when OTLP initialization fails", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-observability-test-")) + await using _ = { + async [Symbol.asyncDispose]() { + await fs.rm(dir, { recursive: true, force: true }) + }, + } + const child = Bun.spawn( + [ + process.execPath, + "--eval", + ` + import { Effect } from "effect" + import { Observability } from "./src/observability.ts" + await Effect.void.pipe(Effect.provide(Observability.layer), Effect.scoped, Effect.runPromise) + `, + ], + { + cwd: path.join(import.meta.dir, "../.."), + env: { + ...process.env, + OTEL_EXPORTER_OTLP_ENDPOINT: "://invalid", + XDG_CACHE_HOME: path.join(dir, "cache"), + XDG_CONFIG_HOME: path.join(dir, "config"), + XDG_DATA_HOME: path.join(dir, "data"), + XDG_STATE_HOME: path.join(dir, "state"), + }, + stdout: "ignore", + stderr: "pipe", + }, + ) + const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]) + + expect({ exitCode, stderr }).toEqual({ exitCode: 0, stderr: "" }) +}) + test("file logger appends concurrent runs with a run on every line", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-log-test-")) await using _ = { diff --git a/packages/core/test/event-logger.test.ts b/packages/core/test/event-logger.test.ts new file mode 100644 index 0000000000..ef48d7abbd --- /dev/null +++ b/packages/core/test/event-logger.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Layer, Logger } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Database } from "@opencode-ai/core/database/database" +import { EventV2 } from "@opencode-ai/core/event" +import { EventLogger } from "@opencode-ai/core/event-logger" +import { Agent } from "@opencode-ai/schema/agent" +import { Catalog } from "@opencode-ai/schema/catalog" +import { Command } from "@opencode-ai/schema/command" +import { Config } from "@opencode-ai/schema/config" +import { McpEvent } from "@opencode-ai/schema/mcp-event" + +const UnlistedUpdated = EventV2.ephemeral({ type: "test.updated", schema: {} }) + +describe("EventLogger", () => { + test("logs explicitly listed updated events", async () => { + const output = new Array>() + const logger = Logger.map(Logger.formatStructured, (entry) => { + output.push(entry) + }) + + await Effect.gen(function* () { + const events = yield* EventV2.Service + yield* events.publish(Agent.Event.Updated, {}) + yield* events.publish(Catalog.Event.Updated, {}) + yield* events.publish(Command.Event.Updated, {}) + yield* events.publish(Config.Event.Updated, {}) + yield* events.publish(McpEvent.StatusChanged, { server: "example" }) + yield* events.publish(UnlistedUpdated, {}) + }).pipe( + Effect.provide(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, EventLogger.node]))), + Effect.provide(Logger.layer([logger])), + Effect.scoped, + Effect.runPromise, + ) + + expect(output.map((entry) => entry.message)).toEqual([ + ["event", { event: expect.objectContaining({ type: "agent.updated" }) }], + ["event", { event: expect.objectContaining({ type: "catalog.updated" }) }], + ["event", { event: expect.objectContaining({ type: "command.updated" }) }], + ["event", { event: expect.objectContaining({ type: "config.updated" }) }], + ]) + }) +}) diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index b40f088c5d..70a8083d16 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -2,6 +2,8 @@ import { describe, expect } from "bun:test" import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Ref, Schema, Stream } from "effect" import { EventV2 } from "@opencode-ai/core/event" import { Event } from "@opencode-ai/schema/event" +import { EventManifest } from "@opencode-ai/schema/event-manifest" +import { McpEvent } from "@opencode-ai/schema/mcp-event" import { Session } from "@opencode-ai/schema/session" import { SessionEvent } from "@opencode-ai/schema/session-event" import { SessionV1 } from "@opencode-ai/schema/session-v1" @@ -22,14 +24,14 @@ const locationLayer = Layer.succeed( location({ directory: AbsolutePath.make("project"), workspaceID: WorkspaceV2.ID.make("wrk_test") }), ), ) -const Message = EventV2.define({ +const Message = EventV2.ephemeral({ type: "test.message", schema: { text: Schema.String, }, }) -const SyncMessage = EventV2.define({ +const SyncMessage = EventV2.durable({ type: "test.sync", durable: { version: 1, @@ -41,7 +43,7 @@ const SyncMessage = EventV2.define({ }, }) -const SyncSent = EventV2.define({ +const SyncSent = EventV2.durable({ type: "test.sent", durable: { version: 1, @@ -53,14 +55,14 @@ const SyncSent = EventV2.define({ }, }) -const GlobalMessage = EventV2.define({ +const GlobalMessage = EventV2.ephemeral({ type: "test.global", schema: { text: Schema.String, }, }) -const VersionedMessage = EventV2.define({ +const VersionedMessage = EventV2.durable({ type: "test.versioned", durable: { version: 2, @@ -129,12 +131,12 @@ describe("EventV2", () => { it.effect("selects the latest durable definition independent of declaration order", () => Effect.sync(() => { - const latest = EventV2.define({ + const latest = EventV2.durable({ type: "test.out-of-order", durable: { version: 2, aggregate: "id" }, schema: { id: Schema.String }, }) - const historical = EventV2.define({ + const historical = EventV2.durable({ type: "test.out-of-order", durable: { version: 1, aggregate: "id" }, schema: { id: Schema.String }, @@ -329,8 +331,8 @@ describe("EventV2", () => { const events = yield* EventV2.Service const consuming = yield* Deferred.make() const release = yield* Deferred.make() - const slowStream = yield* EventV2.liveBounded(events, 1) - const fastStream = yield* EventV2.liveBounded(events, 8) + const slowStream = yield* EventV2.liveBounded(events, { capacity: 1 }) + const fastStream = yield* EventV2.liveBounded(events, { capacity: 8 }) const slow = yield* slowStream.pipe( Stream.runForEach(() => Deferred.succeed(consuming, undefined).pipe(Effect.andThen(Deferred.await(release)))), Effect.forkScoped, @@ -355,6 +357,20 @@ describe("EventV2", () => { }), ) + it.effect("filters internal events before they enter a bounded server stream", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const stream = yield* EventV2.liveBounded(events, { capacity: 1, accept: EventManifest.isServer }) + const received = yield* stream.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + + yield* events.publish(McpEvent.ToolsChanged, { server: "one" }) + yield* events.publish(McpEvent.ToolsChanged, { server: "two" }) + const published = yield* events.publish(McpEvent.StatusChanged, { server: "example" }) + + expect(Array.from(yield* Fiber.join(received))).toEqual([published]) + }), + ) + it.effect("preserves observer interruption", () => Effect.gen(function* () { const events = yield* EventV2.Service @@ -554,6 +570,7 @@ describe("EventV2", () => { yield* events.replay({ id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, @@ -573,6 +590,7 @@ describe("EventV2", () => { yield* events.replay({ id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, @@ -609,6 +627,7 @@ describe("EventV2", () => { const exit = yield* events .replay({ id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID: envelopeAggregateID, @@ -642,6 +661,7 @@ describe("EventV2", () => { yield* events.replay({ id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, @@ -650,6 +670,7 @@ describe("EventV2", () => { const exit = yield* events .replay({ id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 5, aggregateID, @@ -674,13 +695,14 @@ describe("EventV2", () => { yield* events.replay({ id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(SessionEvent.ContextUpdated.type, 1), seq: 0, aggregateID, - data: { sessionID: aggregateID, messageID: "msg_context", timestamp: 0, text: "context" }, + data: { sessionID: aggregateID, text: "context" }, }) - expect(received[0]?.data.timestamp).toEqual(DateTime.makeUnsafe(0)) + expect(received[0]?.created).toEqual(DateTime.makeUnsafe(0)) }), ) @@ -690,6 +712,7 @@ describe("EventV2", () => { const exit = yield* events .replay({ id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: "unknown.event.1", seq: 0, aggregateID: EventV2.ID.create(), @@ -708,6 +731,7 @@ describe("EventV2", () => { const source = yield* events.replayAll([ { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, @@ -715,6 +739,7 @@ describe("EventV2", () => { }, { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, @@ -735,6 +760,7 @@ describe("EventV2", () => { const one = yield* events.replayAll([ { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, @@ -742,6 +768,7 @@ describe("EventV2", () => { }, { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, @@ -751,6 +778,7 @@ describe("EventV2", () => { const two = yield* events.replayAll([ { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 2, aggregateID, @@ -758,6 +786,7 @@ describe("EventV2", () => { }, { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 3, aggregateID, @@ -793,6 +822,7 @@ describe("EventV2", () => { yield* events.replay( { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, @@ -812,6 +842,7 @@ describe("EventV2", () => { const id = EventV2.ID.create() const replayed = { id, + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, @@ -833,6 +864,7 @@ describe("EventV2", () => { const published = yield* events.publish(DurableMessage, durableData(aggregateID, "owned")) const replayed = { id: published.id, + created: published.created, type: EventV2.versionedType(DurableMessage.type, 1), seq: published.durable!.seq, aggregateID, @@ -867,6 +899,7 @@ describe("EventV2", () => { yield* events.replay( { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, @@ -895,6 +928,7 @@ describe("EventV2", () => { yield* events.replay( { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, @@ -905,6 +939,7 @@ describe("EventV2", () => { yield* events.replay( { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 2, aggregateID, @@ -937,6 +972,7 @@ describe("EventV2", () => { yield* events.replay( { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, @@ -949,6 +985,7 @@ describe("EventV2", () => { .replay( { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, @@ -970,6 +1007,7 @@ describe("EventV2", () => { yield* events.listen((event) => Effect.sync(() => received.push(event))) const replayed = { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, @@ -990,6 +1028,7 @@ describe("EventV2", () => { const aggregateID = Session.ID.create() const replayed = { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, @@ -1014,6 +1053,7 @@ describe("EventV2", () => { const id = EventV2.ID.create() yield* events.replay({ id, + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, @@ -1023,6 +1063,7 @@ describe("EventV2", () => { const exit = yield* events .replay({ id, + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, @@ -1045,6 +1086,7 @@ describe("EventV2", () => { yield* events.replay( { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, @@ -1055,6 +1097,7 @@ describe("EventV2", () => { yield* events.replay( { id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, @@ -1116,6 +1159,7 @@ describe("EventV2", () => { yield* events.replay({ id: EventV2.ID.create(), + created: DateTime.makeUnsafe(0), type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, diff --git a/packages/core/test/filesystem/ignore.test.ts b/packages/core/test/filesystem/ignore.test.ts index 87b07eacb9..d734ed16b2 100644 --- a/packages/core/test/filesystem/ignore.test.ts +++ b/packages/core/test/filesystem/ignore.test.ts @@ -1,5 +1,7 @@ import { expect, test } from "bun:test" import { Ignore } from "@opencode-ai/core/filesystem/ignore" +// @ts-ignore +import { createWrapper } from "@parcel/watcher/wrapper" test("match nested and non-nested", () => { expect(Ignore.match("node_modules/index.js")).toBe(true) @@ -8,3 +10,30 @@ test("match nested and non-nested", () => { expect(Ignore.match("node_modules/bar")).toBe(true) expect(Ignore.match("node_modules/bar/")).toBe(true) }) + +test("parcel patterns ignore built-in folders at any depth", async () => { + let ignoreGlobs: string[] = [] + const watcher = createWrapper({ + subscribe: async ( + _directory: string, + _callback: (...args: unknown[]) => unknown, + options: { ignoreGlobs?: string[] }, + ) => { + ignoreGlobs = options.ignoreGlobs ?? [] + }, + }) + await watcher.subscribe("/tmp/project", () => {}, { ignore: Ignore.PATTERNS }) + const patterns = ignoreGlobs.map((source) => new RegExp(source)) + + for (const path of [ + "nested/node_modules", + "nested/node_modules/package/index.js", + "nested/.git", + "nested/.git/HEAD", + "nested/dist", + "nested/dist/index.js", + ]) { + expect(patterns.some((pattern) => pattern.test(path))).toBe(true) + } + expect(patterns.some((pattern) => pattern.test("nested/src/index.ts"))).toBe(false) +}) diff --git a/packages/core/test/filesystem/watcher.test.ts b/packages/core/test/filesystem/watcher.test.ts index 2139520468..360c4c70db 100644 --- a/packages/core/test/filesystem/watcher.test.ts +++ b/packages/core/test/filesystem/watcher.test.ts @@ -2,13 +2,15 @@ import { $ } from "bun" import { describe, expect } from "bun:test" import fs from "fs/promises" import path from "path" -import { Deferred, Duration, Effect, Fiber, Layer, Option, Stream } from "effect" +import { Deferred, Duration, Effect, Fiber, Layer, Option, Schedule, Stream } from "effect" import { Config } from "@opencode-ai/core/config" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { FSUtil } from "@opencode-ai/core/fs-util" +import { LocationWatcher } from "@opencode-ai/core/filesystem/location-watcher" import { Watcher } from "@opencode-ai/core/filesystem/watcher" +import { FileSystem } from "@opencode-ai/schema/filesystem" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" @@ -34,7 +36,7 @@ function provide(directory: string, vcs?: Location.Interface["vcs"]) { Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })), ) return Effect.provide( - AppNodeBuilder.build(Watcher.node, [ + AppNodeBuilder.build(LocationWatcher.node, [ [Config.node, configLayer], [Location.node, locationLayer], ]), @@ -66,7 +68,7 @@ function wait(check: (event: WatcherEvent) => boolean) { return Effect.gen(function* () { const events = yield* EventV2.Service const deferred = yield* Deferred.make() - const fiber = yield* events.subscribe(Watcher.Event.Updated).pipe( + const fiber = yield* events.subscribe(FileSystem.Event.Changed).pipe( Stream.runForEach((event) => { if (!check(event.data)) return Effect.void return Deferred.succeed(deferred, event.data).pipe(Effect.asVoid) @@ -136,7 +138,29 @@ function ready(directory: string) { }) } -describeWatcher("Watcher", () => { +describeWatcher("LocationWatcher", () => { + it.live("limits file watches to the exact target", () => + withTmp((directory) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const watcher = yield* Watcher.Service + const target = path.join(directory, "opencode.json") + const sibling = path.join(directory, "other.json") + const update = yield* watcher + .subscribe({ path: target, type: "file" }) + .pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true })) + yield* fs.writeFileString(sibling, "sibling") + const writes = yield* Effect.suspend(() => fs.writeFileString(target, `target-${Math.random()}`)).pipe( + Effect.repeat(Schedule.spaced("10 millis")), + Effect.forkScoped, + ) + const event = yield* Fiber.join(update).pipe(Effect.ensuring(Fiber.interrupt(writes))) + + expect(event.valueOrUndefined?.path).toBe(target) + }).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))), + ), + ) + it.live("publishes root create, update, and delete events", () => withTmp( (directory) => @@ -175,6 +199,24 @@ describeWatcher("Watcher", () => { ), ) + it.live("ignores dependency, VCS, and build directories at any depth", () => + withTmp((directory) => + Effect.gen(function* () { + const afs = yield* FSUtil.Service + yield* ready(directory) + const roots = ["node_modules", ".git", "dist"].map((name) => path.join(directory, "nested", name)) + const files = roots.map((root) => path.join(root, "package", "index.js")) + yield* noUpdate( + (event) => roots.some((root) => event.file === root || event.file.startsWith(`${root}${path.sep}`)), + Effect.forEach(files, (file) => afs.writeWithDirs(file, "ignored"), { + concurrency: "unbounded", + discard: true, + }), + ) + }), + ), + ) + it.live("cleanup stops publishing events", () => Effect.gen(function* () { const events = yield* EventV2.Service diff --git a/packages/core/test/fixture/mcp-output-schema.ts b/packages/core/test/fixture/mcp-output-schema.ts new file mode 100644 index 0000000000..cb853be002 --- /dev/null +++ b/packages/core/test/fixture/mcp-output-schema.ts @@ -0,0 +1,40 @@ +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" +import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" + +const server = new Server({ name: "output-schema", version: "1.0.0" }, { capabilities: { tools: {} } }) + +server.setRequestHandler(ListToolsRequestSchema, ({ params }) => + Promise.resolve( + params?.cursor === "page-2" + ? { + tools: [ + { + name: "second", + inputSchema: { type: "object" }, + outputSchema: { + type: "object", + properties: { value: { type: "number" } }, + required: ["value"], + }, + }, + ], + } + : { + tools: [ + { + name: "first", + inputSchema: { type: "object" }, + outputSchema: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + }, + }, + ], + nextCursor: "page-2", + }, + ), +) + +await server.connect(new StdioServerTransport()) diff --git a/packages/core/test/form.test.ts b/packages/core/test/form.test.ts index 7ea351df3a..8defff611f 100644 --- a/packages/core/test/form.test.ts +++ b/packages/core/test/form.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { Effect, Exit } from "effect" +import { Deferred, Effect, Exit, Fiber } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" @@ -19,6 +19,27 @@ const input = { } satisfies Form.CreateInput describe("Form", () => { + it.effect("returns a terminal cancelled state from ask", () => + Effect.gen(function* () { + const service = yield* Form.Service + const events = yield* EventV2.Service + const created = yield* Deferred.make() + const unsubscribe = yield* events.listen((event) => + event.type === Form.Event.Created.type + ? Deferred.succeed(created, (event.data as { readonly form: Form.Info }).form).pipe(Effect.asVoid) + : Effect.void, + ) + yield* Effect.addFinalizer(() => unsubscribe) + const fiber = yield* service.ask(input).pipe(Effect.forkScoped) + const form = yield* Deferred.await(created) + + yield* service.cancel(form.id) + + expect(yield* Fiber.join(fiber)).toEqual({ status: "cancelled" }) + expect(yield* service.state(form.id)).toEqual({ status: "cancelled" }) + }), + ) + it.effect("supports the temporary global mcp elicitation owner", () => Effect.gen(function* () { const service = yield* Form.Service @@ -92,6 +113,79 @@ describe("Form", () => { }), ) + it.effect("requires every when condition to match and treats empty when as active", () => + Effect.gen(function* () { + const service = yield* Form.Service + const created = yield* service.create({ + sessionID: "global", + mode: "form", + fields: [ + { key: "a", type: "boolean" }, + { key: "b", type: "boolean" }, + { + key: "x", + type: "string", + required: true, + when: [ + { key: "a", op: "eq", value: true }, + { key: "b", op: "eq", value: true }, + ], + }, + { key: "z", type: "string", required: true, when: [] }, + ], + }) + + const missingX = yield* service.reply({ id: created.id, answer: { a: true, b: true, z: "ok" } }).pipe(Effect.flip) + expect(missingX).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: x" })) + + const inactiveX = yield* service + .reply({ id: created.id, answer: { a: true, b: false, x: "nope", z: "ok" } }) + .pipe(Effect.flip) + expect(inactiveX).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: x" })) + + const missingZ = yield* service.reply({ id: created.id, answer: { a: true, b: false } }).pipe(Effect.flip) + expect(missingZ).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: z" })) + + yield* service.reply({ id: created.id, answer: { a: true, b: false, z: "ok" } }) + expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { a: true, b: false, z: "ok" } }) + }), + ) + + it.effect("evaluates neq against multiselect answers as non-inclusion", () => + Effect.gen(function* () { + const service = yield* Form.Service + const options = [ + { value: "go", label: "Go" }, + { value: "ts", label: "TypeScript" }, + ] + const created = yield* service.create({ + sessionID: "global", + mode: "form", + fields: [ + { key: "langs", type: "multiselect", options }, + { key: "note", type: "string", required: true, when: [{ key: "langs", op: "neq", value: "go" }] }, + ], + }) + + const missing = yield* service.reply({ id: created.id, answer: { langs: ["ts"] } }).pipe(Effect.flip) + expect(missing).toEqual( + new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: note" }), + ) + + // an answered-but-empty multiselect also satisfies neq + const missingEmpty = yield* service.reply({ id: created.id, answer: { langs: [] } }).pipe(Effect.flip) + expect(missingEmpty).toEqual( + new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: note" }), + ) + + const inactive = yield* service.reply({ id: created.id, answer: { langs: ["go"], note: "x" } }).pipe(Effect.flip) + expect(inactive).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: note" })) + + yield* service.reply({ id: created.id, answer: { langs: ["go"] } }) + expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { langs: ["go"] } }) + }), + ) + it.effect("treats unanswered when references as false and cascades inactivity", () => Effect.gen(function* () { const service = yield* Form.Service diff --git a/packages/core/test/lib/tool.ts b/packages/core/test/lib/tool.ts index fa625a591b..74c1b710ab 100644 --- a/packages/core/test/lib/tool.ts +++ b/packages/core/test/lib/tool.ts @@ -2,7 +2,9 @@ import { AgentV2 } from "@opencode-ai/core/agent" import type { PermissionV2 } from "@opencode-ai/core/permission" import { SessionMessage } from "@opencode-ai/core/session/message" import { ToolRegistry } from "@opencode-ai/core/tool/registry" -import { Effect } from "effect" +import { Tools } from "@opencode-ai/core/tool/tools" +import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import { Effect, type Scope } from "effect" export const toolIdentity = { agent: AgentV2.ID.make("build"), @@ -34,6 +36,29 @@ export function waitForTool( }) } +/** + * Registers a core tool plugin's tools against the real registry without booting the + * full plugin host. Only the tool domain is live; focused tool tests exercise + * registration, materialization, and settlement through the same path production uses. + */ +export const registerToolPlugin = (plugin: { + readonly id: string + readonly effect: (context: PluginContext) => Effect.Effect +}): Effect.Effect => + Effect.gen(function* () { + const tools = yield* Tools.Service + const context: Pick = { + tool: { + register: tools.register, + execute: { + before: () => Effect.die("registerToolPlugin does not support tool hooks"), + after: () => Effect.die("registerToolPlugin does not support tool hooks"), + }, + }, + } + yield* plugin.effect(context as PluginContext) + }) + export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput, model = testModel) => registry.materialize({ model }).pipe(Effect.flatMap((materialized) => materialized.settle(input))) diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index a6a2c18fa8..f0bef9fd32 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -1,7 +1,9 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { DateTime, Effect, Equal, Hash, Schema } from "effect" +import { Config } from "@opencode-ai/schema/config" +import { Plugin } from "@opencode-ai/schema/plugin" +import { Context, DateTime, Effect, Equal, Hash, Schema, Stream } from "effect" import { define } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" @@ -10,6 +12,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LocationServiceMap } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" import { ModelV2 } from "@opencode-ai/core/model" import { ProjectV2 } from "@opencode-ai/core/project" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -27,6 +30,124 @@ import { ToolRegistry } from "../src/tool/registry" const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node]))) describe("LocationServiceMap", () => { + it.live("applies ordered plugin config operations during boot", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + fs.writeFile(path.join(dir.path, "opencode.json"), JSON.stringify({ plugins: ["-*", "opencode.agent"] })), + ) + const plugins = yield* Effect.gen(function* () { + const plugins = yield* PluginV2.Service + yield* (yield* PluginSupervisor.Service).ready + return yield* plugins.list() + }).pipe( + Effect.scoped, + Effect.provide( + LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })), + ), + ) + + expect(plugins.map((plugin) => plugin.id)).toEqual([Plugin.ID.make("opencode.agent")]) + }), + ), + ), + ) + + it.live("reloads the plugin generation after config updates", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const file = path.join(dir.path, "opencode.json") + yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.agent"] }))) + yield* Effect.gen(function* () { + const registry = yield* PluginV2.Service + const supervisor = yield* PluginSupervisor.Service + yield* supervisor.ready + expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"]) + + yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.command"] }))) + for (let attempt = 0; attempt < 100; attempt++) { + if ((yield* registry.list()).some((plugin) => plugin.id === "opencode.command")) break + yield* Effect.sleep("20 millis") + } + + expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.command"]) + + yield* Effect.promise(() => + fs.writeFile( + file, + JSON.stringify({ + plugins: ["-*", path.join(import.meta.dir, "plugin/fixtures/failing-plugin.ts")], + }), + ), + ) + for (let attempt = 0; attempt < 100; attempt++) { + if ((yield* registry.list()).length === 0) break + yield* Effect.sleep("20 millis") + } + expect(yield* registry.list()).toEqual([]) + + yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.agent"] }))) + for (let attempt = 0; attempt < 100; attempt++) { + if ((yield* registry.list()).some((plugin) => plugin.id === "opencode.agent")) break + yield* Effect.sleep("20 millis") + } + expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"]) + }).pipe( + Effect.scoped, + Effect.provide( + LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })), + ), + ) + }), + ), + ), + ) + + it.live("routes located events only to their location", () => + Effect.acquireRelease( + Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), + (dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)), + ).pipe( + Effect.flatMap(([first, second]) => + Effect.scoped( + Effect.gen(function* () { + const locations = yield* LocationServiceMap.Service + const events = yield* EventV2.Service + const firstRef = Location.Ref.make({ directory: AbsolutePath.make(first.path) }) + const secondRef = Location.Ref.make({ directory: AbsolutePath.make(second.path) }) + const firstContext = yield* locations.contextEffect(firstRef) + const secondContext = yield* locations.contextEffect(secondRef) + const received = { first: 0, second: 0 } + yield* events.subscribe(Config.Event.Updated).pipe( + Stream.runForEach(() => Effect.sync(() => received.first++)), + Effect.provideContext(firstContext), + Effect.forkScoped({ startImmediately: true }), + ) + yield* events.subscribe(Config.Event.Updated).pipe( + Stream.runForEach(() => Effect.sync(() => received.second++)), + Effect.provideContext(secondContext), + Effect.forkScoped({ startImmediately: true }), + ) + yield* Effect.sleep("10 millis") + + yield* events.publish(Config.Event.Updated, {}, { location: firstRef }) + yield* Effect.sleep("10 millis") + + expect(received).toEqual({ first: 1, second: 0 }) + }), + ), + ), + ), + ) + it.live("reuses cached services for constructed and decoded location refs", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), @@ -51,31 +172,38 @@ describe("LocationServiceMap", () => { ), ) - it.live("isolates location state while sharing location policy with catalog", () => + it.live("isolates catalog state by location", () => Effect.acquireRelease( Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), (dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)), ).pipe( Effect.flatMap(([blocked, allowed]) => Effect.gen(function* () { - yield* Effect.promise(() => - fs.writeFile( - path.join(blocked.path, "opencode.json"), - JSON.stringify({ - experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "test" }] }, - }), - ), - ) - - const update = (directory: string) => + const update = (directory: string, providerID: ProviderV2.ID) => Effect.gen(function* () { yield* Reference.Service const catalog = yield* Catalog.Service - yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) + yield* catalog.transform((editor) => editor.provider.update(providerID, () => {})) const registry = yield* ToolRegistry.Service - yield* waitForTool(registry, "glob") - yield* waitForTool(registry, "shell") - yield* waitForTool(registry, "subagent") + // Tool plugins register during the forked PluginSupervisor boot; wait for + // every expected tool rather than relying on batch ordering. + yield* Effect.forEach( + [ + "edit", + "glob", + "grep", + "question", + "read", + "shell", + "skill", + "subagent", + "todowrite", + "webfetch", + "websearch", + "write", + ], + (name) => waitForTool(registry, name), + ) return { providers: yield* catalog.provider.all(), tools: yield* toolDefinitions(registry), @@ -87,8 +215,11 @@ describe("LocationServiceMap", () => { ), ) - const blockedState = yield* update(blocked.path) - expect(blockedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(false) + const blockedID = ProviderV2.ID.make("blocked-location") + const allowedID = ProviderV2.ID.make("allowed-location") + const blockedState = yield* update(blocked.path, blockedID) + expect(blockedState.providers.some((provider) => provider.id === blockedID)).toBe(true) + expect(blockedState.providers.some((provider) => provider.id === allowedID)).toBe(false) expect(blockedState.tools.map((tool) => tool.name).sort()).toEqual([ "edit", "glob", @@ -103,8 +234,9 @@ describe("LocationServiceMap", () => { "websearch", "write", ]) - const allowedState = yield* update(allowed.path) - expect(allowedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(true) + const allowedState = yield* update(allowed.path, allowedID) + expect(allowedState.providers.some((provider) => provider.id === allowedID)).toBe(true) + expect(allowedState.providers.some((provider) => provider.id === blockedID)).toBe(false) expect(allowedState.tools.map((tool) => tool.name).sort()).toEqual([ "edit", "glob", @@ -246,7 +378,7 @@ describe("LocationServiceMap", () => { }) .pipe(Effect.asVoid), }) - yield* plugins.add(PluginV2.ID.make(reviewer.id), reviewer.effect) + yield* plugins.activate([{ plugin: reviewer }]) expect(yield* (yield* AgentV2.Service).get(AgentV2.ID.make("reviewer"))).toMatchObject({ description: "Reviews code", diff --git a/packages/core/test/location.test.ts b/packages/core/test/location.test.ts index 77e9e96745..d012a0463f 100644 --- a/packages/core/test/location.test.ts +++ b/packages/core/test/location.test.ts @@ -12,6 +12,7 @@ const ref = { directory: AbsolutePath.make("/repo/packages/app"), workspaceID } const projectLayer = Layer.succeed( Project.Service, Project.Service.of({ + list: () => Effect.succeed([]), directories: () => Effect.succeed([]), resolve: () => Effect.succeed({ diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts new file mode 100644 index 0000000000..e40e8dfb2c --- /dev/null +++ b/packages/core/test/mcp.test.ts @@ -0,0 +1,255 @@ +import path from "node:path" +import { describe, expect, test } from "bun:test" +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" +import { ConfigMCP } from "@opencode-ai/core/config/mcp" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { EventV2 } from "@opencode-ai/core/event" +import { MCP } from "@opencode-ai/core/mcp/index" +import { MCPClient } from "@opencode-ai/core/mcp/client" +import { PermissionV2 } from "@opencode-ai/core/permission" +import { SessionV2 } from "@opencode-ai/core/session" +import { McpTool } from "@opencode-ai/core/tool/mcp" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import { Deferred, Effect, Fiber, Layer, Stream } from "effect" +import { testEffect } from "./lib/effect" +import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool" + +let assertion: Deferred.Deferred | undefined +let decision: Effect.Effect = Effect.void +let calls = 0 + +const mcp = Layer.mock(MCP.Service, { + tools: () => + Effect.succeed([ + new MCP.Tool({ + server: MCP.ServerName.make("demo"), + name: "search", + description: "Search", + inputSchema: { type: "object", properties: {} }, + outputSchema: { + type: "object", + properties: { ok: { type: "boolean" } }, + required: ["ok"], + }, + }), + ]), + callTool: (input) => + Effect.sync(() => { + calls += 1 + return new MCP.ToolResult({ + server: MCP.ServerName.make(input.server), + tool: input.name, + isError: false, + structured: { ok: true }, + content: [], + }) + }), +}) +const permissions = Layer.mock(PermissionV2.Service, { + assert: (input) => + Effect.gen(function* () { + if (!assertion) return yield* Effect.die("Permission test is not initialized") + yield* Deferred.succeed(assertion, input) + yield* decision + }), +}) +const events = Layer.mock(EventV2.Service, { subscribe: () => Stream.never }) +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, McpTool.node]), [ + [MCP.node, mcp], + [PermissionV2.node, permissions], + [EventV2.node, events], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ]), +) + +describe("MCP errors", () => { + test("expose useful messages", () => { + expect(new MCP.NotFoundError({ server: MCP.ServerName.make("demo") }).message).toBe("MCP server not found: demo") + expect(new MCP.ToolCallError({ server: MCP.ServerName.make("demo"), tool: "search", message: "failed" }).message).toBe( + "failed", + ) + expect(new MCPClient.NeedsAuthError({ server: "demo" }).message).toBe("MCP server requires authentication: demo") + expect(new MCPClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline") + }) +}) + +test("MCP tool names match V1 sanitization", () => { + expect(McpTool.name("context 7", "resolve.library/id")).toBe("context_7_resolve_library_id") +}) + +test("preserves output schema validation across paginated tool discovery", async () => { + const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } }) + server.setRequestHandler(ListToolsRequestSchema, ({ params }) => + Promise.resolve( + params?.cursor === "page-2" + ? { + tools: [ + { + name: "second", + inputSchema: { type: "object" }, + outputSchema: { + type: "object", + properties: { value: { type: "number" } }, + required: ["value"], + }, + }, + ], + } + : { + tools: [ + { + name: "first", + inputSchema: { type: "object" }, + outputSchema: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + }, + }, + ], + nextCursor: "page-2", + }, + ), + ) + server.setRequestHandler(CallToolRequestSchema, ({ params }) => + Promise.resolve({ + content: [], + structuredContent: { value: params.name === "first" ? 42 : 1 }, + }), + ) + + const client = new Client({ name: "pagination-test", version: "1.0.0" }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]) + + try { + const first = await client.listTools() + const second = await client.listTools({ cursor: first.nextCursor }) + expect([...first.tools, ...second.tools].map((tool) => tool.name)).toEqual(["first", "second"]) + await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow( + "Structured content does not match the tool's output schema", + ) + } finally { + await Promise.all([client.close(), server.close()]) + } +}) + +test("retains output schemas across paginated MCP discovery", async () => { + const tools = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const connection = yield* MCPClient.connect( + "pagination", + new ConfigMCP.Local({ + type: "local", + command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")], + }), + import.meta.dir, + ) + return yield* connection.tools() + }), + ), + ) + + expect(tools.map((tool) => ({ name: tool.name, outputSchema: tool.outputSchema }))).toEqual([ + { + name: "first", + outputSchema: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + }, + }, + { + name: "second", + outputSchema: { + type: "object", + properties: { value: { type: "number" } }, + required: ["value"], + }, + }, + ]) +}) + +it.effect("advertises MCP output schemas to Code Mode", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + yield* waitForTool(registry, "execute") + const execute = (yield* toolDefinitions(registry)).find((tool) => tool.name === "execute") + + expect(execute?.description).toContain("tools.demo.search(input: {}): Promise<{ ok: boolean }>") + }), +) + +it.effect("waits for permission before calling an MCP tool", () => + Effect.gen(function* () { + calls = 0 + assertion = yield* Deferred.make() + const permission = yield* Deferred.make() + decision = Deferred.await(permission) + const registry = yield* ToolRegistry.Service + yield* waitForTool(registry, "execute") + + const fiber = yield* settleTool(registry, { + sessionID: SessionV2.ID.make("ses_mcp_permission"), + ...toolIdentity, + call: { + type: "tool-call", + id: "call_mcp_permission", + name: "execute", + input: { code: "return await tools.demo.search({})" }, + }, + }).pipe(Effect.forkScoped) + expect(yield* Deferred.await(assertion)).toEqual({ + action: "demo_search", + resources: ["*"], + save: ["*"], + metadata: {}, + sessionID: SessionV2.ID.make("ses_mcp_permission"), + agent: toolIdentity.agent, + source: { + type: "tool", + messageID: toolIdentity.assistantMessageID, + callID: "call_mcp_permission", + }, + }) + expect(calls).toBe(0) + + yield* Deferred.succeed(permission, undefined) + yield* Fiber.join(fiber) + expect(calls).toBe(1) + }), +) + +it.effect("does not call MCP when permission is rejected", () => + Effect.gen(function* () { + calls = 0 + assertion = yield* Deferred.make() + decision = Effect.fail(new PermissionV2.RejectedError()) + const registry = yield* ToolRegistry.Service + yield* waitForTool(registry, "execute") + + const settlement = yield* settleTool(registry, { + sessionID: SessionV2.ID.make("ses_mcp_rejected"), + ...toolIdentity, + call: { + type: "tool-call", + id: "call_mcp_rejected", + name: "execute", + input: { code: "return await tools.demo.search({})" }, + }, + }) + expect(settlement.result).toEqual({ type: "text", value: "Unable to execute demo_search" }) + expect(settlement.output?.structured).toEqual({ + toolCalls: [{ tool: "demo.search", status: "error" }], + error: true, + }) + expect(calls).toBe(0) + }), +) diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index d9b7ed5582..9aa1cedc4d 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -149,6 +149,22 @@ describe("ModelsDev Service", () => { }), ) + it.effect("allows models.dev entries without temperature metadata", () => + Effect.sync(() => { + const result = Schema.decodeUnknownSync(ModelsDev.Model)({ + id: "no-temperature-model", + name: "No Temperature Model", + release_date: "2026-01-01", + attachment: false, + reasoning: false, + tool_call: true, + limit: { context: 128000, output: 8192 }, + }) + + expect(result.temperature).toBeUndefined() + }), + ) + it.live("get() returns providers from disk when cache file exists", () => Effect.gen(function* () { yield* writeCache(fixture) diff --git a/packages/core/test/newtype.test.ts b/packages/core/test/newtype.test.ts new file mode 100644 index 0000000000..1bcba5709d --- /dev/null +++ b/packages/core/test/newtype.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from "bun:test" +import { Effect, Schema } from "effect" +import { Newtype } from "../src/schema" + +class UserID extends Newtype()("Test.UserID", Schema.NonEmptyString) {} +class ProjectID extends Newtype()("Test.ProjectID", Schema.NonEmptyString) {} +class Port extends Newtype()("Test.Port", Schema.FiniteFromString) {} + +const User = Schema.Struct({ id: UserID }) + +test("constructs nominal values from the underlying type", () => { + const id = UserID.make("user-1") + const acceptUserID = (_id: UserID) => undefined + + expect(String(id)).toBe("user-1") + acceptUserID(id) + + if (false) { + // @ts-expect-error distinct newtypes are not interchangeable + acceptUserID(ProjectID.make("project-1")) + } +}) + +test("preserves constructor validation", () => { + expect(() => UserID.make("")).toThrow() +}) + +test("decodes and encodes as a schema", async () => { + const decoded = await Effect.runPromise(Schema.decodeUnknownEffect(User)({ id: "user-1" })) + const encoded = await Effect.runPromise(Schema.encodeEffect(User)(decoded)) + + expect(String(decoded.id)).toBe("user-1") + expect(encoded).toEqual({ id: "user-1" }) +}) + +test("preserves the underlying schema validation", async () => { + const result = await Effect.runPromise(Schema.decodeUnknownEffect(UserID)("").pipe(Effect.result)) + expect(result._tag).toBe("Failure") +}) + +test("preserves transformed encoded and decoded representations", async () => { + const decoded = await Effect.runPromise(Schema.decodeUnknownEffect(Port)("8080")) + const encoded = await Effect.runPromise(Schema.encodeEffect(Port)(decoded)) + + expect(Number(decoded)).toBe(8080) + expect(encoded).toBe("8080") +}) diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 27f1d04061..e437feb066 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -1,8 +1,12 @@ import { describe, expect } from "bun:test" -import { Effect, Exit, Fiber, Schema } from "effect" +import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect" import { define } from "@opencode-ai/plugin/v2/effect" +import { Config as ConfigSchema } from "@opencode-ai/schema/config" +import { Plugin } from "@opencode-ai/schema/plugin" import { AgentV2 } from "@opencode-ai/core/agent" +import { EventV2 } from "@opencode-ai/core/event" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { SessionV2 } from "@opencode-ai/core/session" import { SessionMessage } from "@opencode-ai/core/session/message" import { Tool } from "@opencode-ai/core/tool/tool" @@ -13,40 +17,36 @@ import { PluginTestLayer } from "./plugin/fixture" const it = testEffect(PluginTestLayer) +class Secret extends Context.Service()("@opencode/test/PluginSecret") {} + describe("PluginV2", () => { - it.effect("waits for a plugin and returns immediately once active", () => + it.live("exposes public events through the plugin context", () => Effect.gen(function* () { const plugins = yield* PluginV2.Service - const id = PluginV2.ID.make("waited") - const waiting = yield* plugins.wait(id).pipe(Effect.forkChild) + const events = yield* EventV2.Service + const host = yield* PluginHost.make(plugins) + const received = yield* host.event.subscribe().pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runHead, + Effect.forkScoped({ startImmediately: true }), + ) + yield* Effect.sleep("10 millis") - yield* plugins.add(id, () => Effect.void) - yield* Fiber.join(waiting) - yield* plugins.wait(id) + yield* events.publish(ConfigSchema.Event.Updated, {}) + + expect((yield* Fiber.join(received)).valueOrUndefined?.type).toBe("config.updated") }), ) - it.effect("propagates plugin activation defects to waiters", () => - Effect.gen(function* () { - const plugins = yield* PluginV2.Service - const id = PluginV2.ID.make("failed") - const waiting = yield* plugins.wait(id).pipe(Effect.exit, Effect.forkChild) - - const added = yield* plugins.add(id, () => Effect.die("boom")).pipe(Effect.exit) - const pending = yield* Fiber.join(waiting) - const later = yield* plugins.wait(id).pipe(Effect.exit) - - expect(Exit.isFailure(added)).toBe(true) - expect(Exit.isFailure(pending)).toBe(true) - expect(Exit.isFailure(later)).toBe(true) - }), - ) - - it.effect("adds, replaces, and removes plugins", () => + it.effect("skips identical generations and replaces changed plugin versions", () => Effect.gen(function* () { const plugins = yield* PluginV2.Service const agents = yield* AgentV2.Service + const events = yield* EventV2.Service let description = "first" + const updated = yield* events + .subscribe(Plugin.Event.Updated) + .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped({ startImmediately: true })) const managed = () => define({ @@ -61,19 +61,102 @@ describe("PluginV2", () => { .pipe(Effect.asVoid), }) - yield* plugins.add(PluginV2.ID.make("managed"), managed().effect) + yield* plugins.activate([{ plugin: managed() }]) expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first") description = "second" - yield* plugins.add(PluginV2.ID.make("managed"), managed().effect) - expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("second") + yield* plugins.activate([{ plugin: managed() }]) + expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first") - yield* plugins.remove(PluginV2.ID.make("managed")) + yield* plugins.activate([{ plugin: managed(), version: "next" }]) + expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("second") + expect(yield* Fiber.join(updated)).toHaveLength(2) + + yield* plugins.activate([]) expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined() }), ) + it.effect("rejects duplicate IDs before replacing the active generation", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const active = Plugin.ID.make("active") + const duplicate = "duplicate" + yield* plugins.activate([{ plugin: { id: active, effect: () => Effect.void } }]) + + const result = yield* plugins + .activate([ + { plugin: { id: duplicate, effect: () => Effect.void } }, + { plugin: { id: duplicate, effect: () => Effect.void } }, + ]) + .pipe(Effect.exit) + + expect(Exit.isFailure(result)).toBe(true) + expect(yield* plugins.list()).toEqual([{ id: active }]) + }), + ) + + it.effect("retries the same generation after materialization fails", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + let fail = true + const plugin = define({ + id: "retry", + effect: (ctx) => + ctx.agent + .transform(() => { + if (fail) throw new Error("materialization failed") + }) + .pipe(Effect.asVoid), + }) + + expect(Exit.isFailure(yield* plugins.activate([{ plugin }]).pipe(Effect.exit))).toBe(true) + fail = false + yield* plugins.activate([{ plugin }]) + + expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("retry") }]) + }), + ) + + it.effect("closes the previous generation in reverse order", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const closed: string[] = [] + yield* plugins.activate( + ["first", "second"].map((id) => ({ + plugin: { + id, + effect: () => Effect.addFinalizer(() => Effect.sync(() => closed.push(id))), + }, + })), + ) + + yield* plugins.activate([]) + + expect(closed).toEqual(["second", "first"]) + }), + ) + + it.effect("isolates plugins from ambient services", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + let visible = true + const plugin = define({ + id: "isolated", + effect: () => + Effect.serviceOption(Secret).pipe( + Effect.tap((secret) => Effect.sync(() => (visible = secret._tag === "Some"))), + Effect.asVoid, + ), + }) + + yield* plugins.activate([{ plugin }]).pipe(Effect.provideService(Secret, "secret")) + + expect(visible).toBe(false) + }), + ) + it.effect("registers location tools through the plugin context", () => Effect.gen(function* () { const plugins = yield* PluginV2.Service @@ -93,18 +176,51 @@ describe("PluginV2", () => { .pipe(Effect.orDie), }) - yield* plugins.add(PluginV2.ID.make(plugin.id), plugin.effect) + yield* plugins.activate([{ plugin }]) expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toContain( "plugin_tool", ) - yield* plugins.remove(PluginV2.ID.make(plugin.id)) + yield* plugins.activate([]) expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).not.toContain( "plugin_tool", ) }), ) + it.effect("groups tool names and defers registrations from direct exposure", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const registry = yield* ToolRegistry.Service + const tool = (description: string) => + Tool.make({ + description, + input: Schema.Struct({}), + output: Schema.Struct({ ok: Schema.Boolean }), + execute: () => Effect.succeed({ ok: true }), + }) + const plugin = define({ + id: "grouped-tools", + effect: (ctx) => + Effect.gen(function* () { + yield* ctx.tool.register({ plain: tool("Plain") }).pipe(Effect.orDie) + yield* ctx.tool.register({ "look/up": tool("Lookup") }, { group: "context 7" }).pipe(Effect.orDie) + yield* ctx.tool + .register({ search: tool("Search") }, { group: "context 7", deferred: true }) + .pipe(Effect.orDie) + }), + }) + + yield* plugins.activate([{ plugin }]) + + expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toEqual([ + "plain", + "context_7_look_up", + "execute", + ]) + }), + ) + it.effect("fires before/after tool hooks with mutable events around settlement", () => Effect.gen(function* () { const plugins = yield* PluginV2.Service @@ -147,7 +263,7 @@ describe("PluginV2", () => { }), }) - yield* plugins.add(PluginV2.ID.make(plugin.id), plugin.effect) + yield* plugins.activate([{ plugin }]) const materialized = yield* registry.materialize({ model: testModel }) const settlement = yield* materialized.settle({ diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index be84975f64..65848dbd72 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/packages/core/test/plugin/command.test.ts @@ -23,7 +23,11 @@ describe("CommandPlugin.Plugin", () => { const command = yield* CommandV2.Service yield* CommandPlugin.Plugin.effect( host({ - command: { transform: command.transform, reload: command.reload }, + command: { + list: () => Effect.die("unused command.list"), + transform: command.transform, + reload: command.reload, + }, }), ).pipe( Effect.provideService( diff --git a/packages/core/test/plugin/fixtures/failing-plugin.ts b/packages/core/test/plugin/fixtures/failing-plugin.ts new file mode 100644 index 0000000000..4daac0acd0 --- /dev/null +++ b/packages/core/test/plugin/fixtures/failing-plugin.ts @@ -0,0 +1,7 @@ +import { define } from "@opencode-ai/plugin/v2/effect" +import { Effect } from "effect" + +export default define({ + id: "failing-plugin", + effect: () => Effect.die("plugin failed"), +}) diff --git a/packages/core/test/plugin/fixtures/variant-source-plugin.ts b/packages/core/test/plugin/fixtures/variant-source-plugin.ts new file mode 100644 index 0000000000..087a714227 --- /dev/null +++ b/packages/core/test/plugin/fixtures/variant-source-plugin.ts @@ -0,0 +1,29 @@ +import { define } from "@opencode-ai/plugin/v2/effect" +import { Effect } from "effect" + +export default define({ + id: "variant-source", + effect: (ctx) => + ctx.catalog + .transform((catalog) => { + catalog.provider.update("configured", (provider) => { + provider.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" } + }) + catalog.model.update("configured", "glm-5.2", (model) => { + model.api = { + id: "glm-5.2", + type: "aisdk", + package: "@ai-sdk/openai-compatible", + } + model.variants = [ + { + id: "high", + settings: {}, + headers: { custom: "true" }, + body: {}, + }, + ] + }) + }) + .pipe(Effect.asVoid), +}) diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 384dcb8f2b..0609d1546a 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -6,7 +6,7 @@ import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2/types" -import { Effect } from "effect" +import { Effect, Stream } from "effect" type Overrides = Partial> @@ -23,14 +23,33 @@ export function host(overrides: Overrides = {}): PluginContext { language: () => Effect.die("unused aisdk.language"), }, catalog: overrides.catalog ?? { + provider: { + list: () => Effect.die("unused catalog.provider.list"), + get: () => Effect.die("unused catalog.provider.get"), + }, + model: { + list: () => Effect.die("unused catalog.model.list"), + default: () => Effect.die("unused catalog.model.default"), + }, transform: () => Effect.die("unused catalog.transform"), reload: () => Effect.die("unused catalog.reload"), }, command: overrides.command ?? { + list: () => Effect.die("unused command.list"), transform: () => Effect.die("unused command.transform"), reload: () => Effect.die("unused command.reload"), }, + event: overrides.event ?? { + subscribe: () => Stream.empty, + }, integration: overrides.integration ?? { + list: () => Effect.die("unused integration.list"), + get: () => Effect.die("unused integration.get"), + connectKey: () => Effect.die("unused integration.connectKey"), + connectOauth: () => Effect.die("unused integration.connectOauth"), + attemptStatus: () => Effect.die("unused integration.attemptStatus"), + attemptComplete: () => Effect.die("unused integration.attemptComplete"), + attemptCancel: () => Effect.die("unused integration.attemptCancel"), transform: () => Effect.die("unused integration.transform"), reload: () => Effect.die("unused integration.reload"), connection: { @@ -39,14 +58,15 @@ export function host(overrides: Overrides = {}): PluginContext { }, }, plugin: overrides.plugin ?? { - add: () => Effect.die("unused plugin.add"), - remove: () => Effect.die("unused plugin.remove"), + list: () => Effect.die("unused plugin.list"), }, reference: overrides.reference ?? { + list: () => Effect.die("unused reference.list"), transform: () => Effect.die("unused reference.transform"), reload: () => Effect.die("unused reference.reload"), }, skill: overrides.skill ?? { + list: () => Effect.die("unused skill.list"), transform: () => Effect.die("unused skill.transform"), reload: () => Effect.die("unused skill.reload"), }, @@ -97,6 +117,14 @@ export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] { export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"] { return { + provider: { + list: () => Effect.die("unused catalog.provider.list"), + get: () => Effect.die("unused catalog.provider.get"), + }, + model: { + list: () => Effect.die("unused catalog.model.list"), + default: () => Effect.die("unused catalog.model.default"), + }, reload: catalog.reload, transform: (callback) => catalog.transform((draft) => @@ -161,6 +189,13 @@ export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog" export function integrationHost(integration: Integration.Interface): PluginContext["integration"] { return { + list: () => Effect.die("unused integration.list"), + get: () => Effect.die("unused integration.get"), + connectKey: () => Effect.die("unused integration.connectKey"), + connectOauth: () => Effect.die("unused integration.connectOauth"), + attemptStatus: () => Effect.die("unused integration.attemptStatus"), + attemptComplete: () => Effect.die("unused integration.attemptComplete"), + attemptCancel: () => Effect.die("unused integration.attemptCancel"), reload: integration.reload, connection: { active: (id) => integration.connection.active(Integration.ID.make(id)), diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index 41a6641946..fbe4029c3a 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -11,6 +11,35 @@ import { PluginTestLayer } from "./fixture" const it = testEffect(PluginTestLayer) describe("fromPromise", () => { + it.effect("forwards standard client reads", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + const seen: string[] = [] + const promisePlugin = define({ + id: "promise-client-reads", + setup: async (ctx) => { + const results = await Promise.all([ + ctx.agent.list(), + ctx.catalog.provider.list(), + ctx.catalog.model.list(), + ctx.command.list(), + ctx.integration.list(), + ctx.plugin.list(), + ctx.reference.list(), + ctx.skill.list(), + ]) + seen.push(...results.map((result) => result.location.directory)) + }, + }) + + yield* PluginPromise.fromPromise(promisePlugin).effect(host) + + expect(seen).toHaveLength(8) + expect(new Set(seen).size).toBe(1) + }), + ) + it.effect("loads a promise plugin and registers a transform hook", () => Effect.gen(function* () { const agents = yield* AgentV2.Service diff --git a/packages/core/test/plugin/provider-kilo.test.ts b/packages/core/test/plugin/provider-kilo.test.ts index b34ceb2d5b..1ff356523c 100644 --- a/packages/core/test/plugin/provider-kilo.test.ts +++ b/packages/core/test/plugin/provider-kilo.test.ts @@ -19,7 +19,7 @@ const addPlugin = Effect.fn(function* () { describe("KiloPlugin", () => { it.effect("is registered so legacy referer headers can be applied", () => - Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("kilo"))), + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.kilo")), ) it.effect("applies legacy referer headers only to kilo", () => diff --git a/packages/core/test/plugin/provider-llmgateway.test.ts b/packages/core/test/plugin/provider-llmgateway.test.ts index 5dce7cdcf7..8f4af57889 100644 --- a/packages/core/test/plugin/provider-llmgateway.test.ts +++ b/packages/core/test/plugin/provider-llmgateway.test.ts @@ -21,7 +21,7 @@ const addPlugin = Effect.fn(function* () { describe("LLMGatewayPlugin", () => { it.effect("is registered so legacy referer headers can be applied", () => - Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("llmgateway"))), + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.llmgateway")), ) it.effect("applies legacy referer headers only to enabled llmgateway", () => diff --git a/packages/core/test/plugin/provider-nvidia.test.ts b/packages/core/test/plugin/provider-nvidia.test.ts index 260ffff689..c176cef4ac 100644 --- a/packages/core/test/plugin/provider-nvidia.test.ts +++ b/packages/core/test/plugin/provider-nvidia.test.ts @@ -19,7 +19,7 @@ const addPlugin = Effect.fn(function* () { describe("NvidiaPlugin", () => { it.effect("is registered so legacy referer headers can be applied", () => - Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("nvidia"))), + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.nvidia")), ) it.effect("applies NVIDIA tracking headers only to nvidia", () => diff --git a/packages/core/test/plugin/provider-openrouter.test.ts b/packages/core/test/plugin/provider-openrouter.test.ts index d611f40363..60dfc67b51 100644 --- a/packages/core/test/plugin/provider-openrouter.test.ts +++ b/packages/core/test/plugin/provider-openrouter.test.ts @@ -22,7 +22,7 @@ const addPlugin = Effect.fn(function* () { describe("OpenRouterPlugin", () => { it.effect("is registered so legacy OpenRouter behavior can be applied", () => - Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("openrouter"))), + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.openrouter")), ) it.effect("applies legacy referer headers only to openrouter", () => diff --git a/packages/core/test/plugin/provider-snowflake-cortex.test.ts b/packages/core/test/plugin/provider-snowflake-cortex.test.ts index cd67feb40e..4de00105b7 100644 --- a/packages/core/test/plugin/provider-snowflake-cortex.test.ts +++ b/packages/core/test/plugin/provider-snowflake-cortex.test.ts @@ -43,9 +43,11 @@ function withEnv(vars: Record, effect: () = describe("SnowflakeCortexPlugin", () => { it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () => Effect.sync(() => { - expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("snowflake-cortex")) + expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.snowflake-cortex") const ids = ProviderPlugins.map((p) => p.id) - expect(ids.indexOf("snowflake-cortex")).toBeLessThan(ids.indexOf("openai-compatible")) + expect(ids.indexOf("opencode.provider.snowflake-cortex")).toBeLessThan( + ids.indexOf("opencode.provider.openai-compatible"), + ) }), ) diff --git a/packages/core/test/plugin/provider-zenmux.test.ts b/packages/core/test/plugin/provider-zenmux.test.ts index b9d34a1f5b..9c44800ebc 100644 --- a/packages/core/test/plugin/provider-zenmux.test.ts +++ b/packages/core/test/plugin/provider-zenmux.test.ts @@ -24,7 +24,7 @@ function required(value: T | undefined): T { describe("ZenmuxPlugin", () => { it.effect("is registered so legacy referer headers can be applied", () => - Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("zenmux"))), + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.zenmux")), ) it.effect("applies the exact legacy Zenmux headers", () => diff --git a/packages/core/test/plugin/skill.test.ts b/packages/core/test/plugin/skill.test.ts index 1bc49f3716..1a2ba5009d 100644 --- a/packages/core/test/plugin/skill.test.ts +++ b/packages/core/test/plugin/skill.test.ts @@ -19,7 +19,15 @@ describe("SkillPlugin.Plugin", () => { it.effect("registers built-in skills", () => Effect.gen(function* () { const skill = yield* SkillV2.Service - yield* SkillPlugin.Plugin.effect(host({ skill: { ...skill, reload: skill.reload } })).pipe( + yield* SkillPlugin.Plugin.effect( + host({ + skill: { + list: () => Effect.die("unused skill.list"), + transform: skill.transform, + reload: skill.reload, + }, + }), + ).pipe( Effect.provideService(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })), Effect.provideService( Location.Service, diff --git a/packages/core/test/policy.test.ts b/packages/core/test/policy.test.ts deleted file mode 100644 index 1428c1b830..0000000000 --- a/packages/core/test/policy.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { Location } from "@opencode-ai/core/location" -import { Policy } from "@opencode-ai/core/policy" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { location } from "./fixture/location" -import { testEffect } from "./lib/effect" - -const it = testEffect( - AppNodeBuilder.build(Policy.node, [ - [ - Location.node, - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), - ], - ]), -) - -describe("Policy", () => { - it.effect("returns the caller's fallback when no statement matches", () => - Effect.gen(function* () { - const policy = yield* Policy.Service - - expect(yield* policy.evaluate("provider.use", "anthropic", "allow")).toBe("allow") - expect(yield* policy.evaluate("provider.use", "anthropic", "deny")).toBe("deny") - }), - ) - - it.effect("evaluates wildcard provider rules in written order", () => - Effect.gen(function* () { - const policy = yield* Policy.Service - yield* policy.load([ - new Policy.Info({ - effect: "deny", - action: "provider.*", - resource: "*", - }), - new Policy.Info({ - effect: "allow", - action: "provider.use", - resource: "anthropic", - }), - ]) - - expect(yield* policy.evaluate("provider.use", "anthropic", "allow")).toBe("allow") - expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny") - }), - ) - - it.effect("matches action and resource independently", () => - Effect.gen(function* () { - const policy = yield* Policy.Service - yield* policy.load([ - new Policy.Info({ - effect: "deny", - action: "provider.*", - resource: "company-*", - }), - ]) - - expect(yield* policy.evaluate("provider.use", "company-stable", "allow")).toBe("deny") - expect(yield* policy.evaluate("plugin.load", "company-stable", "allow")).toBe("allow") - }), - ) - - it.effect("uses the last matching loaded statement", () => - Effect.gen(function* () { - const policy = yield* Policy.Service - yield* policy.load([ - new Policy.Info({ - effect: "allow", - action: "provider.use", - resource: "openai", - }), - new Policy.Info({ - effect: "deny", - action: "provider.use", - resource: "openai", - }), - ]) - - expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny") - }), - ) -}) diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index 801ccb5226..af1255f426 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -2,16 +2,72 @@ import { afterAll, describe, expect } from "bun:test" import { $ } from "bun" import fs from "fs/promises" import path from "path" -import { Effect, Schema } from "effect" +import { Effect, Layer, Schema } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Database } from "@opencode-ai/core/database/database" import { Global } from "@opencode-ai/core/global" import { ProjectV2 } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" import { Hash } from "@opencode-ai/core/util/hash" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -const it = testEffect(AppNodeBuilder.build(ProjectV2.node)) +const it = testEffect( + Layer.merge(AppNodeBuilder.build(ProjectV2.node), AppNodeBuilder.build(Database.node)), +) + +describe("ProjectV2.list", () => { + it.effect("returns complete projects ordered by recent update", () => + Effect.gen(function* () { + const db = (yield* Database.Service).db + const project = yield* ProjectV2.Service + yield* db + .insert(ProjectTable) + .values([ + { + id: ProjectV2.ID.make("older"), + worktree: abs("/older"), + vcs: "git", + name: "Older", + icon_color: "#000000", + commands: { start: "bun dev" }, + sandboxes: [abs("/older/sandbox")], + time_created: 1, + time_updated: 1, + }, + { + id: ProjectV2.ID.make("newer"), + worktree: abs("/newer"), + sandboxes: [], + time_created: 2, + time_updated: 2, + time_initialized: 3, + }, + ]) + .run() + + expect(yield* project.list()).toEqual([ + { + id: ProjectV2.ID.make("newer"), + worktree: abs("/newer"), + time: { created: 2, updated: 2, initialized: 3 }, + sandboxes: [], + }, + { + id: ProjectV2.ID.make("older"), + worktree: abs("/older"), + vcs: "git", + name: "Older", + icon: { color: "#000000" }, + commands: { start: "bun dev" }, + time: { created: 1, updated: 1 }, + sandboxes: [abs("/older/sandbox")], + }, + ]) + }), + ) +}) const globalConfig = await tmpdir() afterAll(() => globalConfig[Symbol.asyncDispose]()) diff --git a/packages/core/test/session-compact.test.ts b/packages/core/test/session-compact.test.ts index cee32ade04..98551e9f22 100644 --- a/packages/core/test/session-compact.test.ts +++ b/packages/core/test/session-compact.test.ts @@ -33,6 +33,7 @@ const model = Model.make({ const projects = Layer.succeed( ProjectV2.Service, ProjectV2.Service.of({ + list: () => Effect.succeed([]), resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }), directories: () => Effect.succeed([]), commit: () => Effect.void, @@ -85,17 +86,13 @@ describe("SessionV2.compact", () => { const prompt = Prompt.make({ text: "Please compact this session history." }) yield* events.publish(SessionEvent.PromptAdmitted, { sessionID: created.id, - messageID, - timestamp: DateTime.makeUnsafe(0), + inputID: messageID, prompt, delivery: "steer", }) - yield* events.publish(SessionEvent.Prompted, { + yield* events.publish(SessionEvent.PromptPromoted, { sessionID: created.id, - messageID, - timestamp: DateTime.makeUnsafe(0), - prompt, - delivery: "steer", + inputID: messageID, }) yield* session.compact({ sessionID: created.id }) diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 9c41d1d9ac..1b07937ebd 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -17,11 +17,11 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Prompt } from "@opencode-ai/core/session/prompt" +import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionInput } from "@opencode-ai/core/session/input" import { SessionEvent } from "@opencode-ai/core/session/event" -import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" import { WorkspaceV2 } from "@opencode-ai/core/workspace" @@ -31,6 +31,7 @@ import { tmpdir } from "./fixture/tmpdir" const projects = Layer.succeed( ProjectV2.Service, ProjectV2.Service.of({ + list: () => Effect.succeed([]), resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }), directories: () => Effect.succeed([]), commit: () => Effect.void, @@ -62,6 +63,13 @@ const assertCreateInputTypes = (session: SessionV2.Interface) => { } void assertCreateInputTypes +function withTmp(f: (directory: string) => Effect.Effect) { + return Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe(Effect.flatMap((tmp) => f(tmp.path))) +} + describe("SessionV2.create", () => { it.effect("creates a fresh projected session when the ID is omitted", () => Effect.gen(function* () { @@ -190,8 +198,6 @@ describe("SessionV2.create", () => { yield* SessionInput.promoteSteers(db, events, parent.id) yield* events.publish(SessionEvent.Synthetic, { sessionID: parent.id, - messageID: SessionMessage.ID.create(), - timestamp: yield* DateTime.now, text: "parent note", }) @@ -208,7 +214,7 @@ describe("SessionV2.create", () => { expect(forkContext.map((message) => message.id)).not.toEqual(parentContext.map((message) => message.id)) expect(history).toHaveLength(1) expect(history[0]).toMatchObject({ - type: "session.next.forked", + type: "session.forked", durable: { seq: 0 }, data: { sessionID: forked.id, parentID: parent.id }, }) @@ -260,7 +266,7 @@ describe("SessionV2.create", () => { const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id))) expect(context).toMatchObject([{ text: "First" }]) expect(context[0]?.id).not.toBe(first.id) - expect(history[0]).toMatchObject({ data: { messageID: second.id } }) + expect(history[0]).toMatchObject({ data: { from: second.id } }) }), ) @@ -373,8 +379,8 @@ describe("SessionV2.create", () => { expect( Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(2), Stream.runCollect)), ).toMatchObject([ - { durable: { seq: 1 }, type: "session.next.prompt.admitted", data: { prompt: { text: "Hello" } } }, - { durable: { seq: 2 }, type: "session.next.prompted" }, + { durable: { seq: 1 }, type: "session.prompt.admitted", data: { prompt: { text: "Hello" } } }, + { durable: { seq: 2 }, type: "session.prompt.promoted" }, ]) }), ) @@ -399,6 +405,7 @@ describe("SessionV2.create", () => { .all() .pipe(Effect.orDie)).map((event) => ({ id: event.id, + created: DateTime.makeUnsafe(event.created), aggregateID: event.aggregate_id, seq: event.seq, type: event.type, @@ -459,7 +466,7 @@ describe("SessionV2.create", () => { ).toEqual([ [0, EventV2.versionedType(SessionV1.Event.Created.type, 1)], [1, EventV2.versionedType(SessionEvent.PromptAdmitted.type, 1)], - [2, EventV2.versionedType(SessionEvent.Prompted.type, 1)], + [2, EventV2.versionedType(SessionEvent.PromptPromoted.type, 1)], ]) }).pipe(Effect.provide(Layer.fresh(targetLayer))) }), @@ -476,20 +483,43 @@ describe("SessionV2.create", () => { }), ) - it.effect("reports unfinished Session operations as unavailable", () => - Effect.gen(function* () { - const session = yield* SessionV2.Service - const created = yield* session.create({ location }) - const unavailable = ( - effect: Effect.Effect, - ) => - effect.pipe( - Effect.flip, - Effect.map((error) => (error instanceof SessionV2.OperationUnavailableError ? error.operation : "not-found")), - ) + it.live("runs a shell command and projects the started/ended shell message", () => + withTmp((directory) => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const created = yield* session.create({ + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) - expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell") - }), + yield* session.shell({ sessionID: created.id, command: "echo hello" }) + + const messages = yield* session.messages({ sessionID: created.id, order: "asc" }) + const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell") + expect(shell).toMatchObject({ type: "shell", shell: { command: "echo hello", status: "exited", exit: 0 } }) + expect(shell?.output?.output).toContain("hello") + expect(shell?.output?.truncated).toBe(false) + expect(shell?.time.completed).toBeDefined() + }), + ), + ) + + it.live("still emits shell ended for a failing command", () => + withTmp((directory) => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const created = yield* session.create({ + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) + + yield* session.shell({ sessionID: created.id, command: "false" }) + + const messages = yield* session.messages({ sessionID: created.id, order: "asc" }) + const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell") + expect(shell).toMatchObject({ type: "shell", shell: { command: "false", status: "exited" } }) + expect(shell?.shell.exit).not.toBe(0) + expect(shell?.time.completed).toBeDefined() + }), + ), ) it.effect("switches the selected agent through the durable Session event", () => @@ -502,7 +532,7 @@ describe("SessionV2.create", () => { expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" }) expect( Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)), - ).toMatchObject([{ type: "session.next.agent.switched", data: { agent: "plan" } }]) + ).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan" } }]) }), ) @@ -533,9 +563,9 @@ describe("SessionV2.create", () => { yield* session.switchModel({ sessionID: created.id, model }) expect(yield* session.get(created.id)).toMatchObject({ model }) - expect( - Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)), - ).toMatchObject([{ type: "session.next.model.switched", data: { model } }]) + const events = Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)) + expect(events).toMatchObject([{ type: "session.model.selected" }]) + expect(events[0]?.data).toEqual({ sessionID: created.id, model }) }), ) diff --git a/packages/core/test/session-instructions.test.ts b/packages/core/test/session-instructions.test.ts index b9e401be4b..19e717c836 100644 --- a/packages/core/test/session-instructions.test.ts +++ b/packages/core/test/session-instructions.test.ts @@ -33,12 +33,29 @@ import { ToolHooks } from "@opencode-ai/core/tool/hooks" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { tempLocationLayer } from "./fixture/location" +import { makeLocationNode } from "@opencode-ai/core/effect/app-node" import { testEffect } from "./lib/effect" -import { settleTool, testModel } from "./lib/tool" +import { registerToolPlugin, settleTool, testModel } from "./lib/tool" + +const readToolNode = makeLocationNode({ + name: "test/read-tool-plugin", + layer: Layer.effectDiscard(registerToolPlugin(ReadTool.Plugin)), + deps: [ + ToolRegistry.toolsNode, + ReadToolFileSystem.node, + LocationMutation.node, + Image.node, + PermissionV2.node, + SessionInstructions.node, + FSUtil.node, + Location.node, + ], +}) const projects = Layer.succeed( ProjectV2.Service, ProjectV2.Service.of({ + list: () => Effect.succeed([]), resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }), directories: () => Effect.succeed([]), commit: () => Effect.void, @@ -69,7 +86,7 @@ const testLayer = AppNodeBuilder.build( FSUtil.node, LocationMutation.node, ReadToolFileSystem.node, - ReadTool.node, + readToolNode, ToolRegistry.node, ToolRegistry.toolsNode, ToolHooks.node, @@ -117,8 +134,6 @@ const seedSynthetic = (sessionID: SessionV2.ID, paths: string[]) => const events = yield* EventV2.Service yield* events.publish(SessionEvent.Synthetic, { sessionID, - messageID: SessionMessage.ID.create(), - timestamp: yield* DateTime.now, text: `Instructions from: ${paths[0]}\nprior`, description: `Loaded ${paths[0]}`, metadata: { instruction: { paths } }, @@ -156,7 +171,9 @@ describe("SessionInstructions", () => { expect(firstInjected[0]!.text).toBe( `Instructions from: ${deepPath}\ndeep-instructions\n\nInstructions from: ${subPath}\nsub-instructions`, ) - expect(firstInjected[0]!.description).toBe(`Loaded ${path.relative(dir, deepPath)}, ${path.relative(dir, subPath)}`) + expect(firstInjected[0]!.description).toBe( + `Loaded ${path.relative(dir, deepPath)}, ${path.relative(dir, subPath)}`, + ) // The synthetic's metadata carries the durable dedup ledger. expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [deepPath, subPath] } }) expect(firstInjected[0]!.text).not.toContain("root-instructions") @@ -192,47 +209,49 @@ describe("SessionInstructions", () => { // Seed the durable history with a prior synthetic that already claims sub's AGENTS.md // via the instruction metadata ledger. yield* seedSynthetic(sessionID, [subPath]) - expect((yield* synthetics(sessionID))).toHaveLength(1) + expect(yield* synthetics(sessionID)).toHaveLength(1) yield* settleTool(registry, readCall(sessionID, "call-sub", "sub/file.txt")) // The durable claim on the prior synthetic prevents re-injection; no new synthetic. - expect((yield* synthetics(sessionID))).toHaveLength(1) + expect(yield* synthetics(sessionID)).toHaveLength(1) }), ) - it.effect("discovers AGENTS.md on a directory listing, including the listed directory's own, and dedups with a later file read", () => - Effect.gen(function* () { - const location = yield* Location.Service - const dir = location.directory - const rootPath = path.resolve(dir, "AGENTS.md") - const pkgPath = path.resolve(dir, "packages", "foo", "AGENTS.md") - yield* mkdir(path.resolve(dir, "packages", "foo")) - yield* writeAgents(rootPath, "root-instructions") - yield* writeAgents(pkgPath, "pkg-instructions") - yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "packages", "foo", "file.txt"), "content")) + it.effect( + "discovers AGENTS.md on a directory listing, including the listed directory's own, and dedups with a later file read", + () => + Effect.gen(function* () { + const location = yield* Location.Service + const dir = location.directory + const rootPath = path.resolve(dir, "AGENTS.md") + const pkgPath = path.resolve(dir, "packages", "foo", "AGENTS.md") + yield* mkdir(path.resolve(dir, "packages", "foo")) + yield* writeAgents(rootPath, "root-instructions") + yield* writeAgents(pkgPath, "pkg-instructions") + yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "packages", "foo", "file.txt"), "content")) - const session = yield* SessionV2.Service - const registry = yield* ToolRegistry.Service - const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id + const session = yield* SessionV2.Service + const registry = yield* ToolRegistry.Service + const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id - // Listing packages/foo/ discovers its own AGENTS.md, walking up to but excluding - // the Location root (already supplied by the core/instructions baseline). - yield* settleTool(registry, readCall(sessionID, "call-list", "packages/foo")) + // Listing packages/foo/ discovers its own AGENTS.md, walking up to but excluding + // the Location root (already supplied by the core/instructions baseline). + yield* settleTool(registry, readCall(sessionID, "call-list", "packages/foo")) - const firstInjected = yield* synthetics(sessionID) - expect(firstInjected).toHaveLength(1) - expect(firstInjected[0]!.text).toBe(`Instructions from: ${pkgPath}\npkg-instructions`) - expect(firstInjected[0]!.description).toBe(`Loaded ${path.relative(dir, pkgPath)}`) - expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [pkgPath] } }) - expect(firstInjected[0]!.text).not.toContain("root-instructions") + const firstInjected = yield* synthetics(sessionID) + expect(firstInjected).toHaveLength(1) + expect(firstInjected[0]!.text).toBe(`Instructions from: ${pkgPath}\npkg-instructions`) + expect(firstInjected[0]!.description).toBe(`Loaded ${path.relative(dir, pkgPath)}`) + expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [pkgPath] } }) + expect(firstInjected[0]!.text).not.toContain("root-instructions") - // A subsequent file read under the listed directory is a dedup: pkg's AGENTS.md is - // already injected for this session, so nothing new is emitted. - yield* settleTool(registry, readCall(sessionID, "call-file", "packages/foo/file.txt")) + // A subsequent file read under the listed directory is a dedup: pkg's AGENTS.md is + // already injected for this session, so nothing new is emitted. + yield* settleTool(registry, readCall(sessionID, "call-file", "packages/foo/file.txt")) - expect((yield* synthetics(sessionID))).toHaveLength(1) - }), + expect(yield* synthetics(sessionID)).toHaveLength(1) + }), ) it.effect("listing the Location root directory injects no instructions", () => @@ -253,7 +272,7 @@ describe("SessionInstructions", () => { // dropped by the dirname filter, and up() only walks upward so nested dirs are unseen. yield* settleTool(registry, readCall(sessionID, "call-root-list", ".")) - expect((yield* synthetics(sessionID))).toHaveLength(0) + expect(yield* synthetics(sessionID)).toHaveLength(0) }), ) diff --git a/packages/core/test/session-log.test.ts b/packages/core/test/session-log.test.ts index b80dd2c206..ef0865f89e 100644 --- a/packages/core/test/session-log.test.ts +++ b/packages/core/test/session-log.test.ts @@ -18,6 +18,7 @@ import { testEffect } from "./lib/effect" const projects = Layer.succeed( ProjectV2.Service, ProjectV2.Service.of({ + list: () => Effect.succeed([]), resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }), directories: () => Effect.succeed([]), commit: () => Effect.void, @@ -40,14 +41,14 @@ describe("SessionV2.log", () => { const session = yield* SessionV2.Service const events = yield* EventV2.Service const created = yield* session.create({ location }) - yield* session.rename({ sessionID: created.id, title: "renamed" }) + yield* session.rename({ sessionID: created.id, title: "session.renamed" }) const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id }))) const watermark = (yield* events.sequences([created.id])).get(created.id) // Session creation commits a non-public durable event, so the marker's // seq covers more of the aggregate than the public events emitted. - expect(items.map((item) => item.type)).toEqual(["session.next.renamed", "log.synced"]) + expect(items.map((item) => item.type)).toEqual(["session.renamed", "log.synced"]) expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: watermark }) }), ) @@ -64,7 +65,7 @@ describe("SessionV2.log", () => { yield* session.rename({ sessionID: created.id, title: "renamed live" }) const items = Array.from(yield* Fiber.join(fiber)) - expect(items.map((item) => item.type)).toEqual(["log.synced", "session.next.renamed"]) + expect(items.map((item) => item.type)).toEqual(["log.synced", "session.renamed"]) }), ) @@ -78,7 +79,7 @@ describe("SessionV2.log", () => { it.effect("reads across undecodable gaps in aggregate order and marks the true log position", () => Effect.gen(function* () { - const GapEvent = EventV2.define({ + const GapEvent = EventV2.durable({ type: "test.session.log.gap", durable: { aggregate: "sessionID", version: 1 }, schema: { sessionID: SessionV2.ID, value: Schema.String }, @@ -137,7 +138,7 @@ describe("SessionV2 watermarks", () => { const events = yield* EventV2.Service const first = yield* session.create({ location }) const second = yield* session.create({ location }) - yield* session.rename({ sessionID: first.id, title: "renamed" }) + yield* session.rename({ sessionID: first.id, title: "session.renamed" }) const page = yield* session.list() const sequences = yield* events.sequences([first.id, second.id]) diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 4191a58715..1d77303d43 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -19,6 +19,7 @@ import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionInput } from "@opencode-ai/core/session/input" +import { Shell } from "@opencode-ai/schema/shell" import { SessionContextCheckpointTable, SessionInputTable, @@ -33,6 +34,7 @@ const sessionsLayer = AppNodeBuilder.build(SessionV2.node, [[SessionExecution.no const sessionID = SessionV2.ID.make("ses_projector_test") const created = DateTime.makeUnsafe(0) const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") } +const previousModel = { ...model, variant: ModelV2.VariantID.make("medium") } const encodeMessage = Schema.encodeSync(SessionMessage.Message) const assistantRow = ( @@ -79,7 +81,6 @@ describe("SessionProjector", () => { const events = yield* EventV2.Service yield* events.publish(SessionEvent.RevertEvent.Staged, { sessionID, - timestamp: DateTime.makeUnsafe(1), revert: { messageID: boundary, snapshot: Snapshot.ID.make("tree"), diff: "patch", files: [] }, }) expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toMatchObject({ @@ -87,17 +88,15 @@ describe("SessionProjector", () => { snapshot: "tree", files: [], }) - yield* events.publish(SessionEvent.RevertEvent.Cleared, { sessionID, timestamp: DateTime.makeUnsafe(2) }) + yield* events.publish(SessionEvent.RevertEvent.Cleared, { sessionID }) expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toBeNull() yield* events.publish(SessionEvent.RevertEvent.Staged, { sessionID, - timestamp: DateTime.makeUnsafe(3), revert: { messageID: boundary, files: [] }, }) yield* events.publish(SessionEvent.RevertEvent.Committed, { sessionID, messageID: boundary, - timestamp: DateTime.makeUnsafe(4), }) expect( (yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id), @@ -131,37 +130,29 @@ describe("SessionProjector", () => { yield* events.publish(SessionEvent.PromptAdmitted, { sessionID, - messageID: SessionMessage.ID.make("msg_first"), - timestamp: created, + inputID: SessionMessage.ID.make("msg_first"), prompt: Prompt.make({ text: "first" }), delivery: "steer", }) yield* events.publish( - SessionEvent.Prompted, + SessionEvent.PromptPromoted, { sessionID, - messageID: SessionMessage.ID.make("msg_first"), - timestamp: created, - prompt: Prompt.make({ text: "first" }), - delivery: "steer", + inputID: SessionMessage.ID.make("msg_first"), }, { id: EventV2.ID.make("evt_z") }, ) yield* events.publish(SessionEvent.PromptAdmitted, { sessionID, - messageID: SessionMessage.ID.make("msg_second"), - timestamp: created, + inputID: SessionMessage.ID.make("msg_second"), prompt: Prompt.make({ text: "second" }), delivery: "steer", }) yield* events.publish( - SessionEvent.Prompted, + SessionEvent.PromptPromoted, { sessionID, - messageID: SessionMessage.ID.make("msg_second"), - timestamp: created, - prompt: Prompt.make({ text: "second" }), - delivery: "steer", + inputID: SessionMessage.ID.make("msg_second"), }, { id: EventV2.ID.make("evt_a") }, ) @@ -190,7 +181,7 @@ describe("SessionProjector", () => { }).pipe(Effect.provide(sessionsLayer)), ) - it.effect("marks an inbox row promoted with the Prompted event sequence", () => + it.effect("marks an inbox row promoted with the PromptPromoted event sequence", () => Effect.gen(function* () { const { db } = yield* Database.Service yield* db @@ -220,12 +211,9 @@ describe("SessionProjector", () => { }) if (!admitted) return yield* Effect.die("Prompt admission failed") - const event = yield* events.publish(SessionEvent.Prompted, { + const event = yield* events.publish(SessionEvent.PromptPromoted, { sessionID, - timestamp: admitted.timeCreated, - messageID: id, - prompt: Prompt.make({ text: "promote me" }), - delivery: "steer", + inputID: id, }) expect( @@ -251,54 +239,59 @@ describe("SessionProjector", () => { directory: "/project", title: "test", version: "test", + model: previousModel, }) .run() .pipe(Effect.orDie) const events = yield* EventV2.Service - yield* events.publish(SessionEvent.AgentSwitched, { + yield* events.publish(SessionEvent.AgentSelected, { sessionID, - messageID: SessionMessage.ID.create(), - timestamp: created, agent: "build", }) - yield* events.publish(SessionEvent.ModelSwitched, { + yield* events.publish(SessionEvent.ModelSelected, { sessionID, - messageID: SessionMessage.ID.create(), - timestamp: created, model, }) yield* events.publish(SessionEvent.Synthetic, { sessionID, - messageID: SessionMessage.ID.create(), - timestamp: created, text: "synthetic context", metadata: { source: "projector-test" }, }) yield* events.publish(SessionEvent.Shell.Started, { sessionID, - messageID: SessionMessage.ID.create(), - timestamp: created, - callID: "shell-1", - command: "pwd", + shell: Shell.Info.make({ + id: Shell.ID.make("sh_projector"), + status: "running", + command: "pwd", + cwd: "/project", + shell: "/bin/sh", + file: "/tmp/sh_projector.out", + metadata: {}, + time: { started: 0 }, + }), }) yield* events.publish(SessionEvent.Shell.Ended, { sessionID, - timestamp: DateTime.makeUnsafe(1), - callID: "shell-1", - output: "/project", + shell: Shell.Info.make({ + id: Shell.ID.make("sh_projector"), + status: "exited", + command: "pwd", + cwd: "/project", + shell: "/bin/sh", + file: "/tmp/sh_projector.out", + exit: 0, + metadata: {}, + time: { started: 0, completed: 1 }, + }), + output: { output: "/project", cursor: 8, size: 8, truncated: false }, }) - const compactionID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Compaction.Started, { sessionID, - messageID: compactionID, - timestamp: created, reason: "manual", }) yield* events.publish(SessionEvent.Compaction.Delta, { sessionID, - messageID: compactionID, - timestamp: created, text: "partial", }) expect( @@ -319,8 +312,6 @@ describe("SessionProjector", () => { ).toEqual([]) yield* events.publish(SessionEvent.Compaction.Ended, { sessionID, - messageID: compactionID, - timestamp: DateTime.makeUnsafe(1), reason: "manual", text: "summary", recent: "recent context", @@ -348,9 +339,11 @@ describe("SessionProjector", () => { text: "synthetic context", metadata: { source: "projector-test" }, }) + expect(messages.find((message) => message.type === "model-switched")).toMatchObject({ previous: previousModel }) expect(messages.find((message) => message.type === "shell")).toMatchObject({ - output: "/project", - time: { completed: DateTime.makeUnsafe(1) }, + shell: { command: "pwd", status: "exited", exit: 0 }, + output: { output: "/project", truncated: false }, + time: { completed: DateTime.makeUnsafe(0) }, }) expect(messages.find((message) => message.type === "compaction")).toMatchObject({ summary: "summary", @@ -388,13 +381,20 @@ describe("SessionProjector", () => { .pipe(Effect.orDie) const events = yield* EventV2.Service const id = SessionMessage.ID.make("msg_creator_collision") + const { + id: _, + type, + ...data + } = encodeMessage({ id, sessionID, type: "synthetic", text: "existing", time: { created } }) + yield* db + .insert(SessionMessageTable) + .values({ id, session_id: sessionID, type, seq: 0, time_created: 0, data }) + .run() - yield* events.publish(SessionEvent.Synthetic, { sessionID, messageID: id, timestamp: created, text: "keep me" }) const exit = yield* events .publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: id, - timestamp: created, agent: "build", model, }) @@ -464,7 +464,6 @@ describe("SessionProjector", () => { const service = yield* EventV2.Service yield* service.publish(SessionEvent.Step.Ended, { sessionID, - timestamp: DateTime.makeUnsafe(1), assistantMessageID: SessionMessage.ID.make("msg_assistant_2"), finish: "stop", cost: 0, @@ -485,7 +484,7 @@ describe("SessionProjector", () => { expect(messages[1]).toMatchObject({ type: "assistant", finish: "stop", - time: { completed: DateTime.makeUnsafe(1) }, + time: { completed: DateTime.makeUnsafe(0) }, }) }), ) @@ -526,7 +525,6 @@ describe("SessionProjector", () => { yield* service.publish(SessionEvent.Text.Started, { sessionID, assistantMessageID: SessionMessage.ID.make("msg_assistant_completed"), - timestamp: DateTime.makeUnsafe(3), textID: "text-stale", }) diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 58e26b7b5c..e7d3f5189f 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -201,7 +201,6 @@ describe("SessionV2.prompt", () => { yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie) yield* events.publish(SessionEvent.RevertEvent.Staged, { sessionID, - timestamp: yield* DateTime.now, revert: { messageID: boundary.id, files: [] }, }) expect((yield* session.get(sessionID)).revert?.messageID).toBe(boundary.id) @@ -257,16 +256,16 @@ describe("SessionV2.prompt", () => { const streamed = Array.from(yield* Fiber.join(fiber)) expect(streamed.map((event): [number | undefined, string] => [event.durable?.seq, event.type])).toEqual([ - [0, "session.next.prompt.admitted"], - [1, "session.next.prompt.admitted"], - [2, "session.next.prompted"], - [3, "session.next.prompted"], + [0, "session.prompt.admitted"], + [1, "session.prompt.admitted"], + [2, "session.prompt.promoted"], + [3, "session.prompt.promoted"], ]) expect( Array.from( yield* publicEvents({ sessionID, after: streamed[0].durable?.seq }).pipe(Stream.take(1), Stream.runCollect), ).map((event): [number | undefined, string] => [event.durable?.seq, event.type]), - ).toEqual([[1, "session.next.prompt.admitted"]]) + ).toEqual([[1, "session.prompt.admitted"]]) }), ) @@ -429,7 +428,7 @@ describe("SessionV2.prompt", () => { { concurrency: "unbounded" }, ) - expect(yield* eventCount(EventV2.versionedType(SessionEvent.Prompted.type, 1))).toBe(1) + expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptPromoted.type, 1))).toBe(1) expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 }) expect(yield* session.messages({ sessionID })).toMatchObject([ { id: messageID, type: "user", text: "Promote once" }, @@ -467,6 +466,7 @@ describe("SessionV2.prompt", () => { yield* events.replayAll( recorded.map((event) => ({ id: event.id, + created: DateTime.makeUnsafe(event.created), aggregateID: event.aggregate_id, seq: event.seq, type: event.type, @@ -514,13 +514,23 @@ describe("SessionV2.prompt", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - const events = yield* EventV2.Service - yield* events.publish(SessionEvent.Synthetic, { + const { db } = yield* Database.Service + const { + id: _, + type, + ...data + } = encodeMessage({ + id: messageID, sessionID, - messageID, - timestamp: yield* DateTime.now, + type: "synthetic", text: "Existing history", + time: { created: DateTime.makeUnsafe(0) }, }) + yield* db + .insert(SessionMessageTable) + .values({ id: messageID, session_id: sessionID, type, seq: 0, time_created: 0, data }) + .run() + .pipe(Effect.orDie) const failure = yield* session .prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "Conflicting prompt" }), resume: false }) diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 5798b665a8..9aed484858 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -7,6 +7,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message" import { AgentAttachment, FileAttachment } from "@opencode-ai/core/session/prompt" import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message" import { SessionV2 } from "@opencode-ai/core/session" +import { Shell } from "@opencode-ai/schema/shell" import { DateTime } from "effect" const created = DateTime.makeUnsafe(0) @@ -51,13 +52,13 @@ describe("toLLMMessages", () => { const file = FileAttachment.make({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" }) const messages = toLLMMessages( [ - SessionMessage.AgentSwitched.make({ + SessionMessage.AgentSelected.make({ id: id("agent"), type: "agent-switched", agent: "build", time: { created }, }), - SessionMessage.ModelSwitched.make({ + SessionMessage.ModelSelected.make({ id: id("model"), type: "model-switched", model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, @@ -87,9 +88,18 @@ describe("toLLMMessages", () => { SessionMessage.Shell.make({ id: id("shell"), type: "shell", - callID: "shell-1", - command: "pwd", - output: "/project", + shell: Shell.Info.make({ + id: Shell.ID.make("sh_test"), + status: "exited", + command: "pwd", + cwd: "/project", + shell: "/bin/sh", + file: "/tmp/sh_test.out", + exit: 0, + metadata: {}, + time: { started: 0, completed: 0 }, + }), + output: { output: "/project", cursor: 8, size: 8, truncated: false }, time: { created, completed: created }, }), SessionMessage.Compaction.make({ @@ -354,7 +364,7 @@ Recent work state: SessionMessage.ToolStateError.make({ status: "error", input: { query: "Effect" }, - error: { type: "unknown", message: "Provider turn interrupted" }, + error: { type: "unknown", message: "Step interrupted" }, content: [], structured: {}, }), @@ -362,7 +372,7 @@ Recent work }), ], finish: "error", - error: { type: "unknown", message: "Provider turn interrupted" }, + error: { type: "unknown", message: "Step interrupted" }, time: { created, completed: created }, }), ], @@ -386,7 +396,7 @@ Recent work result: { type: "error", value: { - error: { type: "unknown", message: "Provider turn interrupted" }, + error: { type: "unknown", message: "Step interrupted" }, content: [], structured: {}, }, diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 0ebe7a6f9e..35e84fcb6c 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -98,7 +98,7 @@ const execution = Layer.effect( Effect.gen(function* () { const sessionRunner = yield* SessionRunner.Service const coordinator = yield* SessionRunCoordinator.make({ - drain: (sessionID, force) => sessionRunner.run({ sessionID, force }), + drain: (sessionID, force) => sessionRunner.drain({ sessionID, force }), }) return SessionExecution.Service.of({ active: coordinator.active, @@ -193,12 +193,12 @@ describe("SessionRunnerLLM recorded", () => { .orderBy(EventTable.seq) .all()).map((event) => event.type), ).toEqual([ - "session.next.prompt.admitted.1", - "session.next.prompted.1", - "session.next.step.started.1", - "session.next.text.started.1", - "session.next.text.ended.1", - "session.next.step.ended.2", + "session.prompt.admitted.1", + "session.prompt.promoted.1", + "session.step.started.1", + "session.text.started.1", + "session.text.ended.1", + "session.step.ended.1", ]) }), ) diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index 048766e439..d016b2694b 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -76,7 +76,7 @@ test("local tool success serializes media base64 once and reconstructs from stru await Effect.runPromise(publisher.publish(call)) await Effect.runPromise(publisher.publish(result)) - const success = published.find((event) => event.type === "session.next.tool.success.1") + const success = published.find((event) => event.type === "session.tool.success.1") expect(success).toBeDefined() const serialized = JSON.stringify(success) expect(serialized.split(base64)).toHaveLength(2) @@ -94,7 +94,7 @@ test("provider-executed success retains its compatibility result", async () => { const { published, publisher } = capture() await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true }))) await Effect.runPromise(publisher.publish(LLMEvent.toolResult({ ...result, providerExecuted: true }))) - const success = published.find((event) => event.type === "session.next.tool.success.1") + const success = published.find((event) => event.type === "session.tool.success.1") expect(success?.data).toHaveProperty("result") }) @@ -110,14 +110,13 @@ test("binary failure emits no success event", async () => { }), ), ) - expect(published.some((event) => event.type === "session.next.tool.success.1")).toBe(false) - expect(published.some((event) => event.type === "session.next.tool.failed.1")).toBe(true) + expect(published.some((event) => event.type === "session.tool.success.1")).toBe(false) + expect(published.some((event) => event.type === "session.tool.failed.1")).toBe(true) }) test("old success event data containing result still decodes", () => { const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({ sessionID, - timestamp: Date.now(), assistantMessageID: SessionMessage.ID.create(), callID: "call-old", structured: { type: "media", mime: "image/png" }, @@ -133,6 +132,6 @@ test("step finish records settlement without publishing step ended", async () => await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 }))) await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: "stop" }))) - expect(published.some((event) => event.type === "session.next.step.ended.2")).toBe(false) + expect(published.some((event) => event.type === "step.ended.2")).toBe(false) expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" }) }) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 45699e7112..cdf1ae37d4 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -21,7 +21,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission" import { EventTable } from "@opencode-ai/core/event/sql" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" -import { QuestionV2 } from "@opencode-ai/core/question" +import { Form } from "@opencode-ai/core/form" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { Snapshot } from "@opencode-ai/core/snapshot" @@ -39,6 +39,7 @@ import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt" import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { QuestionTool } from "@opencode-ai/core/tool/question" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { AgentV2 } from "@opencode-ai/core/agent" import { Config } from "@opencode-ai/core/config" @@ -260,7 +261,7 @@ const execution = Layer.effect( Effect.gen(function* () { const sessionRunner = yield* SessionRunner.Service const coordinator = yield* SessionRunCoordinator.make({ - drain: (sessionID, force) => sessionRunner.run({ sessionID, force }), + drain: (sessionID, force) => sessionRunner.drain({ sessionID, force }), }) return SessionExecution.Service.of({ active: coordinator.active, @@ -276,7 +277,7 @@ const it = testEffect( LayerNode.group([ Database.node, EventV2.node, - QuestionV2.node, + Form.node, SessionProjector.node, SessionStore.node, AgentV2.node, @@ -423,6 +424,7 @@ const replaySessionProjection = (id: SessionV2.ID) => yield* events.replayAll( recorded.map((event) => ({ id: event.id, + created: DateTime.makeUnsafe(event.created), aggregateID: event.aggregate_id, seq: event.seq, type: event.type, @@ -574,7 +576,7 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) => ) const runner = yield* SessionRunner.Service - const fiber = yield* runner.run({ sessionID, force: true }).pipe(Effect.forkChild) + const fiber = yield* runner.drain({ sessionID, force: true }).pipe(Effect.forkChild) yield* Deferred.await(streamed) yield* Fiber.interrupt(fiber) expect(yield* session.context(sessionID)).toMatchObject([ @@ -582,7 +584,7 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) => { type: "assistant", finish: "error", - error: { type: "unknown", message: "Provider turn interrupted" }, + error: { type: "unknown", message: "Step interrupted" }, content: [ kind === "tool input" ? { type: "tool", id: fragmentID(kind, "interrupted"), state: { status: "error" } } @@ -740,7 +742,6 @@ describe("SessionRunnerLLM", () => { yield* events.publish(SessionEvent.Moved, { sessionID, - timestamp: DateTime.makeUnsafe(1), location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }), }) expect( @@ -848,7 +849,7 @@ describe("SessionRunnerLLM", () => { yield* db .select({ id: EventTable.id }) .from(EventTable) - .where(eq(EventTable.type, "session.next.context.updated.1")) + .where(eq(EventTable.type, "session.context.updated.1")) .all() .pipe(Effect.orDie), ).toHaveLength(1) @@ -1010,10 +1011,8 @@ describe("SessionRunnerLLM", () => { response = [] yield* session.resume(sessionID) skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills") - yield* events.publish(SessionEvent.AgentSwitched, { + yield* events.publish(SessionEvent.AgentSelected, { sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), agent: "reviewer", }) yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) @@ -1039,10 +1038,8 @@ describe("SessionRunnerLLM", () => { if (switched) return Effect.void switched = true return events - .publish(SessionEvent.AgentSwitched, { + .publish(SessionEvent.AgentSelected, { sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), agent: "reviewer", }) .pipe(Effect.asVoid) @@ -1069,10 +1066,8 @@ describe("SessionRunnerLLM", () => { if (switched) return Effect.void switched = true return events - .publish(SessionEvent.ModelSwitched, { + .publish(SessionEvent.ModelSelected, { sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, }) .pipe(Effect.asVoid) @@ -1175,10 +1170,8 @@ describe("SessionRunnerLLM", () => { systemBaseline = "Changed context" yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) - yield* events.publish(SessionEvent.ModelSwitched, { + yield* events.publish(SessionEvent.ModelSelected, { sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, }) systemBaseline = "Replacement context" @@ -1217,10 +1210,8 @@ describe("SessionRunnerLLM", () => { requests.length = 0 response = [] yield* session.resume(sessionID) - yield* events.publish(SessionEvent.ModelSwitched, { + yield* events.publish(SessionEvent.ModelSelected, { sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, }) systemUnavailable = true @@ -1252,14 +1243,10 @@ describe("SessionRunnerLLM", () => { const compactionID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Compaction.Started, { sessionID, - messageID: compactionID, - timestamp: DateTime.makeUnsafe(1), reason: "manual", }) yield* events.publish(SessionEvent.Compaction.Ended, { sessionID, - messageID: compactionID, - timestamp: DateTime.makeUnsafe(2), reason: "manual", text: "summary", recent: "", @@ -1482,14 +1469,10 @@ describe("SessionRunnerLLM", () => { const compactionID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Compaction.Started, { sessionID, - messageID: compactionID, - timestamp: DateTime.makeUnsafe(1), reason: "manual", }) yield* events.publish(SessionEvent.Compaction.Ended, { sessionID, - messageID: compactionID, - timestamp: DateTime.makeUnsafe(2), reason: "manual", text: "summary", recent: "", @@ -1686,10 +1669,8 @@ describe("SessionRunnerLLM", () => { toolExecutionsReady = 1 const run = yield* Effect.forkChild(session.resume(sessionID)) yield* Deferred.await(toolExecutionsStarted) - yield* events.publish(SessionEvent.ModelSwitched, { + yield* events.publish(SessionEvent.ModelSelected, { sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, }) systemBaseline = "Replacement context" @@ -2403,27 +2384,23 @@ describe("SessionRunnerLLM", () => { yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID, - timestamp: yield* DateTime.now, agent: "build", model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") }, }) yield* events.publish(SessionEvent.Tool.Input.Started, { sessionID, - timestamp: yield* DateTime.now, assistantMessageID, callID: "call-interrupted", name: "echo", }) yield* events.publish(SessionEvent.Tool.Input.Ended, { sessionID, - timestamp: yield* DateTime.now, assistantMessageID, callID: "call-interrupted", text: '{"text":"stale"}', }) yield* events.publish(SessionEvent.Tool.Called, { sessionID, - timestamp: yield* DateTime.now, assistantMessageID, callID: "call-interrupted", tool: "echo", @@ -2467,27 +2444,23 @@ describe("SessionRunnerLLM", () => { yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID, - timestamp: yield* DateTime.now, agent: "build", model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") }, }) yield* events.publish(SessionEvent.Tool.Input.Started, { sessionID, - timestamp: yield* DateTime.now, assistantMessageID, callID: "call-hosted-interrupted", name: "web_search", }) yield* events.publish(SessionEvent.Tool.Input.Ended, { sessionID, - timestamp: yield* DateTime.now, assistantMessageID, callID: "call-hosted-interrupted", text: '{"query":"stale"}', }) yield* events.publish(SessionEvent.Tool.Called, { sessionID, - timestamp: yield* DateTime.now, assistantMessageID, callID: "call-hosted-interrupted", tool: "web_search", @@ -2527,13 +2500,11 @@ describe("SessionRunnerLLM", () => { yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID, - timestamp: yield* DateTime.now, agent: "build", model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") }, }) yield* events.publish(SessionEvent.Tool.Input.Started, { sessionID, - timestamp: yield* DateTime.now, assistantMessageID, callID: "call-pending-interrupted", name: "echo", @@ -2578,7 +2549,7 @@ describe("SessionRunnerLLM", () => { const events = yield* EventV2.Service const defect = new Error("fail after prompt promotion") let fail = true - yield* events.project(SessionEvent.Prompted, () => (fail ? Effect.die(defect) : Effect.void)) + yield* events.project(SessionEvent.PromptPromoted, () => (fail ? Effect.die(defect) : Effect.void)) yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover promoted input" }), resume: false }) expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect) @@ -2603,7 +2574,9 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service const events = yield* EventV2.Service yield* events.listen((event) => - event.type === SessionEvent.Prompted.type ? Effect.die("fail after prompt promotion commits") : Effect.void, + event.type === SessionEvent.PromptPromoted.type + ? Effect.die("fail after prompt promotion commits") + : Effect.void, ) yield* session.prompt({ sessionID, @@ -2850,19 +2823,17 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("interrupts runner continuation when a question is dismissed", () => + it.effect("interrupts runner continuation when a question is cancelled", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service const registry = yield* ToolRegistry.Service - const questions = yield* QuestionV2.Service yield* registry.register({ question: Tool.make({ description: "Ask the user", input: Schema.Struct({}), output: Schema.Struct({}), - execute: (_, context) => - questions.ask({ sessionID: context.sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie), + execute: () => Effect.die(new QuestionTool.CancelledError()), }), }) yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Ask then stop" }), resume: false }) @@ -2879,12 +2850,6 @@ describe("SessionRunnerLLM", () => { ] const run = yield* session.resume(sessionID).pipe(Effect.exit, Effect.forkChild) - let pending = yield* questions.list() - while (pending.length === 0) { - yield* Effect.yieldNow - pending = yield* questions.list() - } - yield* questions.reject(pending[0]!.id) const exit = yield* Fiber.join(run) expect(exit._tag).toBe("Failure") @@ -3011,9 +2976,9 @@ describe("SessionRunnerLLM", () => { expect(requests).toHaveLength(1) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Interrupt provider" }, - { type: "assistant", finish: "error", error: { type: "unknown", message: "Provider turn interrupted" } }, + { type: "assistant", finish: "error", error: { type: "unknown", message: "Step interrupted" } }, ]) - expect(yield* recordedEventTypes(sessionID)).toContain("session.next.step.failed.2") + expect(yield* recordedEventTypes(sessionID)).toContain("session.step.failed.1") yield* session.interrupt(sessionID) }), ) @@ -3035,7 +3000,7 @@ describe("SessionRunnerLLM", () => { ] const runner = yield* SessionRunner.Service - const run = yield* runner.run({ sessionID, force: true }).pipe(Effect.forkChild) + const run = yield* runner.drain({ sessionID, force: true }).pipe(Effect.forkChild) yield* Deferred.await(toolExecutionsStarted) yield* Fiber.interrupt(run) toolExecutionGate = undefined @@ -3046,7 +3011,7 @@ describe("SessionRunnerLLM", () => { { type: "assistant", finish: "error", - error: { type: "unknown", message: "Provider turn interrupted" }, + error: { type: "unknown", message: "Step interrupted" }, content: [ { type: "tool", @@ -3057,8 +3022,8 @@ describe("SessionRunnerLLM", () => { }, ]) const eventTypes = yield* recordedEventTypes(sessionID) - expect(eventTypes).toContain("session.next.step.failed.2") - expect(eventTypes).not.toContain("session.next.step.ended.2") + expect(eventTypes).toContain("session.step.failed.1") + expect(eventTypes).not.toContain("session.step.ended.1") }), ) @@ -3424,9 +3389,10 @@ describe("SessionRunnerLLM", () => { streamStarted = undefined response = [LLMEvent.textStart({ id: "text-1" }), LLMEvent.textStart({ id: "text-1" })] - expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe( - "Duplicate text start: text-1", - ) + const defect = yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed)) + expect(defect).toBeInstanceOf(Error) + if (!(defect instanceof Error)) return + expect(defect.message).toBe("Duplicate text start: text-1") }), ) @@ -3468,9 +3434,10 @@ describe("SessionRunnerLLM", () => { streamStarted = undefined response = [LLMEvent.toolInputDelta({ id: "call-1", name: "read", text: "{}" })] - expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe( - "Tool input delta before start: call-1", - ) + const defect = yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed)) + expect(defect).toBeInstanceOf(Error) + if (!(defect instanceof Error)) return + expect(defect.message).toBe("Tool input delta before start: call-1") }), ) }) diff --git a/packages/core/test/session-skill.test.ts b/packages/core/test/session-skill.test.ts new file mode 100644 index 0000000000..8f9803ef4a --- /dev/null +++ b/packages/core/test/session-skill.test.ts @@ -0,0 +1,70 @@ +import path from "path" +import { describe, expect } from "bun:test" +import { Effect, Layer, LayerMap } from "effect" +import { Database } from "@opencode-ai/core/database/database" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { EventV2 } from "@opencode-ai/core/event" +import { Location } from "@opencode-ai/core/location" +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import type { LocationServices } from "@opencode-ai/core/location-services" +import { ProjectV2 } from "@opencode-ai/core/project" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionStore } from "@opencode-ai/core/session/store" +import { SkillV2 } from "@opencode-ai/core/skill" +import { testEffect } from "./lib/effect" + +const location = Location.Ref.make({ directory: AbsolutePath.make("/project") }) +const projects = Layer.mock(ProjectV2.Service, { + resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }), +}) +const skills = Layer.mock(SkillV2.Service, { + list: () => + Effect.succeed([ + SkillV2.Info.make({ + name: "effect", + description: "Effect guidance", + location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")), + content: "Use Effect", + }), + ]), +}) +const locations = Layer.effect( + LocationServiceMap.Service, + LayerMap.make( + () => + // The skill endpoint only needs the location-scoped Skill service. + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion + skills as unknown as Layer.Layer, + ), +) +const it = testEffect( + AppNodeBuilder.build( + LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]), + [ + [LocationServiceMap.node, locations], + [ProjectV2.node, projects], + [SessionExecution.node, SessionExecution.noopLayer], + ], + ), +) + +describe("SessionV2.skill", () => { + it.effect("projects the caller-supplied message ID", () => + Effect.gen(function* () { + const sessions = yield* SessionV2.Service + const session = yield* sessions.create({ location }) + const id = SessionMessage.ID.make("msg_caller_skill") + + yield* sessions.skill({ id, sessionID: session.id, skill: "effect", resume: false }) + + expect(yield* sessions.messages({ sessionID: session.id })).toContainEqual( + expect.objectContaining({ id, type: "skill", name: "effect", text: "Use Effect" }), + ) + }), + ) +}) diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index 1e9bcfbeb8..59abb8fabc 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -86,17 +86,13 @@ const prompt = (sessionID: SessionV2.ID, text: string) => const messageID = SessionMessage.ID.create() yield* events.publish(SessionEvent.PromptAdmitted, { sessionID, - messageID, - timestamp: DateTime.makeUnsafe(0), + inputID: messageID, prompt: Prompt.make({ text }), delivery: "steer", }) - yield* events.publish(SessionEvent.Prompted, { + yield* events.publish(SessionEvent.PromptPromoted, { sessionID, - messageID, - timestamp: DateTime.makeUnsafe(0), - prompt: Prompt.make({ text }), - delivery: "steer", + inputID: messageID, }) }) diff --git a/packages/core/test/session-tool-progress.test.ts b/packages/core/test/session-tool-progress.test.ts index 6d07b14657..e730b3556b 100644 --- a/packages/core/test/session-tool-progress.test.ts +++ b/packages/core/test/session-tool-progress.test.ts @@ -51,7 +51,6 @@ describe("Tool.Progress", () => { yield* service.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID, - timestamp, agent: "build", model, }) @@ -69,14 +68,12 @@ describe("Tool.Progress", () => { Effect.gen(function* () { yield* service.publish(SessionEvent.Tool.Input.Started, { sessionID, - timestamp, assistantMessageID, callID, name: "bash", }) yield* service.publish(SessionEvent.Tool.Called, { sessionID, - timestamp, assistantMessageID, callID, tool: "bash", @@ -92,7 +89,6 @@ describe("Tool.Progress", () => { yield* service.publish(SessionEvent.Tool.Progress, { sessionID, - timestamp, assistantMessageID, callID: "call-success", structured: { phase: "checkpoint" }, @@ -104,7 +100,6 @@ describe("Tool.Progress", () => { const success = yield* service.publish(SessionEvent.Tool.Success, { sessionID, - timestamp, assistantMessageID, callID: "call-success", structured: { phase: "done" }, @@ -118,7 +113,6 @@ describe("Tool.Progress", () => { yield* start("call-failed") yield* service.publish(SessionEvent.Tool.Progress, { sessionID, - timestamp, assistantMessageID, callID: "call-failed", structured: { phase: "checkpoint" }, @@ -126,7 +120,6 @@ describe("Tool.Progress", () => { }) const failed = yield* service.publish(SessionEvent.Tool.Failed, { sessionID, - timestamp, assistantMessageID, callID: "call-failed", error: { type: "unknown", message: "boom" }, diff --git a/packages/core/test/shared-schema.test.ts b/packages/core/test/shared-schema.test.ts index e7556a8b7c..7d227aac19 100644 --- a/packages/core/test/shared-schema.test.ts +++ b/packages/core/test/shared-schema.test.ts @@ -22,14 +22,12 @@ import { FileSystem } from "@opencode-ai/schema/filesystem" import { Integration } from "@opencode-ai/schema/integration" import { LLM } from "@opencode-ai/schema/llm" import { Permission } from "@opencode-ai/schema/permission" -import { Plugin } from "@opencode-ai/schema/plugin" import { Pty } from "@opencode-ai/schema/pty" import { Reference } from "@opencode-ai/schema/reference" import { SessionTodo } from "@opencode-ai/schema/session-todo" import { Skill } from "@opencode-ai/schema/skill" import { AbsolutePath, DateTimeUtcFromMillis, optional, statics } from "@opencode-ai/schema/schema" import { ProviderV2 } from "@opencode-ai/core/provider" -import { PluginV2 } from "@opencode-ai/core/plugin" test("Core reuses the canonical shared schemas", async () => { const [ @@ -129,8 +127,6 @@ test("Core reuses the canonical shared schemas", async () => { [corePermission.Ruleset, Permission.Ruleset], [corePermissionV1.Event, PermissionV1.Event], [coreProjectCopy.Event, ProjectDirectories.Event], - [PluginV2.ID, Plugin.ID], - [PluginV2.Event, Plugin.Event], [corePty.Info, Pty.Info], [corePty.Event, Pty.Event], [coreProject.ID, Project.ID], @@ -148,8 +144,8 @@ test("Core reuses the canonical shared schemas", async () => { [coreSessionInput.Admitted, SessionInput.Admitted], [coreSessionMessage.ID, SessionMessage.ID], [coreSessionMessage.UnknownError, SessionMessage.UnknownError], - [coreSessionMessage.AgentSwitched, SessionMessage.AgentSwitched], - [coreSessionMessage.ModelSwitched, SessionMessage.ModelSwitched], + [coreSessionMessage.AgentSelected, SessionMessage.AgentSelected], + [coreSessionMessage.ModelSelected, SessionMessage.ModelSelected], [coreSessionMessage.User, SessionMessage.User], [coreSessionMessage.Synthetic, SessionMessage.Synthetic], [coreSessionMessage.System, SessionMessage.System], diff --git a/packages/core/test/skill-discovery.test.ts b/packages/core/test/skill-discovery.test.ts index e875beb931..28071c926f 100644 --- a/packages/core/test/skill-discovery.test.ts +++ b/packages/core/test/skill-discovery.test.ts @@ -1,36 +1,42 @@ import fs from "fs/promises" import path from "path" import { describe, expect, test } from "bun:test" -import { Effect, Layer } from "effect" -import { HttpClient, HttpClientResponse } from "effect/unstable/http" +import { Effect } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Global } from "@opencode-ai/core/global" import { SkillDiscovery } from "@opencode-ai/core/skill/discovery" import { tmpdir } from "./fixture/tmpdir" -const base = "https://skills.example.test/catalog/" +type Fixture = { + tmp: Awaited> + server: Bun.Server + state: { + skills: unknown[] + files: Record + requests: string[] + } + base: string +} -async function pull(skills: unknown[], files: Record = {}, cache?: Awaited>) { - const tmp = cache ?? (await tmpdir()) - const requests: string[] = [] - const http = Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.sync(() => requests.push(request.url)).pipe( - Effect.map(() => { - const body = request.url === `${base}index.json` ? JSON.stringify({ skills }) : files[request.url] - return HttpClientResponse.fromWeb( - request, - new Response(body ?? "Not Found", { status: body === undefined ? 404 : 200 }), - ) - }), - ), - ), - ) +async function pull(skills: unknown[], files: Record = {}, fixture?: Fixture) { + const state = fixture?.state ?? { skills, files, requests: [] } + state.skills = skills + state.files = files + state.requests = [] + const server = + fixture?.server ?? + Bun.serve({ + port: 0, + fetch(request) { + state.requests.push(request.url) + const pathname = new URL(request.url).pathname + const body = pathname === "/catalog/index.json" ? JSON.stringify({ skills: state.skills }) : state.files[pathname] + return new Response(body ?? "Not Found", { status: body === undefined ? 404 : 200 }) + }, + }) + const tmp = fixture?.tmp ?? (await tmpdir()) + const base = fixture?.base ?? new URL("/catalog/", server.url).href const skillDiscoveryLayer = AppNodeBuilder.build(SkillDiscovery.node, [ - [LayerNodePlatform.httpClient, http], [Global.node, Global.layerWith({ cache: tmp.path })], ]) const directories = await Effect.runPromise( @@ -38,7 +44,12 @@ async function pull(skills: unknown[], files: Record = {}, cache return yield* (yield* SkillDiscovery.Service).pull(base) }).pipe(Effect.provide(skillDiscoveryLayer)), ) - return { tmp, requests, directories } + return { tmp, server, state, base, requests: state.requests, directories } +} + +async function dispose(fixture: Fixture) { + await fixture.server.stop(true) + await fixture.tmp[Symbol.asyncDispose]() } describe("SkillDiscovery.pull", () => { @@ -46,10 +57,10 @@ describe("SkillDiscovery.pull", () => { const result = await pull([{ name: "../outside", files: ["SKILL.md"] }]) try { expect(result.directories).toEqual([]) - expect(result.requests).toEqual([`${base}index.json`]) + expect(result.requests).toEqual([`${result.base}index.json`]) expect(await fs.readdir(result.tmp.path)).toEqual([]) } finally { - await result.tmp[Symbol.asyncDispose]() + await dispose(result) } }) @@ -57,10 +68,10 @@ describe("SkillDiscovery.pull", () => { const result = await pull([{ name: "deploy", files: ["SKILL.md", "../outside.md"] }]) try { expect(result.directories).toEqual([]) - expect(result.requests).toEqual([`${base}index.json`]) + expect(result.requests).toEqual([`${result.base}index.json`]) expect(await fs.readdir(result.tmp.path)).toEqual([]) } finally { - await result.tmp[Symbol.asyncDispose]() + await dispose(result) } }) @@ -68,10 +79,10 @@ describe("SkillDiscovery.pull", () => { const result = await pull([{ name: "deploy", files: ["SKILL.md", "/tmp/outside.md"] }]) try { expect(result.directories).toEqual([]) - expect(result.requests).toEqual([`${base}index.json`]) + expect(result.requests).toEqual([`${result.base}index.json`]) expect(await fs.readdir(result.tmp.path)).toEqual([]) } finally { - await result.tmp[Symbol.asyncDispose]() + await dispose(result) } }) @@ -79,87 +90,87 @@ describe("SkillDiscovery.pull", () => { const result = await pull([{ name: "deploy", files: ["SKILL.md", "https://evil.example.test/outside.md"] }]) try { expect(result.directories).toEqual([]) - expect(result.requests).toEqual([`${base}index.json`]) + expect(result.requests).toEqual([`${result.base}index.json`]) expect(await fs.readdir(result.tmp.path)).toEqual([]) } finally { - await result.tmp[Symbol.asyncDispose]() + await dispose(result) } }) test("downloads safe nested files under the skill root", async () => { const result = await pull([{ name: "deploy", files: ["SKILL.md", "references/guide.md"] }], { - [`${base}deploy/SKILL.md`]: "# Deploy", - [`${base}deploy/references/guide.md`]: "# Guide", + "/catalog/deploy/SKILL.md": "# Deploy", + "/catalog/deploy/references/guide.md": "# Guide", }) try { expect(result.directories).toHaveLength(1) expect(result.requests.toSorted()).toEqual( - [`${base}index.json`, `${base}deploy/SKILL.md`, `${base}deploy/references/guide.md`].toSorted(), + [ + `${result.base}index.json`, + `${result.base}deploy/SKILL.md`, + `${result.base}deploy/references/guide.md`, + ].toSorted(), ) expect(await fs.readFile(path.join(result.directories[0], "SKILL.md"), "utf8")).toBe("# Deploy") expect(await fs.readFile(path.join(result.directories[0], "references", "guide.md"), "utf8")).toBe("# Guide") } finally { - await result.tmp[Symbol.asyncDispose]() + await dispose(result) } }) test("refreshes cached files when the version changes", async () => { - const tmp = await tmpdir() + const first = await pull( + [{ name: "deploy", version: "1", files: ["SKILL.md"] }], + { "/catalog/deploy/SKILL.md": "# Old" }, + ) try { - const first = await pull( - [{ name: "deploy", version: "1", files: ["SKILL.md"] }], - { - [`${base}deploy/SKILL.md`]: "# Old", - }, - tmp, - ) const second = await pull( [{ name: "deploy", version: "2", files: ["SKILL.md"] }], - { - [`${base}deploy/SKILL.md`]: "# New", - }, - tmp, + { "/catalog/deploy/SKILL.md": "# New" }, + first, ) expect(await fs.readFile(path.join(first.directories[0], "SKILL.md"), "utf8")).toBe("# New") - expect(second.requests).toContain(`${base}deploy/SKILL.md`) + expect(second.requests).toContain(`${first.base}deploy/SKILL.md`) const third = await pull( [{ name: "deploy", version: "2", files: ["SKILL.md"] }], - { [`${base}deploy/SKILL.md`]: "# Ignored" }, - tmp, + { "/catalog/deploy/SKILL.md": "# Ignored" }, + first, ) - expect(third.requests).toEqual([`${base}index.json`]) + expect(third.requests).toEqual([`${first.base}index.json`]) } finally { - await tmp[Symbol.asyncDispose]() + await dispose(first) } }) test("publishes complete updates and removes stale files", async () => { - const tmp = await tmpdir() + const first = await pull( + [{ name: "deploy", version: "1", files: ["SKILL.md", "old.md"] }], + { + "/catalog/deploy/SKILL.md": "# Old", + "/catalog/deploy/old.md": "old reference", + }, + ) try { - const first = await pull( - [{ name: "deploy", version: "1", files: ["SKILL.md", "old.md"] }], - { - [`${base}deploy/SKILL.md`]: "# Old", - [`${base}deploy/old.md`]: "old reference", - }, - tmp, - ) const root = first.directories[0] await pull( [{ name: "deploy", version: "2", files: ["SKILL.md", "missing.md"] }], - { [`${base}deploy/SKILL.md`]: "# Partial" }, - tmp, + { "/catalog/deploy/SKILL.md": "# Partial" }, + first, ) expect(await fs.readFile(path.join(root, "SKILL.md"), "utf8")).toBe("# Old") expect(await fs.readFile(path.join(root, "old.md"), "utf8")).toBe("old reference") - await pull([{ name: "deploy", version: "3", files: ["SKILL.md"] }], { [`${base}deploy/SKILL.md`]: "# New" }, tmp) + await pull( + [{ name: "deploy", version: "3", files: ["SKILL.md"] }], + { "/catalog/deploy/SKILL.md": "# New" }, + first, + ) expect(await fs.readFile(path.join(root, "SKILL.md"), "utf8")).toBe("# New") expect(await Bun.file(path.join(root, "old.md")).exists()).toBe(false) } finally { - await tmp[Symbol.asyncDispose]() + await dispose(first) } }) }) diff --git a/packages/core/test/skill.test.ts b/packages/core/test/skill.test.ts index 38ca4cfb92..7e57610afa 100644 --- a/packages/core/test/skill.test.ts +++ b/packages/core/test/skill.test.ts @@ -10,7 +10,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { AbsolutePath } from "@opencode-ai/core/schema" import { SkillV2 } from "@opencode-ai/core/skill" import { SkillDiscovery } from "@opencode-ai/core/skill/discovery" -import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher" +import { FileSystem } from "@opencode-ai/schema/filesystem" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" @@ -206,7 +206,7 @@ metadata: waitForSkillUpdate(), ({ deferred }) => events - .publish(FileSystemWatcher.Event.Updated, { file, event: "change" }) + .publish(FileSystem.Event.Changed, { file, event: "change" }) .pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")), ({ fiber }) => Fiber.interrupt(fiber), ) diff --git a/packages/core/test/tool-apply-patch.test.ts b/packages/core/test/tool-apply-patch.test.ts index 8084cbc24e..a717b799e3 100644 --- a/packages/core/test/tool-apply-patch.test.ts +++ b/packages/core/test/tool-apply-patch.test.ts @@ -16,8 +16,15 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ApplyPatchTool } from "@opencode-ai/core/tool/apply-patch" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" +import { makeLocationNode } from "@opencode-ai/core/effect/app-node" import { testEffect } from "./lib/effect" -import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" +import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool" + +const applyPatchToolNode = makeLocationNode({ + name: "test/apply-patch-tool-plugin", + layer: Layer.effectDiscard(registerToolPlugin(ApplyPatchTool.Plugin)), + deps: [ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node], +}) const sessionID = SessionV2.ID.make("ses_apply_patch_tool_test") const assertions: PermissionV2.AssertInput[] = [] @@ -101,7 +108,7 @@ const withTool = (directory: string, body: (registry: ToolRegistry.Inte ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, - ApplyPatchTool.node, + applyPatchToolNode, ]), [ [FSUtil.node, filesystem], diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index 2c684523b7..9df73c9b7d 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -17,8 +17,15 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { EditTool } from "@opencode-ai/core/tool/edit" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" +import { makeLocationNode } from "@opencode-ai/core/effect/app-node" import { testEffect } from "./lib/effect" -import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" +import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool" + +const editToolNode = makeLocationNode({ + name: "test/edit-tool-plugin", + layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)), + deps: [ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node], +}) const sessionID = SessionV2.ID.make("ses_edit_tool_test") const assertions: PermissionV2.AssertInput[] = [] @@ -91,7 +98,7 @@ const withTool = (directory: string, body: (registry: ToolRegistry.Inte ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, - EditTool.node, + editToolNode, ]), [ [FSUtil.node, filesystem], diff --git a/packages/core/test/tool-question.test.ts b/packages/core/test/tool-question.test.ts index 5c5c95da61..b9d49e892e 100644 --- a/packages/core/test/tool-question.test.ts +++ b/packages/core/test/tool-question.test.ts @@ -1,19 +1,20 @@ import { describe, expect } from "bun:test" -import { Effect, Exit, Fiber, Layer } from "effect" +import { Cause, Effect, Exit, Fiber, Layer } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Form } from "@opencode-ai/core/form" import { PermissionV2 } from "@opencode-ai/core/permission" -import { QuestionV2 } from "@opencode-ai/core/question" import { SessionV2 } from "@opencode-ai/core/session" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { QuestionTool } from "@opencode-ai/core/tool/question" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { testEffect } from "./lib/effect" -import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" +import { makeLocationNode } from "@opencode-ai/core/effect/app-node" +import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool" const sessionID = SessionV2.ID.make("ses_question_tool_test") const assertions: PermissionV2.AssertInput[] = [] -let captured: QuestionV2.AskInput | undefined +let captured: Form.CreateInput | undefined let reject = false let deny = false const capturedInput = () => captured @@ -31,22 +32,38 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const question = Layer.succeed( - QuestionV2.Service, - QuestionV2.Service.of({ - ask: (input: QuestionV2.AskInput) => +const form = Layer.succeed( + Form.Service, + Form.Service.of({ + ask: (input: Form.CreateInput) => Effect.sync(() => { captured = input - }).pipe(Effect.andThen(reject ? Effect.fail(new QuestionV2.RejectedError()) : Effect.succeed([["Build"], []]))), - reply: () => Effect.die("unused"), - reject: () => Effect.die("unused"), + }).pipe( + Effect.andThen( + Effect.sync( + (): Form.TerminalState => + reject ? { status: "cancelled" } : { status: "answered", answer: { q0: "Build", q1: ["Dev"] } }, + ), + ), + ), + create: () => Effect.die("unused"), + get: () => Effect.die("unused"), list: () => Effect.die("unused"), + state: () => Effect.die("unused"), + reply: () => Effect.die("unused"), + cancel: () => Effect.die("unused"), }), ) +const questionToolNode = makeLocationNode({ + name: "test/question-tool-plugin", + layer: Layer.effectDiscard(registerToolPlugin(QuestionTool.Plugin)), + deps: [ToolRegistry.toolsNode, PermissionV2.node, Form.node], +}) + const it = testEffect( - AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, QuestionTool.node]), [ + AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, questionToolNode]), [ [PermissionV2.node, permission], - [QuestionV2.node, question], + [Form.node, form], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], ]), ) @@ -88,6 +105,12 @@ describe("QuestionTool", () => { question: "Which environment?", header: "Environment", options: [{ label: "Dev", description: "Development" }], + multiple: true, + }, + { + question: "Anything else?", + header: "Optional", + options: [], }, ] @@ -102,14 +125,14 @@ describe("QuestionTool", () => { result: { type: "text", value: - 'User has answered your questions: "What should happen?"="Build", "Which environment?"="Unanswered". You can now continue with the user\'s answers in mind.', + 'User has answered your questions: "What should happen?"="Build", "Which environment?"="Dev", "Anything else?"="Unanswered". You can now continue with the user\'s answers in mind.', }, output: { - structured: { answers: [["Build"], []] }, + structured: { answers: [["Build"], ["Dev"], []] }, content: [ { type: "text", - text: 'User has answered your questions: "What should happen?"="Build", "Which environment?"="Unanswered". You can now continue with the user\'s answers in mind.', + text: 'User has answered your questions: "What should happen?"="Build", "Which environment?"="Dev", "Anything else?"="Unanswered". You can now continue with the user\'s answers in mind.', }, ], }, @@ -117,8 +140,34 @@ describe("QuestionTool", () => { expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }]) expect(capturedInput()).toEqual({ sessionID, - questions, - tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" }, + metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } }, + mode: "form", + fields: [ + { + key: "q0", + title: "Action", + description: "What should happen?", + options: [{ value: "Build", label: "Build", description: "Build it" }], + custom: true, + type: "string", + }, + { + key: "q1", + title: "Environment", + description: "Which environment?", + options: [{ value: "Dev", label: "Dev", description: "Development" }], + custom: true, + type: "multiselect", + }, + { + key: "q2", + title: "Optional", + description: "Anything else?", + options: [], + custom: true, + type: "string", + }, + ], }) }), ) @@ -137,8 +186,9 @@ describe("QuestionTool", () => { }) expect(capturedInput()).toEqual({ sessionID, - questions: [], - tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" }, + metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } }, + mode: "form", + fields: [], }) }), ) @@ -157,6 +207,11 @@ describe("QuestionTool", () => { const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) + expect(error).toBeInstanceOf(QuestionTool.CancelledError) + expect(error).toHaveProperty("message", "The user dismissed this question") + } }), ) }) diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index 3c94888d57..f6f60df485 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -19,8 +19,25 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ReadTool } from "@opencode-ai/core/tool/read" import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem" +import { makeLocationNode } from "@opencode-ai/core/effect/app-node" +import { SessionInstructions } from "@opencode-ai/core/session/instructions" import { testEffect } from "./lib/effect" -import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" +import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool" + +const readToolNode = makeLocationNode({ + name: "test/read-tool-plugin", + layer: Layer.effectDiscard(registerToolPlugin(ReadTool.Plugin)), + deps: [ + ToolRegistry.toolsNode, + ReadToolFileSystem.node, + LocationMutation.node, + Image.node, + PermissionV2.node, + SessionInstructions.node, + FSUtil.node, + Location.node, + ], +}) const assertions: PermissionV2.AssertInput[] = [] const missingPath = "__missing_read_target__.txt" @@ -130,7 +147,7 @@ const unavailableImage = Layer.succeed( Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }), ) const readLayer = (imageLayer: Layer.Layer) => - AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, ReadTool.node]), [ + AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, readToolNode]), [ [ReadToolFileSystem.node, reader], [PermissionV2.node, permission], [Config.node, config], diff --git a/packages/core/test/tool-search.test.ts b/packages/core/test/tool-search.test.ts new file mode 100644 index 0000000000..b1bad34102 --- /dev/null +++ b/packages/core/test/tool-search.test.ts @@ -0,0 +1,87 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { makeLocationNode } from "@opencode-ai/core/effect/app-node" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Location } from "@opencode-ai/core/location" +import { PermissionV2 } from "@opencode-ai/core/permission" +import { Ripgrep } from "@opencode-ai/core/ripgrep" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { GlobTool } from "@opencode-ai/core/tool/glob" +import { GrepTool } from "@opencode-ai/core/tool/grep" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import { location } from "./fixture/location" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" +import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool" + +const globToolNode = makeLocationNode({ + name: "test/glob-tool-plugin", + layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)), + deps: [ToolRegistry.toolsNode, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node], +}) +const grepToolNode = makeLocationNode({ + name: "test/grep-tool-plugin", + layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)), + deps: [ToolRegistry.toolsNode, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node], +}) +const permission = Layer.succeed( + PermissionV2.Service, + PermissionV2.Service.of({ + assert: () => Effect.void, + ask: () => Effect.die("unused"), + reply: () => Effect.die("unused"), + get: () => Effect.die("unused"), + forSession: () => Effect.die("unused"), + list: () => Effect.die("unused"), + }), +) +const sessionID = SessionV2.ID.make("ses_search_tool_test") + +const withTools = (directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect) => + Effect.gen(function* () { + return yield* body(yield* ToolRegistry.Service) + }).pipe( + Effect.provide( + AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, globToolNode, grepToolNode]), [ + [ + Location.node, + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), + ], + [PermissionV2.node, permission], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ]), + ), + ) + +const call = (name: "glob" | "grep", input: unknown) => ({ + sessionID, + ...toolIdentity, + call: { type: "tool-call" as const, id: `call-${name}`, name, input }, +}) + +const it = testEffect(Layer.empty) + +describe("search tools", () => { + for (const name of ["glob", "grep"] as const) { + it.live(`${name} reports a missing search path`, () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + withTools(tmp.path, (registry) => + Effect.gen(function* () { + const result = yield* executeTool( + registry, + call(name, { path: "missing", pattern: name === "glob" ? "*" : "needle" }), + ) + expect(result).toEqual({ type: "error", value: "Search path does not exist: missing" }) + }), + ), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + } +}) diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 15bd741a2e..117eba27f1 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -79,27 +79,23 @@ const executionNode = makeGlobalNode({ yield* events.publish(SessionEvent.Step.Started, { sessionID: id, assistantMessageID, - timestamp: yield* DateTime.now, agent: session.agent ?? AgentV2.ID.make("code"), model: sessionModel, }) yield* events.publish(SessionEvent.Text.Started, { sessionID: id, assistantMessageID, - timestamp: yield* DateTime.now, textID, }) yield* events.publish(SessionEvent.Text.Ended, { sessionID: id, assistantMessageID, - timestamp: yield* DateTime.now, textID, text: "ok", }) yield* events.publish(SessionEvent.Step.Ended, { sessionID: id, assistantMessageID, - timestamp: yield* DateTime.now, finish: "stop", cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, @@ -175,9 +171,11 @@ const withSession = (directory: string, body: (registry: ToolRegistry.I }) const locations = yield* LocationServiceMap.Service const locationLayer = locations.get(location) - const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locationLayer)) - yield* waitForTool(registry, ShellTool.name) - return yield* body(registry).pipe(Effect.provide(locationLayer)) + return yield* Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + yield* waitForTool(registry, ShellTool.name) + return yield* body(registry) + }).pipe(Effect.provide(locationLayer), Effect.ensuring(locations.invalidate(location))) }) describe("ShellTool", () => { @@ -482,9 +480,13 @@ describe("ShellTool", () => { const structured = settled.output?.structured as Record | undefined const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined expect(settled.output?.structured).toMatchObject({ truncated: false }) - expect(settled.output?.content[0]).toMatchObject({ + expect(settled.output?.content[0]).toEqual({ type: "text", - text: expect.stringContaining("running in the background"), + text: "The command was moved to the background.", + }) + expect(settled.output?.content[1]).toMatchObject({ + type: "text", + text: expect.stringContaining("DO NOT sleep, poll"), }) expect(shellID).toStartWith("sh_") diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index 4831acb799..23e51f6d08 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -13,7 +13,15 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { tmpdir } from "./fixture/tmpdir" import { it } from "./lib/effect" -import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" +import { makeLocationNode } from "@opencode-ai/core/effect/app-node" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool" + +const skillToolNode = makeLocationNode({ + name: "test/skill-tool-plugin", + layer: Layer.effectDiscard(registerToolPlugin(SkillTool.Plugin)), + deps: [ToolRegistry.toolsNode, FSUtil.node, SkillV2.node, PermissionV2.node], +}) const sessionID = SessionV2.ID.make("ses_skill_tool_test") @@ -66,7 +74,7 @@ describe("SkillTool", () => { }), ) const skillToolLayer = AppNodeBuilder.build( - LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, SkillTool.node]), + LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, skillToolNode]), [ [PermissionV2.node, permission], [SkillV2.node, skills], diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index 6274e329aa..4dd46da691 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -53,27 +53,23 @@ const executionNode = makeGlobalNode({ yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID, - timestamp: yield* DateTime.now, agent: AgentV2.ID.make("reviewer"), model: childModel, }) yield* events.publish(SessionEvent.Text.Started, { sessionID, assistantMessageID, - timestamp: yield* DateTime.now, textID, }) yield* events.publish(SessionEvent.Text.Ended, { sessionID, assistantMessageID, - timestamp: yield* DateTime.now, textID, text: childText, }) yield* events.publish(SessionEvent.Step.Ended, { sessionID, assistantMessageID, - timestamp: yield* DateTime.now, finish: "stop", cost: 0, tokens, diff --git a/packages/core/test/tool-todowrite.test.ts b/packages/core/test/tool-todowrite.test.ts index 170d1230a3..977ece9fc8 100644 --- a/packages/core/test/tool-todowrite.test.ts +++ b/packages/core/test/tool-todowrite.test.ts @@ -15,7 +15,14 @@ import { TodoWriteTool } from "@opencode-ai/core/tool/todowrite" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { testEffect } from "./lib/effect" -import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" +import { makeLocationNode } from "@opencode-ai/core/effect/app-node" +import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool" + +const todoWriteToolNode = makeLocationNode({ + name: "test/todowrite-tool-plugin", + layer: Layer.effectDiscard(registerToolPlugin(TodoWriteTool.Plugin)), + deps: [ToolRegistry.toolsNode, PermissionV2.node, SessionTodo.node], +}) const sessionID = SessionV2.ID.make("ses_todowrite_tool_test") const assertions: PermissionV2.AssertInput[] = [] @@ -43,7 +50,7 @@ const it = testEffect( SessionTodo.node, ToolRegistry.node, ToolRegistry.toolsNode, - TodoWriteTool.node, + todoWriteToolNode, ]), [ [PermissionV2.node, permission], diff --git a/packages/core/test/tool-webfetch.test.ts b/packages/core/test/tool-webfetch.test.ts index 2fe46f0ae5..421c09f876 100644 --- a/packages/core/test/tool-webfetch.test.ts +++ b/packages/core/test/tool-webfetch.test.ts @@ -10,8 +10,15 @@ import { SessionV2 } from "@opencode-ai/core/session" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { WebFetchTool } from "@opencode-ai/core/tool/webfetch" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import { makeLocationNode } from "@opencode-ai/core/effect/app-node" import { testEffect } from "./lib/effect" -import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" +import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool" + +const webFetchToolNode = makeLocationNode({ + name: "test/webfetch-tool-plugin", + layer: Layer.effectDiscard(registerToolPlugin(WebFetchTool.Plugin)), + deps: [ToolRegistry.toolsNode, PermissionV2.node, LayerNodePlatform.httpClient], +}) const sessionID = SessionV2.ID.make("ses_webfetch_test") const requests: Array<{ readonly url: string; readonly headers: Record }> = [] @@ -40,7 +47,7 @@ const permission = Layer.succeed( }), ) const toolLayer = (replacements: LayerNode.Replacements = []) => - AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebFetchTool.node]), [ + AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, webFetchToolNode]), [ [PermissionV2.node, permission], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], ...replacements, diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index 0b99a80ecb..53984ef69b 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -9,8 +9,15 @@ import { SessionV2 } from "@opencode-ai/core/session" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { WebSearchTool } from "@opencode-ai/core/tool/websearch" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import { makeLocationNode } from "@opencode-ai/core/effect/app-node" import { testEffect } from "./lib/effect" -import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" +import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool" + +const webSearchToolNode = makeLocationNode({ + name: "test/websearch-tool-plugin", + layer: Layer.effectDiscard(registerToolPlugin(WebSearchTool.Plugin)), + deps: [ToolRegistry.toolsNode, PermissionV2.node, LayerNodePlatform.httpClient, WebSearchTool.configNode], +}) const sessionID = SessionV2.ID.make("ses_websearch_test") const payload = (text: string) => @@ -125,7 +132,7 @@ const websearchConfig = Layer.succeed( ) const it = testEffect( AppNodeBuilder.build( - LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, WebSearchTool.node]), + LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, webSearchToolNode]), [ [PermissionV2.node, permission], [LayerNodePlatform.httpClient, http], diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index 2bd950f8a0..80fb011cc2 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -17,8 +17,15 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { WriteTool } from "@opencode-ai/core/tool/write" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" +import { makeLocationNode } from "@opencode-ai/core/effect/app-node" import { testEffect } from "./lib/effect" -import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" +import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool" + +const writeToolNode = makeLocationNode({ + name: "test/write-tool-plugin", + layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)), + deps: [ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, PermissionV2.node], +}) const sessionID = SessionV2.ID.make("ses_write_tool_test") const assertions: PermissionV2.AssertInput[] = [] @@ -75,7 +82,7 @@ const withTool = (directory: string, body: (registry: ToolRegistry.Inte ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, - WriteTool.node, + writeToolNode, ]), [ [FSUtil.node, filesystem], diff --git a/packages/docs/LICENSE b/packages/docs/LICENSE deleted file mode 100644 index 5411374274..0000000000 --- a/packages/docs/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2023 Mintlify - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file diff --git a/packages/docs/README.md b/packages/docs/README.md index 17956df329..792f64b3f8 100644 --- a/packages/docs/README.md +++ b/packages/docs/README.md @@ -1,44 +1,24 @@ -# Mintlify Starter Kit +# OpenCode documentation -Use the starter kit to get your docs deployed and ready to customize. +The V2 documentation is a Mintlify site deployed from `packages/docs` on the `dev` branch. -Click the green **Use this template** button at the top of this repo to copy the Mintlify starter kit. The starter kit contains examples with +## Local preview -- Guide pages -- Navigation -- Customizations -- API reference pages -- Use of popular components +The Mintlify CLI requires Node.js 20 through 24. -**[Follow the full quickstart guide](https://starter.mintlify.com/quickstart)** +From this directory, run: -## Development - -Install the [Mintlify CLI](https://www.npmjs.com/package/mint) to preview your documentation changes locally. To install, use the following command: - -``` -npm i -g mint +```bash +npx mint dev ``` -Run the following command at the root of your documentation, where your `docs.json` is located: +The preview opens at `http://localhost:3000` and reloads when MDX or `docs.json` changes. -``` -mint dev +Validate changes before opening a pull request: + +```bash +npx mint validate +npx mint broken-links ``` -View your local preview at `http://localhost:3000`. - -## Publishing changes - -Install our GitHub app from your [dashboard](https://dashboard.mintlify.com/settings/organization/github-app) to propagate changes from your repo to your deployment. Changes are deployed to production automatically after pushing to the default branch. - -## Need help? - -### Troubleshooting - -- If your dev environment isn't running: Run `mint update` to ensure you have the most recent version of the CLI. -- If a page loads as a 404: Make sure you are running in a folder with a valid `docs.json`. - -### Resources - -- [Mintlify documentation](https://mintlify.com/docs) +The hosted preview is available at [opencode.mintlify.site](https://opencode.mintlify.site). diff --git a/packages/docs/ai-tools/claude-code.mdx b/packages/docs/ai-tools/claude-code.mdx deleted file mode 100644 index 4039c6e0ea..0000000000 --- a/packages/docs/ai-tools/claude-code.mdx +++ /dev/null @@ -1,83 +0,0 @@ ---- -title: "Claude Code setup" -description: "Configure Claude Code for your documentation workflow" -icon: "asterisk" ---- - -Claude Code is Anthropic's official CLI tool. This guide will help you set up Claude Code to help you write and maintain your documentation. - -## Prerequisites - -- Active Claude subscription (Pro, Max, or API access) - -## Setup - -1. Install Claude Code globally: - -```bash -npm install -g @anthropic-ai/claude-code -``` - -2. Navigate to your docs directory. -3. (Optional) Add the `CLAUDE.md` file below to your project. -4. Run `claude` to start. - -## Create `CLAUDE.md` - -Create a `CLAUDE.md` file at the root of your documentation repository to train Claude Code on your specific documentation standards: - -```markdown -# Mintlify documentation - -## Working relationship - -- You can push back on ideas-this can lead to better documentation. Cite sources and explain your reasoning when you do so -- ALWAYS ask for clarification rather than making assumptions -- NEVER lie, guess, or make up information - -## Project context - -- Format: MDX files with YAML frontmatter -- Config: docs.json for navigation, theme, settings -- Components: Mintlify components - -## Content strategy - -- Document just enough for user success - not too much, not too little -- Prioritize accuracy and usability of information -- Make content evergreen when possible -- Search for existing information before adding new content. Avoid duplication unless it is done for a strategic reason -- Check existing patterns for consistency -- Start by making the smallest reasonable changes - -## Frontmatter requirements for pages - -- title: Clear, descriptive page title -- description: Concise summary for SEO/navigation - -## Writing standards - -- Second-person voice ("you") -- Prerequisites at start of procedural content -- Test all code examples before publishing -- Match style and formatting of existing pages -- Include both basic and advanced use cases -- Language tags on all code blocks -- Alt text on all images -- Relative paths for internal links - -## Git workflow - -- NEVER use --no-verify when committing -- Ask how to handle uncommitted changes before starting -- Create a new branch when no clear branch exists for changes -- Commit frequently throughout development -- NEVER skip or disable pre-commit hooks - -## Do not - -- Skip frontmatter on any MDX file -- Use absolute URLs for internal links -- Include untested code examples -- Make assumptions - always ask for clarification -``` diff --git a/packages/docs/ai-tools/cursor.mdx b/packages/docs/ai-tools/cursor.mdx deleted file mode 100644 index d05882919f..0000000000 --- a/packages/docs/ai-tools/cursor.mdx +++ /dev/null @@ -1,423 +0,0 @@ ---- -title: "Cursor setup" -description: "Configure Cursor for your documentation workflow" -icon: "arrow-pointer" ---- - -Use Cursor to help write and maintain your documentation. This guide shows how to configure Cursor for better results on technical writing tasks and using Mintlify components. - -## Prerequisites - -- Cursor editor installed -- Access to your documentation repository - -## Project rules - -Create project rules that all team members can use. In your documentation repository root: - -```bash -mkdir -p .cursor -``` - -Create `.cursor/rules.md`: - -````markdown -# Mintlify technical writing rule - -You are an AI writing assistant specialized in creating exceptional technical documentation using Mintlify components and following industry-leading technical writing practices. - -## Core writing principles - -### Language and style requirements - -- Use clear, direct language appropriate for technical audiences -- Write in second person ("you") for instructions and procedures -- Use active voice over passive voice -- Employ present tense for current states, future tense for outcomes -- Avoid jargon unless necessary and define terms when first used -- Maintain consistent terminology throughout all documentation -- Keep sentences concise while providing necessary context -- Use parallel structure in lists, headings, and procedures - -### Content organization standards - -- Lead with the most important information (inverted pyramid structure) -- Use progressive disclosure: basic concepts before advanced ones -- Break complex procedures into numbered steps -- Include prerequisites and context before instructions -- Provide expected outcomes for each major step -- Use descriptive, keyword-rich headings for navigation and SEO -- Group related information logically with clear section breaks - -### User-centered approach - -- Focus on user goals and outcomes rather than system features -- Anticipate common questions and address them proactively -- Include troubleshooting for likely failure points -- Write for scannability with clear headings, lists, and white space -- Include verification steps to confirm success - -## Mintlify component reference - -### Callout components - -#### Note - Additional helpful information - - -Supplementary information that supports the main content without interrupting flow - - -#### Tip - Best practices and pro tips - - -Expert advice, shortcuts, or best practices that enhance user success - - -#### Warning - Important cautions - - -Critical information about potential issues, breaking changes, or destructive actions - - -#### Info - Neutral contextual information - - -Background information, context, or neutral announcements - - -#### Check - Success confirmations - - -Positive confirmations, successful completions, or achievement indicators - - -### Code components - -#### Single code block - -Example of a single code block: - -```javascript config.js -const apiConfig = { - baseURL: "https://api.example.com", - timeout: 5000, - headers: { - Authorization: `Bearer ${process.env.API_TOKEN}`, - }, -} -``` - -#### Code group with multiple languages - -Example of a code group: - - -```javascript Node.js -const response = await fetch('/api/endpoint', { - headers: { Authorization: `Bearer ${apiKey}` } -}); -``` - -```python Python -import requests -response = requests.get('/api/endpoint', - headers={'Authorization': f'Bearer {api_key}'}) -``` - -```curl cURL -curl -X GET '/api/endpoint' \ - -H 'Authorization: Bearer YOUR_API_KEY' -``` - - - -#### Request/response examples - -Example of request/response documentation: - - -```bash cURL -curl -X POST 'https://api.example.com/users' \ - -H 'Content-Type: application/json' \ - -d '{"name": "John Doe", "email": "john@example.com"}' -``` - - - -```json Success -{ - "id": "user_123", - "name": "John Doe", - "email": "john@example.com", - "created_at": "2024-01-15T10:30:00Z" -} -``` - - -### Structural components - -#### Steps for procedures - -Example of step-by-step instructions: - - - - Run `npm install` to install required packages. - - - Verify installation by running `npm list`. - - - - - Create a `.env` file with your API credentials. - - ```bash - API_KEY=your_api_key_here - ``` - - - Never commit API keys to version control. - - - - -#### Tabs for alternative content - -Example of tabbed content: - - - - ```bash - brew install node - npm install -g package-name - ``` - - - - ```powershell - choco install nodejs - npm install -g package-name - ``` - - - - ```bash - sudo apt install nodejs npm - npm install -g package-name - ``` - - - -#### Accordions for collapsible content - -Example of accordion groups: - - - - - **Firewall blocking**: Ensure ports 80 and 443 are open - - **Proxy configuration**: Set HTTP_PROXY environment variable - - **DNS resolution**: Try using 8.8.8.8 as DNS server - - - - ```javascript - const config = { - performance: { cache: true, timeout: 30000 }, - security: { encryption: 'AES-256' } - }; - ``` - - - -### Cards and columns for emphasizing information - -Example of cards and card groups: - - -Complete walkthrough from installation to your first API call in under 10 minutes. - - - - - Learn how to authenticate requests using API keys or JWT tokens. - - - - Understand rate limits and best practices for high-volume usage. - - - -### API documentation components - -#### Parameter fields - -Example of parameter documentation: - - -Unique identifier for the user. Must be a valid UUID v4 format. - - - -User's email address. Must be valid and unique within the system. - - - -Maximum number of results to return. Range: 1-100. - - - -Bearer token for API authentication. Format: `Bearer YOUR_API_KEY` - - -#### Response fields - -Example of response field documentation: - - -Unique identifier assigned to the newly created user. - - - -ISO 8601 formatted timestamp of when the user was created. - - - -List of permission strings assigned to this user. - - -#### Expandable nested fields - -Example of nested field documentation: - - -Complete user object with all associated data. - - - - User profile information including personal details. - - - - User's first name as entered during registration. - - - - URL to user's profile picture. Returns null if no avatar is set. - - - - - - -### Media and advanced components - -#### Frames for images - -Wrap all images in frames: - - -Main dashboard showing analytics overview - - - -Analytics dashboard with charts - - -#### Videos - -Use the HTML video element for self-hosted video content: - - - -Embed YouTube videos using iframe elements: - - - -#### Tooltips - -Example of tooltip usage: - - -API - - -#### Updates - -Use updates for changelogs: - - -## New features -- Added bulk user import functionality -- Improved error messages with actionable suggestions - -## Bug fixes - -- Fixed pagination issue with large datasets -- Resolved authentication timeout problems - - -## Required page structure - -Every documentation page must begin with YAML frontmatter: - -```yaml ---- -title: "Clear, specific, keyword-rich title" -description: "Concise description explaining page purpose and value" ---- -``` - -## Content quality standards - -### Code examples requirements - -- Always include complete, runnable examples that users can copy and execute -- Show proper error handling and edge case management -- Use realistic data instead of placeholder values -- Include expected outputs and results for verification -- Test all code examples thoroughly before publishing -- Specify language and include filename when relevant -- Add explanatory comments for complex logic -- Never include real API keys or secrets in code examples - -### API documentation requirements - -- Document all parameters including optional ones with clear descriptions -- Show both success and error response examples with realistic data -- Include rate limiting information with specific limits -- Provide authentication examples showing proper format -- Explain all HTTP status codes and error handling -- Cover complete request/response cycles - -### Accessibility requirements - -- Include descriptive alt text for all images and diagrams -- Use specific, actionable link text instead of "click here" -- Ensure proper heading hierarchy starting with H2 -- Provide keyboard navigation considerations -- Use sufficient color contrast in examples and visuals -- Structure content for easy scanning with headers and lists - -## Component selection logic - -- Use **Steps** for procedures and sequential instructions -- Use **Tabs** for platform-specific content or alternative approaches -- Use **CodeGroup** when showing the same concept in multiple programming languages -- Use **Accordions** for progressive disclosure of information -- Use **RequestExample/ResponseExample** specifically for API endpoint documentation -- Use **ParamField** for API parameters, **ResponseField** for API responses -- Use **Expandable** for nested object properties or hierarchical information -```` diff --git a/packages/docs/ai-tools/windsurf.mdx b/packages/docs/ai-tools/windsurf.mdx deleted file mode 100644 index 310c81d5f7..0000000000 --- a/packages/docs/ai-tools/windsurf.mdx +++ /dev/null @@ -1,96 +0,0 @@ ---- -title: "Windsurf setup" -description: "Configure Windsurf for your documentation workflow" -icon: "water" ---- - -Configure Windsurf's Cascade AI assistant to help you write and maintain documentation. This guide shows how to set up Windsurf specifically for your Mintlify documentation workflow. - -## Prerequisites - -- Windsurf editor installed -- Access to your documentation repository - -## Workspace rules - -Create workspace rules that provide Windsurf with context about your documentation project and standards. - -Create `.windsurf/rules.md` in your project root: - -````markdown -# Mintlify technical writing rule - -## Project context - -- This is a documentation project on the Mintlify platform -- We use MDX files with YAML frontmatter -- Navigation is configured in `docs.json` -- We follow technical writing best practices - -## Writing standards - -- Use second person ("you") for instructions -- Write in active voice and present tense -- Start procedures with prerequisites -- Include expected outcomes for major steps -- Use descriptive, keyword-rich headings -- Keep sentences concise but informative - -## Required page structure - -Every page must start with frontmatter: - -```yaml ---- -title: "Clear, specific title" -description: "Concise description for SEO and navigation" ---- -``` - -## Mintlify components - -### Callouts - -- `` for helpful supplementary information -- `` for important cautions and breaking changes -- `` for best practices and expert advice -- `` for neutral contextual information -- `` for success confirmations - -### Code examples - -- When appropriate, include complete, runnable examples -- Use `` for multiple language examples -- Specify language tags on all code blocks -- Include realistic data, not placeholders -- Use `` and `` for API docs - -### Procedures - -- Use `` component for sequential instructions -- Include verification steps with `` components when relevant -- Break complex procedures into smaller steps - -### Content organization - -- Use `` for platform-specific content -- Use `` for progressive disclosure -- Use `` and `` for highlighting content -- Wrap images in `` components with descriptive alt text - -## API documentation requirements - -- Document all parameters with `` -- Show response structure with `` -- Include both success and error examples -- Use `` for nested object properties -- Always include authentication examples - -## Quality standards - -- Test all code examples before publishing -- Use relative paths for internal links -- Include alt text for all images -- Ensure proper heading hierarchy (start with h2) -- Check existing patterns for consistency -```` diff --git a/packages/docs/api/index.mdx b/packages/docs/api/index.mdx new file mode 100644 index 0000000000..102ff30324 --- /dev/null +++ b/packages/docs/api/index.mdx @@ -0,0 +1,6 @@ +--- +title: "API Reference" +description: "OpenCode HTTP API." +--- + +The endpoint reference is generated from the current OpenCode V2 [OpenAPI specification](/openapi.json). diff --git a/packages/docs/assets/favicon.svg b/packages/docs/assets/favicon.svg new file mode 100644 index 0000000000..1beb80483d --- /dev/null +++ b/packages/docs/assets/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/docs/assets/logo-dark.svg b/packages/docs/assets/logo-dark.svg new file mode 100644 index 0000000000..9812b2308d --- /dev/null +++ b/packages/docs/assets/logo-dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/docs/assets/logo-light.svg b/packages/docs/assets/logo-light.svg new file mode 100644 index 0000000000..0fa652b24b --- /dev/null +++ b/packages/docs/assets/logo-light.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/docs/config.mdx b/packages/docs/config.mdx new file mode 100644 index 0000000000..a084c1a819 --- /dev/null +++ b/packages/docs/config.mdx @@ -0,0 +1,4 @@ +--- +title: "Config" +description: "Configure OpenCode." +--- diff --git a/packages/docs/development.mdx b/packages/docs/development.mdx deleted file mode 100644 index 432ef80e4f..0000000000 --- a/packages/docs/development.mdx +++ /dev/null @@ -1,96 +0,0 @@ ---- -title: "Development" -description: "Preview changes locally to update your docs" ---- - -**Prerequisites**: - Node.js version 19 or higher - A docs repository with a `docs.json` file - -Follow these steps to install and run Mintlify on your operating system. - - - - -```bash -npm i -g mint -``` - - - - - -Navigate to your docs directory where your `docs.json` file is located, and run the following command: - -```bash -mint dev -``` - -A local preview of your documentation will be available at `http://localhost:3000`. - - - - -## Custom ports - -By default, Mintlify uses port 3000. You can customize the port Mintlify runs on by using the `--port` flag. For example, to run Mintlify on port 3333, use this command: - -```bash -mint dev --port 3333 -``` - -If you attempt to run Mintlify on a port that's already in use, it will use the next available port: - -```md -Port 3000 is already in use. Trying 3001 instead. -``` - -## Mintlify versions - -Please note that each CLI release is associated with a specific version of Mintlify. If your local preview does not align with the production version, please update the CLI: - -```bash -npm mint update -``` - -## Validating links - -The CLI can assist with validating links in your documentation. To identify any broken links, use the following command: - -```bash -mint broken-links -``` - -## Deployment - -If the deployment is successful, you should see the following: - - - Screenshot of a deployment confirmation message that says All checks have passed. - - -## Code formatting - -We suggest using extensions on your IDE to recognize and format MDX. If you're a VSCode user, consider the [MDX VSCode extension](https://marketplace.visualstudio.com/items?itemName=unifiedjs.vscode-mdx) for syntax highlighting, and [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) for code formatting. - -## Troubleshooting - - - - - This may be due to an outdated version of node. Try the following: - 1. Remove the currently-installed version of the CLI: `npm remove -g mint` - 2. Upgrade to Node v19 or higher. - 3. Reinstall the CLI: `npm i -g mint` - - - - - - Solution: Go to the root of your device and delete the `~/.mintlify` folder. Then run `mint dev` again. - - - -Curious about what changed in the latest CLI version? Check out the [CLI changelog](https://www.npmjs.com/package/mintlify?activeTab=versions). diff --git a/packages/docs/docs.json b/packages/docs/docs.json index 1bf8b3700b..68a975cf85 100644 --- a/packages/docs/docs.json +++ b/packages/docs/docs.json @@ -1,53 +1,64 @@ { "$schema": "https://mintlify.com/docs.json", "theme": "mint", - "name": "@opencode-ai/docs", + "name": "OpenCode", + "description": "OpenCode documentation.", "colors": { - "primary": "#16A34A", - "light": "#07C983", - "dark": "#15803D" + "primary": "#3B7DD8", + "light": "#3B7DD8", + "dark": "#FAB283" + }, + "favicon": "/assets/favicon.svg", + "logo": { + "light": "/assets/logo-light.svg", + "dark": "/assets/logo-dark.svg" }, - "favicon": "/favicon-v3.svg", "navigation": { "tabs": [ + { + "tab": "Docs", + "pages": ["index", "config", "plugins", "troubleshooting"] + }, { "tab": "SDK", + "pages": ["sdk/index"] + }, + { + "tab": "API", "groups": [ { - "group": "Getting started", - "pages": ["index", "quickstart", "development"], - "openapi": "https://opencode.ai/openapi.json" + "group": "Overview", + "pages": ["api/index"] + }, + { + "group": "Endpoints", + "openapi": "openapi.json" } ] } ], "global": {} }, - "logo": { - "light": "/logo/light.svg", - "dark": "/logo/dark.svg" - }, "navbar": { "links": [ { - "label": "Support", - "href": "mailto:hi@mintlify.com" + "label": "Discord", + "href": "https://opencode.ai/discord" } ], "primary": { "type": "button", - "label": "Dashboard", - "href": "https://dashboard.mintlify.com" + "label": "GitHub", + "href": "https://github.com/anomalyco/opencode" } }, "contextual": { - "options": ["copy", "view", "chatgpt", "claude", "perplexity", "mcp", "cursor", "vscode"] + "options": ["copy", "view", "chatgpt", "claude", "mcp", "cursor", "vscode"] }, "footer": { "socials": { - "x": "https://x.com/mintlify", - "github": "https://github.com/mintlify", - "linkedin": "https://linkedin.com/company/mintlify" + "github": "https://github.com/anomalyco/opencode", + "x": "https://x.com/opencode" } } } diff --git a/packages/docs/essentials/code.mdx b/packages/docs/essentials/code.mdx deleted file mode 100644 index 7a04654470..0000000000 --- a/packages/docs/essentials/code.mdx +++ /dev/null @@ -1,35 +0,0 @@ ---- -title: "Code blocks" -description: "Display inline code and code blocks" -icon: "code" ---- - -## Inline code - -To denote a `word` or `phrase` as code, enclose it in backticks (`). - -``` -To denote a `word` or `phrase` as code, enclose it in backticks (`). -``` - -## Code blocks - -Use [fenced code blocks](https://www.markdownguide.org/extended-syntax/#fenced-code-blocks) by enclosing code in three backticks and follow the leading ticks with the programming language of your snippet to get syntax highlighting. Optionally, you can also write the name of your code after the programming language. - -```java HelloWorld.java -class HelloWorld { - public static void main(String[] args) { - System.out.println("Hello, World!"); - } -} -``` - -````md -```java HelloWorld.java -class HelloWorld { - public static void main(String[] args) { - System.out.println("Hello, World!"); - } -} -``` -```` diff --git a/packages/docs/essentials/images.mdx b/packages/docs/essentials/images.mdx deleted file mode 100644 index f2a10d2538..0000000000 --- a/packages/docs/essentials/images.mdx +++ /dev/null @@ -1,56 +0,0 @@ ---- -title: "Images and embeds" -description: "Add image, video, and other HTML elements" -icon: "image" ---- - - - -## Image - -### Using Markdown - -The [markdown syntax](https://www.markdownguide.org/basic-syntax/#images) lets you add images using the following code - -```md -![title](/path/image.jpg) -``` - -Note that the image file size must be less than 5MB. Otherwise, we recommend hosting on a service like [Cloudinary](https://cloudinary.com/) or [S3](https://aws.amazon.com/s3/). You can then use that URL and embed. - -### Using embeds - -To get more customizability with images, you can also use [embeds](/writing-content/embed) to add images - -```html - -``` - -## Embeds and HTML elements - - - -
- - - -Mintlify supports [HTML tags in Markdown](https://www.markdownguide.org/basic-syntax/#html). This is helpful if you prefer HTML tags to Markdown syntax, and lets you create documentation with infinite flexibility. - - - -### iFrames - -Loads another HTML page within the document. Most commonly used for embedding videos. - -```html - -``` diff --git a/packages/docs/essentials/markdown.mdx b/packages/docs/essentials/markdown.mdx deleted file mode 100644 index 0ca5b82509..0000000000 --- a/packages/docs/essentials/markdown.mdx +++ /dev/null @@ -1,88 +0,0 @@ ---- -title: "Markdown syntax" -description: "Text, title, and styling in standard markdown" -icon: "text-size" ---- - -## Titles - -Best used for section headers. - -```md -## Titles -``` - -### Subtitles - -Best used for subsection headers. - -```md -### Subtitles -``` - - - -Each **title** and **subtitle** creates an anchor and also shows up on the table of contents on the right. - - - -## Text formatting - -We support most markdown formatting. Simply add `**`, `_`, or `~` around text to format it. - -| Style | How to write it | Result | -| ------------- | ----------------- | --------------- | -| Bold | `**bold**` | **bold** | -| Italic | `_italic_` | _italic_ | -| Strikethrough | `~strikethrough~` | ~strikethrough~ | - -You can combine these. For example, write `**_bold and italic_**` to get **_bold and italic_** text. - -You need to use HTML to write superscript and subscript text. That is, add `` or `` around your text. - -| Text Size | How to write it | Result | -| ----------- | ------------------------ | ---------------------- | -| Superscript | `superscript` | superscript | -| Subscript | `subscript` | subscript | - -## Linking to pages - -You can add a link by wrapping text in `[]()`. You would write `[link to google](https://google.com)` to [link to google](https://google.com). - -Links to pages in your docs need to be root-relative. Basically, you should include the entire folder path. For example, `[link to text](/writing-content/text)` links to the page "Text" in our components section. - -Relative links like `[link to text](../text)` will open slower because we cannot optimize them as easily. - -## Blockquotes - -### Singleline - -To create a blockquote, add a `>` in front of a paragraph. - -> Dorothy followed her through many of the beautiful rooms in her castle. - -```md -> Dorothy followed her through many of the beautiful rooms in her castle. -``` - -### Multiline - -> Dorothy followed her through many of the beautiful rooms in her castle. -> -> The Witch bade her clean the pots and kettles and sweep the floor and keep the fire fed with wood. - -```md -> Dorothy followed her through many of the beautiful rooms in her castle. -> -> The Witch bade her clean the pots and kettles and sweep the floor and keep the fire fed with wood. -``` - -### LaTeX - -Mintlify supports [LaTeX](https://www.latex-project.org) through the Latex component. - -8 x (vk x H1 - H2) = (0,1) - -```md -8 x (vk x H1 - H2) = (0,1) -``` diff --git a/packages/docs/essentials/navigation.mdx b/packages/docs/essentials/navigation.mdx deleted file mode 100644 index a6a3090046..0000000000 --- a/packages/docs/essentials/navigation.mdx +++ /dev/null @@ -1,87 +0,0 @@ ---- -title: "Navigation" -description: "The navigation field in docs.json defines the pages that go in the navigation menu" -icon: "map" ---- - -The navigation menu is the list of links on every website. - -You will likely update `docs.json` every time you add a new page. Pages do not show up automatically. - -## Navigation syntax - -Our navigation syntax is recursive which means you can make nested navigation groups. You don't need to include `.mdx` in page names. - - - -```json Regular Navigation -"navigation": { - "tabs": [ - { - "tab": "Docs", - "groups": [ - { - "group": "Getting Started", - "pages": ["quickstart"] - } - ] - } - ] -} -``` - -```json Nested Navigation -"navigation": { - "tabs": [ - { - "tab": "Docs", - "groups": [ - { - "group": "Getting Started", - "pages": [ - "quickstart", - { - "group": "Nested Reference Pages", - "pages": ["nested-reference-page"] - } - ] - } - ] - } - ] -} -``` - - - -## Folders - -Simply put your MDX files in folders and update the paths in `docs.json`. - -For example, to have a page at `https://yoursite.com/your-folder/your-page` you would make a folder called `your-folder` containing an MDX file called `your-page.mdx`. - - - -You cannot use `api` for the name of a folder unless you nest it inside another folder. Mintlify uses Next.js which reserves the top-level `api` folder for internal server calls. A folder name such as `api-reference` would be accepted. - - - -```json Navigation With Folder -"navigation": { - "tabs": [ - { - "tab": "Docs", - "groups": [ - { - "group": "Group Name", - "pages": ["your-folder/your-page"] - } - ] - } - ] -} -``` - -## Hidden pages - -MDX files not included in `docs.json` will not show up in the sidebar but are accessible through the search bar and by linking directly to them. diff --git a/packages/docs/essentials/reusable-snippets.mdx b/packages/docs/essentials/reusable-snippets.mdx deleted file mode 100644 index a26ab89a35..0000000000 --- a/packages/docs/essentials/reusable-snippets.mdx +++ /dev/null @@ -1,112 +0,0 @@ ---- -title: "Reusable snippets" -description: "Reusable, custom snippets to keep content in sync" -icon: "recycle" ---- - -import SnippetIntro from "/snippets/snippet-intro.mdx" - - - -## Creating a custom snippet - -**Pre-condition**: You must create your snippet file in the `snippets` directory. - - - Any page in the `snippets` directory will be treated as a snippet and will not be rendered into a standalone page. If - you want to create a standalone page from the snippet, import the snippet into another file and call it as a - component. - - -### Default export - -1. Add content to your snippet file that you want to re-use across multiple - locations. Optionally, you can add variables that can be filled in via props - when you import the snippet. - -```mdx snippets/my-snippet.mdx -Hello world! This is my content I want to reuse across pages. My keyword of the -day is {word}. -``` - - - The content that you want to reuse must be inside the `snippets` directory in order for the import to work. - - -2. Import the snippet into your destination file. - -```mdx destination-file.mdx ---- -title: My title -description: My Description ---- - -import MySnippet from "/snippets/path/to/my-snippet.mdx" - -## Header - -Lorem impsum dolor sit amet. - - -``` - -### Reusable variables - -1. Export a variable from your snippet file: - -```mdx snippets/path/to/custom-variables.mdx -export const myName = "my name" - -export const myObject = { fruit: "strawberries" } - -; -``` - -2. Import the snippet from your destination file and use the variable: - -```mdx destination-file.mdx ---- -title: My title -description: My Description ---- - -import { myName, myObject } from "/snippets/path/to/custom-variables.mdx" - -Hello, my name is {myName} and I like {myObject.fruit}. -``` - -### Reusable components - -1. Inside your snippet file, create a component that takes in props by exporting - your component in the form of an arrow function. - -```mdx snippets/custom-component.mdx -export const MyComponent = ({ title }) => ( -
-

{title}

-

... snippet content ...

-
-) - -; -``` - - - MDX does not compile inside the body of an arrow function. Stick to HTML syntax when you can or use a default export - if you need to use MDX. - - -2. Import the snippet into your destination file and pass in the props - -```mdx destination-file.mdx ---- -title: My title -description: My Description ---- - -import { MyComponent } from "/snippets/custom-component.mdx" - -Lorem ipsum dolor sit amet. - - -``` diff --git a/packages/docs/essentials/settings.mdx b/packages/docs/essentials/settings.mdx deleted file mode 100644 index 2cc202ed1f..0000000000 --- a/packages/docs/essentials/settings.mdx +++ /dev/null @@ -1,316 +0,0 @@ ---- -title: "Global Settings" -description: "Mintlify gives you complete control over the look and feel of your documentation using the docs.json file" -icon: "gear" ---- - -Every Mintlify site needs a `docs.json` file with the core configuration settings. Learn more about the [properties](#properties) below. - -## Properties - - -Name of your project. Used for the global title. - -Example: `mintlify` - - - - - An array of groups with all the pages within that group - - - The name of the group. - - Example: `Settings` - - - - The relative paths to the markdown files that will serve as pages. - - Example: `["customization", "page"]` - - - - - - - - Path to logo image or object with path to "light" and "dark" mode logo images - - - Path to the logo in light mode - - - Path to the logo in dark mode - - - Where clicking on the logo links you to - - - - - - Path to the favicon image - - - - Hex color codes for your global theme - - - The primary color. Used most often for highlighted content, section headers, accents, in light mode - - - The primary color for dark mode. Used most often for highlighted content, section headers, accents, in dark mode - - - The primary color for important buttons - - - The color of the background in both light and dark mode - - - The hex color code of the background in light mode - - - The hex color code of the background in dark mode - - - - - - - - Array of `name`s and `url`s of links you want to include in the topbar - - - The name of the button. - - Example: `Contact us` - - - The url once you click on the button. Example: `https://mintlify.com/docs` - - - - - - - - - Link shows a button. GitHub shows the repo information at the url provided including the number of GitHub stars. - - - If `link`: What the button links to. - - If `github`: Link to the repository to load GitHub information from. - - - Text inside the button. Only required if `type` is a `link`. - - - - - - - Array of version names. Only use this if you want to show different versions of docs with a dropdown in the navigation - bar. - - - - An array of the anchors, includes the `icon`, `color`, and `url`. - - - The [Font Awesome](https://fontawesome.com/search?q=heart) icon used to feature the anchor. - - Example: `comments` - - - The name of the anchor label. - - Example: `Community` - - - The start of the URL that marks what pages go in the anchor. Generally, this is the name of the folder you put your pages in. - - - The hex color of the anchor icon background. Can also be a gradient if you pass an object with the properties `from` and `to` that are each a hex color. - - - Used if you want to hide an anchor until the correct docs version is selected. - - - Pass `true` if you want to hide the anchor until you directly link someone to docs inside it. - - - One of: "brands", "duotone", "light", "sharp-solid", "solid", or "thin" - - - - - - - Override the default configurations for the top-most anchor. - - - The name of the top-most anchor - - - Font Awesome icon. - - - One of: "brands", "duotone", "light", "sharp-solid", "solid", or "thin" - - - - - - An array of navigational tabs. - - - The name of the tab label. - - - The start of the URL that marks what pages go in the tab. Generally, this is the name of the folder you put your - pages in. - - - - - - Configuration for API settings. Learn more about API pages at [API Components](/api-playground/demo). - - - The base url for all API endpoints. If `baseUrl` is an array, it will enable for multiple base url - options that the user can toggle. - - - - - - The authentication strategy used for all API endpoints. - - - The name of the authentication parameter used in the API playground. - - If method is `basic`, the format should be `[usernameName]:[passwordName]` - - - The default value that's designed to be a prefix for the authentication input field. - - E.g. If an `inputPrefix` of `AuthKey` would inherit the default input result of the authentication field as `AuthKey`. - - - - - - Configurations for the API playground - - - - Whether the playground is showing, hidden, or only displaying the endpoint with no added user interactivity `simple` - - Learn more at the [playground guides](/api-playground/demo) - - - - - - Enabling this flag ensures that key ordering in OpenAPI pages matches the key ordering defined in the OpenAPI file. - - This behavior will soon be enabled by default, at which point this field will be deprecated. - - - - - - - A string or an array of strings of URL(s) or relative path(s) pointing to your - OpenAPI file. - - Examples: - - ```json Absolute - "openapi": "https://example.com/openapi.json" - ``` - ```json Relative - "openapi": "/openapi.json" - ``` - ```json Multiple - "openapi": ["https://example.com/openapi1.json", "/openapi2.json", "/openapi3.json"] - ``` - - - - - - An object of social media accounts where the key:property pair represents the social media platform and the account url. - - Example: - ```json - { - "x": "https://x.com/mintlify", - "website": "https://mintlify.com" - } - ``` - - - One of the following values `website`, `facebook`, `x`, `discord`, `slack`, `github`, `linkedin`, `instagram`, `hacker-news` - - Example: `x` - - - The URL to the social platform. - - Example: `https://x.com/mintlify` - - - - - - Configurations to enable feedback buttons - - - - Enables a button to allow users to suggest edits via pull requests - - - Enables a button to allow users to raise an issue about the documentation - - - - - - Customize the dark mode toggle. - - - Set if you always want to show light or dark mode for new users. When not - set, we default to the same mode as the user's operating system. - - - Set to true to hide the dark/light mode toggle. You can combine `isHidden` with `default` to force your docs to only use light or dark mode. For example: - - - ```json Only Dark Mode - "modeToggle": { - "default": "dark", - "isHidden": true - } - ``` - - ```json Only Light Mode - "modeToggle": { - "default": "light", - "isHidden": true - } - ``` - - - - - - - - - A background image to be displayed behind every page. See example with [Infisical](https://infisical.com/docs) and - [FRPC](https://frpc.io). - diff --git a/packages/docs/favicon-v3.svg b/packages/docs/favicon-v3.svg deleted file mode 100644 index b785c738bf..0000000000 --- a/packages/docs/favicon-v3.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/packages/docs/favicon.svg b/packages/docs/favicon.svg deleted file mode 100644 index b785c738bf..0000000000 --- a/packages/docs/favicon.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/packages/docs/images/checks-passed.png b/packages/docs/images/checks-passed.png deleted file mode 100644 index 3303c77364..0000000000 Binary files a/packages/docs/images/checks-passed.png and /dev/null differ diff --git a/packages/docs/images/hero-dark.png b/packages/docs/images/hero-dark.png deleted file mode 100644 index a61cbb1252..0000000000 Binary files a/packages/docs/images/hero-dark.png and /dev/null differ diff --git a/packages/docs/images/hero-light.png b/packages/docs/images/hero-light.png deleted file mode 100644 index 68c712d6db..0000000000 Binary files a/packages/docs/images/hero-light.png and /dev/null differ diff --git a/packages/docs/index.mdx b/packages/docs/index.mdx index 19a09f890c..8e61f92a6b 100644 --- a/packages/docs/index.mdx +++ b/packages/docs/index.mdx @@ -1,56 +1,164 @@ --- -title: "Introduction" -description: "Welcome to the new home for your documentation" +title: "Intro" +description: "Get started with OpenCode." --- -## Setting up +[**OpenCode**](https://opencode.ai) is an open source AI coding agent. It's available as a terminal-based interface or +desktop app. -Get your documentation site up and running in minutes. +OpenCode TUI with the opencode theme - - Follow our three step quickstart guide. - +Let's get started. -## Make it yours +--- -Design a docs site that looks great and empowers your users. +#### Prerequisites - - - Edit your docs locally and preview them in real time. - - - Customize the design and colors of your site to match your brand. - - - Organize your docs to help users find what they need and succeed with your product. - - - Auto-generate API documentation from OpenAPI specifications. - - +To use OpenCode in your terminal, you'll need: -## Create beautiful pages +1. A modern terminal emulator like: + - [Ghostty](https://ghostty.org), Linux and macOS + - [WezTerm](https://wezterm.org), cross-platform + - [Alacritty](https://alacritty.org), cross-platform + - [Kitty](https://sw.kovidgoyal.net/kitty/), Linux and macOS +2. API keys for the LLM providers you want to use. -Everything you need to create world-class documentation. +--- - - - Use MDX to style your docs pages. - - - Add sample code to demonstrate how to use your product. - - - Display images and other media. - - - Write once and reuse across your docs. - - +## Install -## Need inspiration? +The curl install script is not available in beta. - - Browse our showcase of exceptional documentation sites. - +You can also install it with the following package managers. + + + + ```bash + npm install -g @opencode-ai/cli@next + ``` + + + ```bash + bun install -g @opencode-ai/cli@next + ``` + + + ```bash + pnpm install -g @opencode-ai/cli@next + ``` + + + ```bash + yarn global add @opencode-ai/cli@next + ``` + + + +During beta, the executable is named `opencode2`. + +### Homebrew + +Homebrew installation is not available in beta. + +### Arch Linux + +Arch Linux installation is not available in beta. + +### Windows + + + For the best experience on Windows, use [Windows Subsystem for Linux + (WSL)](https://opencode.ai/docs/windows-wsl). It provides better performance and full compatibility with OpenCode's + features. + + + + + Chocolatey installation is not available in beta. + + + Scoop installation is not available in beta. + + + Mise installation is not available in beta. + + + Docker installation is not available in beta. + + + +Standalone binaries are not available in beta. + +--- + +## Connect + +With OpenCode you can use any LLM provider by configuring its API key. + +Run `/connect` in the TUI and select your provider. + +```text +/connect +``` + +If you'd like easy access to all the best coding models you can try out +[OpenCode Console](https://console.opencode.ai). + +You can also try [OpenCode Go](https://opencode.ai/go) a $10/month subscription +plan that grants you access to the best open source models. + +See the current [provider directory](https://opencode.ai/docs/providers#directory). + +--- + +## Usage + +You are now ready to use OpenCode in your project. Here are a few common workflows. + +### Ask questions + +Ask OpenCode to explain your codebase. + +Use `@` to fuzzy search for files in the project. + +```text +How is authentication handled in @packages/functions/src/api/index.ts +``` + +### Add features + +Ask OpenCode to add a feature by describing the desired behavior and providing relevant context. + +```text +When a user deletes a note, flag it as deleted in the database. +Create a screen that shows recently deleted notes. +From this screen, the user can restore a note or permanently delete it. +``` + +Give OpenCode plenty of context and examples. + +### Undo changes + +Use `/undo` when a change isn't what you wanted. + +```text +/undo +``` + +OpenCode reverts the changes and restores your original message so you can revise it. Run `/undo` multiple times to undo +multiple changes, or use `/redo` to reapply them. + +```text +/redo +``` + +--- + +## Customize + +Make OpenCode your own by [picking a theme](https://opencode.ai/docs/themes), [customizing +keybinds](https://opencode.ai/docs/keybinds), [configuring formatters](https://opencode.ai/docs/formatters), [creating +commands](https://opencode.ai/docs/commands), or editing the [OpenCode config](https://opencode.ai/docs/config). diff --git a/packages/docs/logo/dark.svg b/packages/docs/logo/dark.svg deleted file mode 100644 index 8b343cd6fc..0000000000 --- a/packages/docs/logo/dark.svg +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/packages/docs/logo/light.svg b/packages/docs/logo/light.svg deleted file mode 100644 index 03e62bf1d9..0000000000 --- a/packages/docs/logo/light.svg +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json deleted file mode 120000 index 854dd8b2b8..0000000000 --- a/packages/docs/openapi.json +++ /dev/null @@ -1 +0,0 @@ -../sdk/openapi.json \ No newline at end of file diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json new file mode 100644 index 0000000000..bcad31bd32 --- /dev/null +++ b/packages/docs/openapi.json @@ -0,0 +1,26010 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "opencode HttpApi", + "version": "0.0.1", + "description": "Experimental HttpApi surface for selected instance routes." + }, + "paths": { + "/api/health": { + "get": { + "tags": [ + "health" + ], + "operationId": "v2.health.get", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "healthy": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "healthy" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Check whether the API server is ready to accept requests.", + "summary": "Check server health" + } + }, + "/api/location": { + "get": { + "tags": [ + "location" + ], + "operationId": "v2.location.get", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Location.Info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Location.Info" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Resolve the requested location or the server default location.", + "summary": "Get location" + } + }, + "/api/agent": { + "get": { + "tags": [ + "agent" + ], + "operationId": "v2.agent.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AgentV2.Info" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve currently registered agents.", + "summary": "List agents" + } + }, + "/api/plugin": { + "get": { + "tags": [ + "plugin" + ], + "operationId": "v2.plugin.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Plugin.Info" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve currently loaded plugins.", + "summary": "List plugins" + } + }, + "/api/session": { + "get": { + "tags": [ + "session" + ], + "operationId": "v2.session.list", + "parameters": [ + { + "name": "workspace", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^wrk" + } + ] + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "limit", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Maximum number of sessions to return. Defaults to the newest 50 sessions." + }, + "required": false + }, + { + "name": "order", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "asc", + "desc" + ] + }, + { + "type": "null" + } + ], + "description": "Session order for the first page. Use desc for newest first or asc for oldest first." + }, + "required": false + }, + { + "name": "search", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "parentID", + "in": "query", + "schema": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + { + "type": "string", + "enum": [ + "null" + ] + } + ], + "description": "Filter by parent session. Use null to return only root sessions." + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "directory", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "project", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "subpath", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "cursor", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string", + "description": "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response." + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "SessionsResponse", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionsResponse" + } + } + } + }, + "400": { + "description": "InvalidCursorError | InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidCursorError" + }, + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.", + "summary": "List sessions" + }, + "post": { + "tags": [ + "session" + ], + "operationId": "v2.session.create", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SessionV2.Info" + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Create a session at the requested location.", + "summary": "Create session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + { + "type": "null" + } + ] + }, + "agent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "model": { + "anyOf": [ + { + "$ref": "#/components/schemas/Model.Ref" + }, + { + "type": "null" + } + ] + }, + "location": { + "anyOf": [ + { + "$ref": "#/components/schemas/Location.Ref" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/active": { + "get": { + "tags": [ + "session" + ], + "operationId": "v2.session.active", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "patternProperties": { + "^ses": { + "$ref": "#/components/schemas/SessionActive" + } + } + }, + "watermarks": { + "$ref": "#/components/schemas/SessionWatermarks" + } + }, + "required": [ + "data", + "watermarks" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive. Watermarks are the durable log positions read alongside the activity snapshot; activity itself is process state, so the pairing is advisory rather than transactional.", + "summary": "List active sessions" + } + }, + "/api/session/{sessionID}": { + "get": { + "tags": [ + "session" + ], + "operationId": "v2.session.get", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SessionV2.Info" + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve a session by ID.", + "summary": "Get session" + } + }, + "/api/session/{sessionID}/fork": { + "post": { + "tags": [ + "session" + ], + "operationId": "v2.session.fork", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SessionV2.Info" + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | MessageNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/MessageNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Create a child session by copying projected history from the parent. When messageID is supplied, copy messages before that boundary.", + "summary": "Fork session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/agent": { + "post": { + "tags": [ + "session" + ], + "operationId": "v2.session.switchAgent", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Switch the agent used by subsequent provider turns.", + "summary": "Switch session agent", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "agent": { + "type": "string" + } + }, + "required": [ + "agent" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/model": { + "post": { + "tags": [ + "session" + ], + "operationId": "v2.session.switchModel", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Switch the model used by subsequent provider turns.", + "summary": "Switch session model", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "model": { + "$ref": "#/components/schemas/Model.Ref" + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/rename": { + "post": { + "tags": [ + "session" + ], + "operationId": "v2.session.rename", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Update the session title.", + "summary": "Rename session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + } + }, + "required": [ + "title" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/prompt": { + "post": { + "tags": [ + "session" + ], + "operationId": "v2.session.prompt", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SessionInput.Admitted" + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "409": { + "description": "ConflictError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConflictError" + } + } + } + } + }, + "description": "Durably admit one session input and schedule agent-loop execution unless resume is false.", + "summary": "Send message", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + { + "type": "null" + } + ] + }, + "prompt": { + "$ref": "#/components/schemas/PromptInput" + }, + "delivery": { + "anyOf": [ + { + "type": "string", + "enum": [ + "steer", + "queue" + ] + }, + { + "type": "null" + } + ] + }, + "resume": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "prompt" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/command": { + "post": { + "tags": [ + "session" + ], + "operationId": "v2.session.command", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SessionInput.Admitted" + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | CommandNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/CommandNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "409": { + "description": "ConflictError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConflictError" + } + } + } + }, + "500": { + "description": "CommandEvaluationError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandEvaluationError" + } + } + } + } + }, + "description": "Resolve a slash command into prompt input, admit it durably, and schedule execution unless resume is false.", + "summary": "Run command", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + { + "type": "null" + } + ] + }, + "command": { + "type": "string" + }, + "arguments": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "agent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "model": { + "anyOf": [ + { + "$ref": "#/components/schemas/Model.Ref" + }, + { + "type": "null" + } + ] + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptInput.FileAttachment" + } + }, + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prompt.AgentAttachment" + } + }, + "delivery": { + "anyOf": [ + { + "type": "string", + "enum": [ + "steer", + "queue" + ] + }, + { + "type": "null" + } + ] + }, + "resume": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "command" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/skill": { + "post": { + "tags": [ + "session" + ], + "operationId": "v2.session.skill", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | SkillNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SkillNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Activate a skill for a session by appending a skill message and resuming execution.", + "summary": "Activate skill", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + { + "type": "null" + } + ] + }, + "skill": { + "type": "string" + }, + "resume": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "skill" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/synthetic": { + "post": { + "tags": [ + "session" + ], + "operationId": "v2.session.synthetic", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Append a synthetic message to a session and resume execution.", + "summary": "Add synthetic message", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "metadata": { + "type": "object" + } + }, + "required": [ + "text" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/shell": { + "post": { + "tags": [ + "session" + ], + "operationId": "v2.session.shell", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Execute one shell command in the session's working directory. Emits a shell.started event before execution and a shell.ended event with the merged output after.", + "summary": "Run shell command", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + { + "type": "null" + } + ] + }, + "command": { + "type": "string" + } + }, + "required": [ + "command" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/compact": { + "post": { + "tags": [ + "session" + ], + "operationId": "v2.session.compact", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "409": { + "description": "SessionBusyError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionBusyError" + } + } + } + }, + "500": { + "description": "UnknownError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnknownError" + } + } + } + }, + "503": { + "description": "ServiceUnavailableError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceUnavailableError" + } + } + } + } + }, + "description": "Compact a session conversation.", + "summary": "Compact session" + } + }, + "/api/session/{sessionID}/wait": { + "post": { + "tags": [ + "session" + ], + "operationId": "v2.session.wait", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "503": { + "description": "ServiceUnavailableError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceUnavailableError" + } + } + } + } + }, + "description": "Wait for a session agent loop to become idle.", + "summary": "Wait for session" + } + }, + "/api/session/{sessionID}/revert/stage": { + "post": { + "tags": [ + "session" + ], + "operationId": "v2.session.revert.stage", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Revert.State" + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "MessageNotFoundError | SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/MessageNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "409": { + "description": "SessionBusyError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionBusyError" + } + } + } + }, + "500": { + "description": "UnknownError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnknownError" + } + } + } + } + }, + "description": "Stage or move a reversible session boundary and optionally apply its file changes.", + "summary": "Stage session revert", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "files": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "messageID" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/revert/clear": { + "post": { + "tags": [ + "session" + ], + "operationId": "v2.session.revert.clear", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "409": { + "description": "SessionBusyError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionBusyError" + } + } + } + }, + "500": { + "description": "UnknownError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnknownError" + } + } + } + } + }, + "summary": "Clear staged revert" + } + }, + "/api/session/{sessionID}/revert/commit": { + "post": { + "tags": [ + "session" + ], + "operationId": "v2.session.revert.commit", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "409": { + "description": "SessionBusyError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionBusyError" + } + } + } + } + }, + "summary": "Commit staged revert" + } + }, + "/api/session/{sessionID}/context": { + "get": { + "tags": [ + "session" + ], + "operationId": "v2.session.context", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Session.Message" + } + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "500": { + "description": "UnknownError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnknownError" + } + } + } + } + }, + "description": "Retrieve the active context messages for a session (all messages after the last compaction).", + "summary": "Get session context" + } + }, + "/api/session/{sessionID}/context-entry": { + "get": { + "tags": [ + "session" + ], + "operationId": "v2.session.context.entry.list", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionContextEntry.Info" + } + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "List API-managed context entries attached to the session's system context.", + "summary": "List context entries" + } + }, + "/api/session/{sessionID}/context-entry/{key}": { + "put": { + "tags": [ + "session" + ], + "operationId": "v2.session.context.entry.put", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + }, + { + "name": "key", + "in": "path", + "schema": { + "$ref": "#/components/schemas/SessionContextEntry.Key" + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Attach or replace one durable context entry. The value is rendered into the session's system context; changes announce as updates at the next turn boundary.", + "summary": "Put context entry", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + } + }, + "required": true + } + }, + "delete": { + "tags": [ + "session" + ], + "operationId": "v2.session.context.entry.remove", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + }, + { + "name": "key", + "in": "path", + "schema": { + "$ref": "#/components/schemas/SessionContextEntry.Key" + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Remove one context entry; the removal is announced to the model at the next turn boundary.", + "summary": "Remove context entry" + } + }, + "/api/session/{sessionID}/log": { + "get": { + "tags": [ + "session" + ], + "operationId": "v2.session.log", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + }, + { + "name": "after", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "follow", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "true", + "false" + ] + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "text/event-stream": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "event": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/SessionLogItemStream" + } + }, + "required": [ + "id", + "event", + "data" + ], + "additionalProperties": false + }, + "x-effect-stream": { + "encoding": "sse", + "causeSchema": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Fail" + ] + }, + "error": { + "not": {} + } + }, + "required": [ + "_tag", + "error" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Die" + ] + }, + "defect": {} + }, + "required": [ + "_tag", + "defect" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Interrupt" + ] + }, + "fiberId": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "fiberId" + ], + "additionalProperties": false + } + ] + } + }, + "errorSchema": { + "not": {} + }, + "failureEvent": "effect/httpapi/stream/failure" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Durable, ordered, gap-free read of public session events after an exclusive aggregate sequence. Emits a synced marker once replay reaches the captured watermark, then completes; with follow=true it continues with live events instead. The only event API that promises reliability: attach after a snapshot watermark to compose fetch and stream without a race window.", + "summary": "Read the session log" + } + }, + "/api/session/{sessionID}/interrupt": { + "post": { + "tags": [ + "session" + ], + "operationId": "v2.session.interrupt", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op.", + "summary": "Interrupt session execution" + } + }, + "/api/session/{sessionID}/background": { + "post": { + "tags": [ + "session" + ], + "operationId": "v2.session.background", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Move active foreground backgroundable tools for this session into background observation. Idle requests are a no-op.", + "summary": "Background blocking session tools" + } + }, + "/api/session/{sessionID}/message/{messageID}": { + "get": { + "tags": [ + "session" + ], + "operationId": "v2.session.message", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + }, + { + "name": "messageID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Session.Message" + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | MessageNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/MessageNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve one projected message owned by the Session.", + "summary": "Get session message" + } + }, + "/api/session/{sessionID}/message": { + "get": { + "tags": [ + "session" + ], + "operationId": "v2.session.messages", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + }, + { + "name": "limit", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Maximum number of messages to return. When omitted, the endpoint returns its default page size." + }, + "required": false + }, + { + "name": "order", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "asc", + "desc" + ] + }, + { + "type": "null" + } + ], + "description": "Message order for the first page. Use desc for newest first or asc for oldest first." + }, + "required": false + }, + { + "name": "cursor", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string", + "description": "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order." + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "SessionMessagesResponse", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionMessagesResponse" + } + } + } + }, + "400": { + "description": "InvalidCursorError | InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidCursorError" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "500": { + "description": "UnknownError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnknownError" + } + } + } + } + }, + "description": "Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.", + "summary": "Get session messages" + } + }, + "/api/model": { + "get": { + "tags": [ + "model" + ], + "operationId": "v2.model.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModelV2.Info" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "503": { + "description": "ServiceUnavailableError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceUnavailableError" + } + } + } + } + }, + "description": "Retrieve available models ordered by release date.", + "summary": "List models" + } + }, + "/api/model/default": { + "get": { + "tags": [ + "model" + ], + "operationId": "v2.model.default", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/ModelV2.Info" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "503": { + "description": "ServiceUnavailableError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceUnavailableError" + } + } + } + } + }, + "description": "Retrieve the model used when a session has no explicit model selection.", + "summary": "Get default model" + } + }, + "/api/generate": { + "post": { + "tags": [ + "generate" + ], + "operationId": "v2.generate.text", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "GenerateTextResponse", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerateTextResponse" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "503": { + "description": "ServiceUnavailableError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceUnavailableError" + } + } + } + } + }, + "description": "Run one stateless model generation at the requested location and return the assistant text. Uses the location's default model when none is specified.", + "summary": "Generate text", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "prompt": { + "type": "string" + }, + "model": { + "anyOf": [ + { + "$ref": "#/components/schemas/Model.Ref" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "prompt" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/provider": { + "get": { + "tags": [ + "provider" + ], + "operationId": "v2.provider.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProviderV2.Info" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "503": { + "description": "ServiceUnavailableError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceUnavailableError" + } + } + } + } + }, + "description": "Retrieve active AI providers so clients can show provider availability and configuration.", + "summary": "List providers" + } + }, + "/api/provider/{providerID}": { + "get": { + "tags": [ + "provider" + ], + "operationId": "v2.provider.get", + "parameters": [ + { + "name": "providerID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/ProviderV2.Info" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "ProviderNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderNotFoundError" + } + } + } + }, + "503": { + "description": "ServiceUnavailableError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceUnavailableError" + } + } + } + } + }, + "description": "Retrieve a single AI provider so clients can inspect its availability and endpoint settings.", + "summary": "Get provider" + } + }, + "/api/integration": { + "get": { + "tags": [ + "integration" + ], + "operationId": "v2.integration.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Integration.Info" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve available integrations and their authentication methods.", + "summary": "List integrations" + } + }, + "/api/integration/{integrationID}": { + "get": { + "tags": [ + "integration" + ], + "operationId": "v2.integration.get", + "parameters": [ + { + "name": "integrationID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/Integration.Info" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve one integration and its authentication methods.", + "summary": "Get integration" + } + }, + "/api/integration/{integrationID}/connect/key": { + "post": { + "tags": [ + "integration" + ], + "operationId": "v2.integration.connect.key", + "parameters": [ + { + "name": "integrationID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Run a key authentication method and store the resulting credential.", + "summary": "Connect with key", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "key" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/integration/{integrationID}/connect/oauth": { + "post": { + "tags": [ + "integration" + ], + "operationId": "v2.integration.connect.oauth", + "parameters": [ + { + "name": "integrationID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Integration.Attempt" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Start an OAuth attempt and return the authorization details.", + "summary": "Begin OAuth connection", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "methodID": { + "type": "string" + }, + "inputs": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "methodID", + "inputs" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/integration/attempt/{attemptID}": { + "get": { + "tags": [ + "integration" + ], + "operationId": "v2.integration.attempt.status", + "parameters": [ + { + "name": "attemptID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Integration.AttemptStatus" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Poll the current status of an OAuth attempt.", + "summary": "Get OAuth attempt status" + }, + "delete": { + "tags": [ + "integration" + ], + "operationId": "v2.integration.attempt.cancel", + "parameters": [ + { + "name": "attemptID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Cancel an OAuth attempt and release its resources.", + "summary": "Cancel OAuth connection" + } + }, + "/api/integration/attempt/{attemptID}/complete": { + "post": { + "tags": [ + "integration" + ], + "operationId": "v2.integration.attempt.complete", + "parameters": [ + { + "name": "attemptID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Complete a code-based OAuth attempt and store the resulting credential.", + "summary": "Complete OAuth connection", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/mcp": { + "get": { + "tags": [ + "mcp" + ], + "operationId": "v2.mcp.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Mcp.Server" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve configured MCP servers and their connection status.", + "summary": "List MCP servers" + } + }, + "/api/credential/{credentialID}": { + "patch": { + "tags": [ + "credential" + ], + "operationId": "v2.credential.update", + "parameters": [ + { + "name": "credentialID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Update a stored credential label.", + "summary": "Update credential", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "label": { + "type": "string" + } + }, + "required": [ + "label" + ], + "additionalProperties": false + } + } + }, + "required": true + } + }, + "delete": { + "tags": [ + "credential" + ], + "operationId": "v2.credential.remove", + "parameters": [ + { + "name": "credentialID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Remove a stored integration credential.", + "summary": "Remove credential" + } + }, + "/api/project": { + "get": { + "tags": [ + "project" + ], + "operationId": "v2.project.list", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Project" + } + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "List known projects.", + "summary": "List projects" + } + }, + "/api/project/current": { + "get": { + "tags": [ + "project" + ], + "operationId": "v2.project.current", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Project.Current", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project.Current" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Resolve the project for the requested location.", + "summary": "Get current project" + } + }, + "/api/project/{projectID}/directories": { + "get": { + "tags": [ + "project" + ], + "operationId": "v2.project.directories", + "parameters": [ + { + "name": "projectID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Project.Directories", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project.Directories" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "List known local absolute directories for a project.", + "summary": "List project directories" + } + }, + "/api/form/request": { + "get": { + "tags": [ + "form" + ], + "operationId": "v2.form.request.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Form.FormInfo" + }, + { + "$ref": "#/components/schemas/Form.UrlInfo" + } + ] + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve pending forms for a location.", + "summary": "List pending form requests" + } + }, + "/api/session/{sessionID}/form": { + "get": { + "tags": [ + "form" + ], + "operationId": "v2.session.form.list", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Form.FormInfo" + }, + { + "$ref": "#/components/schemas/Form.UrlInfo" + } + ] + } + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve pending forms for a session.", + "summary": "List session forms" + }, + "post": { + "tags": [ + "form" + ], + "operationId": "v2.session.form.create", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/Form.FormInfo" + }, + { + "$ref": "#/components/schemas/Form.UrlInfo" + } + ] + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "409": { + "description": "ConflictError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConflictError" + } + } + } + } + }, + "description": "Create a form for a session.", + "summary": "Create session form", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Form.CreatePayload" + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/form/{formID}": { + "get": { + "tags": [ + "form" + ], + "operationId": "v2.session.form.get", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "formID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/Form.FormInfo" + }, + { + "$ref": "#/components/schemas/Form.UrlInfo" + } + ] + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | FormNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/FormNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve a form for a session.", + "summary": "Get session form" + } + }, + "/api/session/{sessionID}/form/{formID}/state": { + "get": { + "tags": [ + "form" + ], + "operationId": "v2.session.form.state", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "formID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Form.State" + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | FormNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/FormNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve the current state for a form.", + "summary": "Get form state" + } + }, + "/api/session/{sessionID}/form/{formID}/reply": { + "post": { + "tags": [ + "form" + ], + "operationId": "v2.session.form.reply", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "formID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "FormInvalidAnswerError | InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/FormInvalidAnswerError" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | FormNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/FormNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "409": { + "description": "FormAlreadySettledError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FormAlreadySettledError" + } + } + } + } + }, + "description": "Submit an answer to a pending form.", + "summary": "Reply to form", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Form.Reply" + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/form/{formID}/cancel": { + "post": { + "tags": [ + "form" + ], + "operationId": "v2.session.form.cancel", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "formID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | FormNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/FormNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "409": { + "description": "FormAlreadySettledError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FormAlreadySettledError" + } + } + } + } + }, + "description": "Cancel a pending form.", + "summary": "Cancel form" + } + }, + "/api/permission/request": { + "get": { + "tags": [ + "permission" + ], + "operationId": "v2.permission.request.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionV2.Request" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve pending permission requests for a location.", + "summary": "List pending permission requests" + } + }, + "/api/permission/saved": { + "get": { + "tags": [ + "permission" + ], + "operationId": "v2.permission.saved.list", + "parameters": [ + { + "name": "projectID", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionSaved.Info" + } + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve saved permissions, optionally filtered by project.", + "summary": "List saved permissions" + } + }, + "/api/permission/saved/{id}": { + "delete": { + "tags": [ + "permission" + ], + "operationId": "v2.permission.saved.remove", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Remove a saved permission by ID.", + "summary": "Remove saved permission" + } + }, + "/api/session/{sessionID}/permission": { + "post": { + "tags": [ + "permission" + ], + "operationId": "v2.session.permission.create", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^per" + } + ] + }, + "effect": { + "$ref": "#/components/schemas/PermissionV2.Effect" + } + }, + "required": [ + "id", + "effect" + ], + "additionalProperties": false + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Evaluate and, when approval is required, create a permission request for a session.", + "summary": "Create permission request", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^per" + } + ] + }, + { + "type": "null" + } + ] + }, + "action": { + "type": "string" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + } + }, + "save": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object" + }, + "source": { + "$ref": "#/components/schemas/PermissionV2.Source" + }, + "agent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "action", + "resources" + ], + "additionalProperties": false + } + } + }, + "required": true + } + }, + "get": { + "tags": [ + "permission" + ], + "operationId": "v2.session.permission.list", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionV2.Request" + } + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve pending permission requests owned by a session.", + "summary": "List session permission requests" + } + }, + "/api/session/{sessionID}/permission/{requestID}": { + "get": { + "tags": [ + "permission" + ], + "operationId": "v2.session.permission.get", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + }, + { + "name": "requestID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^per" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/PermissionV2.Request" + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | PermissionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/PermissionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve a pending permission request owned by a session.", + "summary": "Get permission request" + } + }, + "/api/session/{sessionID}/permission/{requestID}/reply": { + "post": { + "tags": [ + "permission" + ], + "operationId": "v2.session.permission.reply", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + }, + { + "name": "requestID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^per" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | PermissionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/PermissionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Respond to a pending permission request owned by a session.", + "summary": "Reply to pending permission request", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "reply": { + "$ref": "#/components/schemas/PermissionV2.Reply" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "reply" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/fs/read/*": { + "get": { + "tags": [ + "filesystem" + ], + "operationId": "v2.fs.read", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Serve one file relative to the requested location.", + "summary": "Read file" + } + }, + "/api/fs/list": { + "get": { + "tags": [ + "filesystem" + ], + "operationId": "v2.fs.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + }, + { + "name": "path", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileSystem.Entry" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "List direct children of one directory relative to the requested location.", + "summary": "List directory" + } + }, + "/api/fs/find": { + "get": { + "tags": [ + "filesystem" + ], + "operationId": "v2.fs.find", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + }, + { + "name": "query", + "in": "query", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "type", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "file", + "directory" + ] + }, + "required": false + }, + { + "name": "limit", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileSystem.Entry" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Find recursively ranked filesystem entries relative to the requested location.", + "summary": "Find files" + } + }, + "/api/command": { + "get": { + "tags": [ + "command" + ], + "operationId": "v2.command.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CommandV2.Info" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve currently registered commands.", + "summary": "List commands" + } + }, + "/api/skill": { + "get": { + "tags": [ + "skill" + ], + "operationId": "v2.skill.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SkillV2.Info" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve currently registered skills.", + "summary": "List skills" + } + }, + "/api/event": { + "get": { + "tags": [ + "event" + ], + "operationId": "v2.event.subscribe", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "text/event-stream": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "event": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/V2EventStream" + } + }, + "required": [ + "id", + "event", + "data" + ], + "additionalProperties": false + }, + "x-effect-stream": { + "encoding": "sse", + "causeSchema": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Fail" + ] + }, + "error": { + "not": {} + } + }, + "required": [ + "_tag", + "error" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Die" + ] + }, + "defect": {} + }, + "required": [ + "_tag", + "defect" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Interrupt" + ] + }, + "fiberId": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "fiberId" + ], + "additionalProperties": false + } + ] + } + }, + "errorSchema": { + "not": {} + }, + "failureEvent": "effect/httpapi/stream/failure" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Subscribe to native event payloads for the server. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed. Consumers that need reliability should combine the changes feed with durable session log reads.", + "summary": "Subscribe to events" + } + }, + "/api/event/changes": { + "get": { + "tags": [ + "event" + ], + "operationId": "v2.event.changes", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "text/event-stream": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "event": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/EventLog.ChangeStream" + } + }, + "required": [ + "id", + "event", + "data" + ], + "additionalProperties": false + }, + "x-effect-stream": { + "encoding": "sse", + "causeSchema": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Fail" + ] + }, + "error": { + "not": {} + } + }, + "required": [ + "_tag", + "error" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Die" + ] + }, + "defect": {} + }, + "required": [ + "_tag", + "defect" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Interrupt" + ] + }, + "fiberId": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "fiberId" + ], + "additionalProperties": false + } + ] + } + }, + "errorSchema": { + "not": {} + }, + "failureEvent": "effect/httpapi/stream/failure" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Payload-free hint channel: after an event commits, a subscriber eventually receives a hint for that aggregate with seq at or beyond the event, or a sweep-required marker. Hints coalesce to the latest seq per aggregate under backpressure and the stream never fails from overflow. No consumer may derive correctness from receiving a hint; correctness always comes from durable log reads plus the consumer's own checkpoint. A sweep-required marker is emitted first on every (re)subscribe and whenever hint retention is exceeded: treat every aggregate as potentially dirty and recover via bounded sweep plus log reads.", + "summary": "Subscribe to change hints" + } + }, + "/api/pty": { + "get": { + "tags": [ + "pty" + ], + "operationId": "v2.pty.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pty" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "List PTY sessions for a location, including exited sessions retained until removal.", + "summary": "List PTY sessions" + }, + "post": { + "tags": [ + "pty" + ], + "operationId": "v2.pty.create", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Create a pseudo-terminal session for a location.", + "summary": "Create PTY session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "cwd": { + "type": "string" + }, + "title": { + "type": "string" + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/pty/{ptyID}": { + "get": { + "tags": [ + "pty" + ], + "operationId": "v2.pty.get", + "parameters": [ + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^pty" + } + ] + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "PtyNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PtyNotFoundError" + } + } + } + } + }, + "description": "Get one PTY session, including its exit code once exited.", + "summary": "Get PTY session" + }, + "put": { + "tags": [ + "pty" + ], + "operationId": "v2.pty.update", + "parameters": [ + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^pty" + } + ] + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "PtyNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PtyNotFoundError" + } + } + } + } + }, + "description": "Update the title or viewport size of one PTY session.", + "summary": "Update PTY session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "size": { + "type": "object", + "properties": { + "rows": { + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] + }, + "cols": { + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] + } + }, + "required": [ + "rows", + "cols" + ], + "additionalProperties": false + } + }, + "additionalProperties": false + } + } + }, + "required": true + } + }, + "delete": { + "tags": [ + "pty" + ], + "operationId": "v2.pty.remove", + "parameters": [ + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^pty" + } + ] + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "PtyNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PtyNotFoundError" + } + } + } + } + }, + "description": "Terminate and remove one PTY session.", + "summary": "Remove PTY session" + } + }, + "/api/pty/{ptyID}/connect-token": { + "post": { + "tags": [ + "pty" + ], + "operationId": "v2.pty.connectToken", + "parameters": [ + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^pty" + } + ] + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/PtyTicket.ConnectToken" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "403": { + "description": "ForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenError" + } + } + } + }, + "404": { + "description": "PtyNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PtyNotFoundError" + } + } + } + } + }, + "description": "Create a short-lived single-use ticket for opening a PTY WebSocket connection.", + "summary": "Create PTY WebSocket token" + } + }, + "/api/pty/{ptyID}/connect": { + "get": { + "tags": [ + "pty" + ], + "operationId": "v2.pty.connect", + "parameters": [ + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^pty" + } + ] + }, + "required": true + }, + { + "in": "query", + "name": "location[directory]", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "location[workspace]", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "cursor", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "ticket", + "schema": { + "type": "string" + } + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "403": { + "description": "ForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenError" + } + } + } + }, + "404": { + "description": "PtyNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PtyNotFoundError" + } + } + } + } + }, + "description": "Establish a WebSocket connection streaming PTY output and accepting terminal input.", + "summary": "Connect to PTY session", + "x-websocket": true + } + }, + "/api/shell": { + "get": { + "tags": [ + "shell" + ], + "operationId": "v2.shell.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Shell" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "List currently running shell commands for a location. Exited commands are not included.", + "summary": "List running shell commands" + }, + "post": { + "tags": [ + "shell" + ], + "operationId": "v2.shell.create", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Shell" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Spawn one non-interactive shell command for a location. Combined stdout/stderr is captured to a file pageable via output.", + "summary": "Run shell command", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "timeout": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "metadata": { + "type": "object" + } + }, + "required": [ + "command" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/shell/{id}": { + "get": { + "tags": [ + "shell" + ], + "operationId": "v2.shell.get", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Shell" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "ShellNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShellNotFoundError" + } + } + } + } + }, + "description": "Get one shell command, including its status and exit code once exited.", + "summary": "Get shell command" + }, + "delete": { + "tags": [ + "shell" + ], + "operationId": "v2.shell.remove", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "ShellNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShellNotFoundError" + } + } + } + } + }, + "description": "Terminate and remove one shell command and its retained output.", + "summary": "Remove shell command" + } + }, + "/api/shell/{id}/output": { + "get": { + "tags": [ + "shell" + ], + "operationId": "v2.shell.output", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^[+-]?\\d*\\.?\\d+(?:[Ee][+-]?\\d+)?$" + } + ] + }, + "required": false + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^[+-]?\\d*\\.?\\d+(?:[Ee][+-]?\\d+)?$" + } + ] + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "object", + "properties": { + "output": { + "type": "string" + }, + "cursor": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "size": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "output", + "cursor", + "size", + "truncated" + ], + "additionalProperties": false + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "ShellNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShellNotFoundError" + } + } + } + } + }, + "description": "Page through captured combined output by absolute byte cursor.", + "summary": "Read shell output" + } + }, + "/api/question/request": { + "get": { + "tags": [ + "question" + ], + "operationId": "v2.question.request.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2.Request" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve pending question requests for a location.", + "summary": "List pending question requests" + } + }, + "/api/session/{sessionID}/question": { + "get": { + "tags": [ + "question" + ], + "operationId": "v2.session.question.list", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2.Request" + } + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve pending question requests owned by a session.", + "summary": "List session question requests" + } + }, + "/api/session/{sessionID}/question/{requestID}/reply": { + "post": { + "tags": [ + "question" + ], + "operationId": "v2.session.question.reply", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + }, + { + "name": "requestID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^que" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | QuestionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/QuestionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Answer a pending question request owned by a session.", + "summary": "Reply to pending question request", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QuestionV2.Reply" + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/question/{requestID}/reject": { + "post": { + "tags": [ + "question" + ], + "operationId": "v2.session.question.reject", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + }, + { + "name": "requestID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^que" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | QuestionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/QuestionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Reject a pending question request owned by a session.", + "summary": "Reject pending question request" + } + }, + "/api/reference": { + "get": { + "tags": [ + "reference" + ], + "operationId": "v2.reference.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Reference.Info" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "List references available in the requested location.", + "summary": "List references" + } + }, + "/experimental/project/{projectID}/copy": { + "post": { + "tags": [ + "projectCopy" + ], + "operationId": "v2.projectCopy.create", + "parameters": [ + { + "name": "projectID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "ProjectCopy.Copy", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectCopy.Copy" + } + } + } + }, + "400": { + "description": "ProjectCopyError | InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectCopyError" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "strategy": { + "type": "string" + }, + "directory": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "strategy", + "directory" + ], + "additionalProperties": false + } + } + }, + "required": true + } + }, + "delete": { + "tags": [ + "projectCopy" + ], + "operationId": "v2.projectCopy.remove", + "parameters": [ + { + "name": "projectID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "ProjectCopyError | InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectCopyError" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "force": { + "type": "boolean" + } + }, + "required": [ + "directory", + "force" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/experimental/project/{projectID}/copy/refresh": { + "post": { + "tags": [ + "projectCopy" + ], + "operationId": "v2.projectCopy.refresh", + "parameters": [ + { + "name": "projectID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "ProjectCopyError | InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectCopyError" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + } + } + }, + "/api/vcs/status": { + "get": { + "tags": [ + "vcs" + ], + "operationId": "v2.vcs.status", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Vcs.FileStatus" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "List uncommitted working-copy changes relative to the requested location.", + "summary": "VCS status" + } + }, + "/api/vcs/diff": { + "get": { + "tags": [ + "vcs" + ], + "operationId": "v2.vcs.diff", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + }, + { + "name": "mode", + "in": "query", + "schema": { + "$ref": "#/components/schemas/Vcs.Mode" + }, + "required": true + }, + { + "name": "context", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Diff the working copy against HEAD (mode git) or the default-branch merge base (mode branch) for the requested location.", + "summary": "VCS diff" + } + }, + "/api/debug/location": { + "get": { + "tags": [ + "debug" + ], + "operationId": "v2.debug.location", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Location.Ref" + } + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "List locations currently loaded by the server.", + "summary": "List loaded locations" + } + } + }, + "components": { + "schemas": { + "UnauthorizedError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "UnauthorizedError" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "InvalidRequestError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "InvalidRequestError" + ] + }, + "message": { + "type": "string" + }, + "kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "field": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "Location.Info": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "workspaceID": { + "type": "string", + "allOf": [ + { + "pattern": "^wrk" + } + ] + }, + "project": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "directory": { + "type": "string" + } + }, + "required": [ + "id", + "directory" + ], + "additionalProperties": false + } + }, + "required": [ + "directory", + "project" + ], + "additionalProperties": false + }, + "Model.Ref": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": [ + "id", + "providerID" + ], + "additionalProperties": false + }, + "Provider.Settings": { + "type": "object" + }, + "Provider.Request": { + "type": "object", + "properties": { + "settings": { + "$ref": "#/components/schemas/Provider.Settings" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + } + }, + "required": [ + "settings", + "headers", + "body" + ], + "additionalProperties": false + }, + "Agent.Color": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^#[0-9a-fA-F]{6}$" + } + ] + }, + { + "type": "string", + "enum": [ + "primary", + "secondary", + "accent", + "success", + "warning", + "error", + "info" + ] + } + ] + }, + "PermissionV2.Effect": { + "type": "string", + "enum": [ + "allow", + "deny", + "ask" + ] + }, + "PermissionV2.Rule": { + "type": "object", + "properties": { + "action": { + "type": "string" + }, + "resource": { + "type": "string" + }, + "effect": { + "$ref": "#/components/schemas/PermissionV2.Effect" + } + }, + "required": [ + "action", + "resource", + "effect" + ], + "additionalProperties": false + }, + "PermissionV2.Ruleset": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionV2.Rule" + } + }, + "AgentV2.Info": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + }, + "request": { + "$ref": "#/components/schemas/Provider.Request" + }, + "system": { + "type": "string" + }, + "description": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "subagent", + "primary", + "all" + ] + }, + "hidden": { + "type": "boolean" + }, + "color": { + "$ref": "#/components/schemas/Agent.Color" + }, + "steps": { + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] + }, + "permissions": { + "$ref": "#/components/schemas/PermissionV2.Ruleset" + } + }, + "required": [ + "id", + "request", + "mode", + "hidden", + "permissions" + ], + "additionalProperties": false + }, + "Plugin.Info": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "Location.Ref": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "workspaceID": { + "type": "string", + "allOf": [ + { + "pattern": "^wrk" + } + ] + } + }, + "required": [ + "directory" + ], + "additionalProperties": false + }, + "File.Diff": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "added", + "modified", + "deleted" + ] + }, + "additions": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "deletions": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "patch": { + "type": "string" + } + }, + "required": [ + "path", + "status", + "additions", + "deletions", + "patch" + ], + "additionalProperties": false + }, + "Revert.State": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "partID": { + "type": "string" + }, + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/File.Diff" + } + } + }, + "required": [ + "messageID" + ], + "additionalProperties": false + }, + "SessionV2.Info": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "parentID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "projectID": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ], + "additionalProperties": false + } + }, + "required": [ + "input", + "output", + "reasoning", + "cache" + ], + "additionalProperties": false + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "updated": { + "type": "number" + }, + "archived": { + "type": "number" + } + }, + "required": [ + "created", + "updated" + ], + "additionalProperties": false + }, + "title": { + "type": "string" + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "subpath": { + "type": "string" + }, + "revert": { + "$ref": "#/components/schemas/Revert.State" + } + }, + "required": [ + "id", + "projectID", + "cost", + "tokens", + "time", + "title", + "location" + ], + "additionalProperties": false + }, + "SessionWatermarks": { + "type": "object", + "patternProperties": { + "^ses": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "description": "Durable log seq each session's snapshot was computed at. Attach a live log read after the watermark to compose fetch and stream gap-free; apply a snapshot only where its watermark is at or beyond already-applied events. Sessions without durable events are absent." + }, + "SessionsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionV2.Info" + } + }, + "watermarks": { + "$ref": "#/components/schemas/SessionWatermarks" + }, + "cursor": { + "type": "object", + "properties": { + "previous": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "next": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "required": [ + "data", + "watermarks", + "cursor" + ], + "additionalProperties": false + }, + "InvalidCursorError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "InvalidCursorError" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "InvalidRequestError1": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "InvalidRequestError" + ] + }, + "message": { + "type": "string" + }, + "kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "field": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "SessionActive": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "running" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "SessionNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "SessionNotFoundError" + ] + }, + "sessionID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "sessionID", + "message" + ], + "additionalProperties": false + }, + "MessageNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "MessageNotFoundError" + ] + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "sessionID", + "messageID", + "message" + ], + "additionalProperties": false + }, + "Prompt.Source": { + "type": "object", + "properties": { + "start": { + "type": "number" + }, + "end": { + "type": "number" + }, + "text": { + "type": "string" + } + }, + "required": [ + "start", + "end", + "text" + ], + "additionalProperties": false + }, + "PromptInput.FileAttachment": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/Prompt.Source" + } + }, + "required": [ + "uri" + ], + "additionalProperties": false + }, + "Prompt.AgentAttachment": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/Prompt.Source" + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + "PromptInput": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptInput.FileAttachment" + } + }, + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prompt.AgentAttachment" + } + } + }, + "required": [ + "text" + ], + "additionalProperties": false + }, + "Prompt.FileAttachment": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/Prompt.Source" + } + }, + "required": [ + "uri", + "mime" + ], + "additionalProperties": false + }, + "Prompt": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prompt.FileAttachment" + } + }, + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prompt.AgentAttachment" + } + } + }, + "required": [ + "text" + ], + "additionalProperties": false + }, + "SessionInput.Admitted": { + "type": "object", + "properties": { + "admittedSeq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "delivery": { + "type": "string", + "enum": [ + "steer", + "queue" + ] + }, + "timeCreated": { + "type": "number" + }, + "promotedSeq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "admittedSeq", + "id", + "sessionID", + "prompt", + "delivery", + "timeCreated" + ], + "additionalProperties": false + }, + "ConflictError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ConflictError" + ] + }, + "message": { + "type": "string" + }, + "resource": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "CommandNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "CommandNotFoundError" + ] + }, + "command": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "command", + "message" + ], + "additionalProperties": false + }, + "CommandEvaluationError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "CommandEvaluationError" + ] + }, + "command": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "command", + "message" + ], + "additionalProperties": false + }, + "SkillNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "SkillNotFoundError" + ] + }, + "skill": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "skill", + "message" + ], + "additionalProperties": false + }, + "SessionBusyError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "SessionBusyError" + ] + }, + "sessionID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "sessionID", + "message" + ], + "additionalProperties": false + }, + "ServiceUnavailableError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ServiceUnavailableError" + ] + }, + "message": { + "type": "string" + }, + "service": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "UnknownError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "UnknownError" + ] + }, + "message": { + "type": "string" + }, + "ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "Session.Message.AgentSelected": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": [ + "agent-switched" + ] + }, + "agent": { + "type": "string" + } + }, + "required": [ + "id", + "time", + "type", + "agent" + ], + "additionalProperties": false + }, + "Session.Message.ModelSelected": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": [ + "model-switched" + ] + }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + }, + "previous": { + "$ref": "#/components/schemas/Model.Ref" + } + }, + "required": [ + "id", + "time", + "type", + "model" + ], + "additionalProperties": false + }, + "Session.Message.User": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "text": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prompt.FileAttachment" + } + }, + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prompt.AgentAttachment" + } + }, + "type": { + "type": "string", + "enum": [ + "user" + ] + } + }, + "required": [ + "id", + "time", + "text", + "type" + ], + "additionalProperties": false + }, + "Session.Message.Synthetic": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "text": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "synthetic" + ] + } + }, + "required": [ + "id", + "time", + "sessionID", + "text", + "type" + ], + "additionalProperties": false + }, + "Session.Message.System": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": [ + "system" + ] + }, + "text": { + "type": "string" + } + }, + "required": [ + "id", + "time", + "type", + "text" + ], + "additionalProperties": false + }, + "Session.Message.Skill": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": [ + "skill" + ] + }, + "name": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "id", + "time", + "type", + "name", + "text" + ], + "additionalProperties": false + }, + "Shell": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "running", + "exited", + "timeout", + "killed" + ] + }, + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "shell": { + "type": "string" + }, + "file": { + "type": "string" + }, + "pid": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "exit": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "started": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "completed": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "started" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "status", + "command", + "cwd", + "shell", + "file", + "metadata", + "time" + ], + "additionalProperties": false + }, + "Session.Message.Shell": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "completed": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": [ + "shell" + ] + }, + "shell": { + "$ref": "#/components/schemas/Shell" + }, + "output": { + "type": "object", + "properties": { + "output": { + "type": "string" + }, + "cursor": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "size": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "output", + "cursor", + "size", + "truncated" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "time", + "type", + "shell" + ], + "additionalProperties": false + }, + "Session.Message.Assistant.Text": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "id": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "type", + "id", + "text" + ], + "additionalProperties": false + }, + "LLM.ProviderMetadata": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "Session.Message.Assistant.Reasoning": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "reasoning" + ] + }, + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "providerMetadata": { + "$ref": "#/components/schemas/LLM.ProviderMetadata" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "completed": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + } + }, + "required": [ + "type", + "id", + "text" + ], + "additionalProperties": false + }, + "Session.Message.ToolState.Pending": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "pending" + ] + }, + "input": { + "type": "string" + } + }, + "required": [ + "status", + "input" + ], + "additionalProperties": false + }, + "Tool.TextContent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string" + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + "Tool.FileContent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "file" + ] + }, + "uri": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "type", + "uri", + "mime" + ], + "additionalProperties": false + }, + "LLM.ToolContent": { + "anyOf": [ + { + "$ref": "#/components/schemas/Tool.TextContent" + }, + { + "$ref": "#/components/schemas/Tool.FileContent" + } + ] + }, + "Session.Message.ToolState.Running": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "running" + ] + }, + "input": { + "type": "object" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LLM.ToolContent" + } + } + }, + "required": [ + "status", + "input", + "structured", + "content" + ], + "additionalProperties": false + }, + "Session.Message.ToolState.Completed": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "completed" + ] + }, + "input": { + "type": "object" + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prompt.FileAttachment" + } + }, + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LLM.ToolContent" + } + }, + "outputPaths": { + "type": "array", + "items": { + "type": "string" + } + }, + "structured": { + "type": "object" + }, + "result": {} + }, + "required": [ + "status", + "input", + "content", + "structured" + ], + "additionalProperties": false + }, + "Session.Error.Unknown": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "unknown" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "type", + "message" + ], + "additionalProperties": false + }, + "Session.Message.ToolState.Error": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "error" + ] + }, + "input": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LLM.ToolContent" + } + }, + "structured": { + "type": "object" + }, + "error": { + "$ref": "#/components/schemas/Session.Error.Unknown" + }, + "result": {} + }, + "required": [ + "status", + "input", + "content", + "structured", + "error" + ], + "additionalProperties": false + }, + "Session.Message.Assistant.Tool": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "tool" + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "$ref": "#/components/schemas/LLM.ProviderMetadata" + }, + "resultMetadata": { + "$ref": "#/components/schemas/LLM.ProviderMetadata" + } + }, + "required": [ + "executed" + ], + "additionalProperties": false + }, + "state": { + "anyOf": [ + { + "$ref": "#/components/schemas/Session.Message.ToolState.Pending" + }, + { + "$ref": "#/components/schemas/Session.Message.ToolState.Running" + }, + { + "$ref": "#/components/schemas/Session.Message.ToolState.Completed" + }, + { + "$ref": "#/components/schemas/Session.Message.ToolState.Error" + } + ] + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "ran": { + "type": "number" + }, + "completed": { + "type": "number" + }, + "pruned": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + } + }, + "required": [ + "type", + "id", + "name", + "state", + "time" + ], + "additionalProperties": false + }, + "Session.Message.Assistant": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "completed": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": [ + "assistant" + ] + }, + "agent": { + "type": "string" + }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Session.Message.Assistant.Text" + }, + { + "$ref": "#/components/schemas/Session.Message.Assistant.Reasoning" + }, + { + "$ref": "#/components/schemas/Session.Message.Assistant.Tool" + } + ] + } + }, + "snapshot": { + "type": "object", + "properties": { + "start": { + "type": "string" + }, + "end": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "finish": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ], + "additionalProperties": false + } + }, + "required": [ + "input", + "output", + "reasoning", + "cache" + ], + "additionalProperties": false + }, + "error": { + "$ref": "#/components/schemas/Session.Error.Unknown" + } + }, + "required": [ + "id", + "time", + "type", + "agent", + "model", + "content" + ], + "additionalProperties": false + }, + "Session.Message.Compaction": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "compaction" + ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + }, + "summary": { + "type": "string" + }, + "recent": { + "type": "string" + }, + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + } + }, + "required": [ + "type", + "reason", + "summary", + "recent", + "id", + "time" + ], + "additionalProperties": false + }, + "Session.Message": { + "anyOf": [ + { + "$ref": "#/components/schemas/Session.Message.AgentSelected" + }, + { + "$ref": "#/components/schemas/Session.Message.ModelSelected" + }, + { + "$ref": "#/components/schemas/Session.Message.User" + }, + { + "$ref": "#/components/schemas/Session.Message.Synthetic" + }, + { + "$ref": "#/components/schemas/Session.Message.System" + }, + { + "$ref": "#/components/schemas/Session.Message.Skill" + }, + { + "$ref": "#/components/schemas/Session.Message.Shell" + }, + { + "$ref": "#/components/schemas/Session.Message.Assistant" + }, + { + "$ref": "#/components/schemas/Session.Message.Compaction" + } + ] + }, + "SessionContextEntry.Key": { + "type": "string", + "allOf": [ + { + "pattern": "^[a-z0-9][a-z0-9._-]*$", + "description": "Context entry key (lowercase alphanumerics plus . _ -)" + } + ] + }, + "SessionContextEntry.Info": { + "type": "object", + "properties": { + "key": { + "$ref": "#/components/schemas/SessionContextEntry.Key" + }, + "value": {} + }, + "required": [ + "key", + "value" + ], + "additionalProperties": false + }, + "session.agent.selected": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.agent.selected" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "agent": { + "type": "string" + } + }, + "required": [ + "sessionID", + "agent" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.model.selected": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.model.selected" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + } + }, + "required": [ + "sessionID", + "model" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.moved": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.moved" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "subpath": { + "type": "string" + } + }, + "required": [ + "sessionID", + "location" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.renamed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.renamed" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "title": { + "type": "string" + } + }, + "required": [ + "sessionID", + "title" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.forked": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.forked" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "parentID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "from": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "sessionID", + "parentID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.prompt.promoted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.prompt.promoted" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "inputID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "sessionID", + "inputID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.prompt.admitted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.prompt.admitted" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "inputID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "delivery": { + "type": "string", + "enum": [ + "steer", + "queue" + ] + } + }, + "required": [ + "sessionID", + "inputID", + "prompt", + "delivery" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.context.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.context.updated" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "text": { + "type": "string" + } + }, + "required": [ + "sessionID", + "text" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.synthetic": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.synthetic" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "text": { + "type": "string" + }, + "description": { + "type": "string" + }, + "metadata": { + "type": "object" + } + }, + "required": [ + "sessionID", + "text" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.skill.activated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.skill.activated" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "name": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "sessionID", + "name", + "text" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "Shell1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "running", + "exited", + "timeout", + "killed" + ] + }, + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "shell": { + "type": "string" + }, + "file": { + "type": "string" + }, + "pid": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "exit": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "started": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + "completed": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + } + }, + "required": [ + "started" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "status", + "command", + "cwd", + "shell", + "file", + "metadata", + "time" + ], + "additionalProperties": false + }, + "session.shell.started": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.shell.started" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "shell": { + "$ref": "#/components/schemas/Shell1" + } + }, + "required": [ + "sessionID", + "shell" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.shell.ended": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.shell.ended" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "shell": { + "$ref": "#/components/schemas/Shell1" + }, + "output": { + "type": "object", + "properties": { + "output": { + "type": "string" + }, + "cursor": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "size": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "output", + "cursor", + "size", + "truncated" + ], + "additionalProperties": false + } + }, + "required": [ + "sessionID", + "shell", + "output" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.step.started": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.step.started" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "agent": { + "type": "string" + }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + }, + "snapshot": { + "type": "string" + } + }, + "required": [ + "sessionID", + "assistantMessageID", + "agent", + "model" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.step.ended": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.step.ended" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "finish": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ], + "additionalProperties": false + } + }, + "required": [ + "input", + "output", + "reasoning", + "cache" + ], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "sessionID", + "assistantMessageID", + "finish", + "cost", + "tokens" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.step.failed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.step.failed" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "error": { + "$ref": "#/components/schemas/Session.Error.Unknown" + } + }, + "required": [ + "sessionID", + "assistantMessageID", + "error" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.text.started": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.text.started" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "textID": { + "type": "string" + } + }, + "required": [ + "sessionID", + "assistantMessageID", + "textID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.text.ended": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.text.ended" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "textID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "sessionID", + "assistantMessageID", + "textID", + "text" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "LLM.ProviderMetadata3": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "session.reasoning.started": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.reasoning.started" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "reasoningID": { + "type": "string" + }, + "providerMetadata": { + "$ref": "#/components/schemas/LLM.ProviderMetadata3" + } + }, + "required": [ + "sessionID", + "assistantMessageID", + "reasoningID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "LLM.ProviderMetadata4": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "session.reasoning.ended": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.reasoning.ended" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "reasoningID": { + "type": "string" + }, + "text": { + "type": "string" + }, + "providerMetadata": { + "$ref": "#/components/schemas/LLM.ProviderMetadata4" + } + }, + "required": [ + "sessionID", + "assistantMessageID", + "reasoningID", + "text" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.tool.input.started": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.tool.input.started" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "callID": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "sessionID", + "assistantMessageID", + "callID", + "name" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.tool.input.ended": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.tool.input.ended" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "callID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "sessionID", + "assistantMessageID", + "callID", + "text" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "LLM.ProviderMetadata5": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "session.tool.called": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.tool.called" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "input": { + "type": "object" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "$ref": "#/components/schemas/LLM.ProviderMetadata5" + } + }, + "required": [ + "executed" + ], + "additionalProperties": false + } + }, + "required": [ + "sessionID", + "assistantMessageID", + "callID", + "tool", + "input", + "provider" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.tool.progress": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.tool.progress" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LLM.ToolContent" + } + } + }, + "required": [ + "sessionID", + "assistantMessageID", + "callID", + "structured", + "content" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "LLM.ProviderMetadata6": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "session.tool.success": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.tool.success" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LLM.ToolContent" + } + }, + "outputPaths": { + "type": "array", + "items": { + "type": "string" + } + }, + "result": {}, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "$ref": "#/components/schemas/LLM.ProviderMetadata6" + } + }, + "required": [ + "executed" + ], + "additionalProperties": false + } + }, + "required": [ + "sessionID", + "assistantMessageID", + "callID", + "structured", + "content", + "provider" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "LLM.ProviderMetadata7": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "session.tool.failed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.tool.failed" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "callID": { + "type": "string" + }, + "error": { + "$ref": "#/components/schemas/Session.Error.Unknown" + }, + "result": {}, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "$ref": "#/components/schemas/LLM.ProviderMetadata7" + } + }, + "required": [ + "executed" + ], + "additionalProperties": false + } + }, + "required": [ + "sessionID", + "assistantMessageID", + "callID", + "error", + "provider" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.retry.error": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "statusCode": { + "type": "number" + }, + "isRetryable": { + "type": "boolean" + }, + "responseHeaders": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "responseBody": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "message", + "isRetryable" + ], + "additionalProperties": false + }, + "session.retried": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.retried" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "attempt": { + "type": "number" + }, + "error": { + "$ref": "#/components/schemas/session.retry.error" + } + }, + "required": [ + "sessionID", + "attempt", + "error" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.compaction.started": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.compaction.started" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + } + }, + "required": [ + "sessionID", + "reason" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.compaction.ended": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.compaction.ended" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + }, + "text": { + "type": "string" + }, + "recent": { + "type": "string" + } + }, + "required": [ + "sessionID", + "reason", + "text", + "recent" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.revert.staged": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.revert.staged" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "revert": { + "$ref": "#/components/schemas/Revert.State" + } + }, + "required": [ + "sessionID", + "revert" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.revert.cleared": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.revert.cleared" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.revert.committed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.revert.committed" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "sessionID", + "messageID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "SessionDurableEvent": { + "oneOf": [ + { + "$ref": "#/components/schemas/session.agent.selected" + }, + { + "$ref": "#/components/schemas/session.model.selected" + }, + { + "$ref": "#/components/schemas/session.moved" + }, + { + "$ref": "#/components/schemas/session.renamed" + }, + { + "$ref": "#/components/schemas/session.forked" + }, + { + "$ref": "#/components/schemas/session.prompt.promoted" + }, + { + "$ref": "#/components/schemas/session.prompt.admitted" + }, + { + "$ref": "#/components/schemas/session.context.updated" + }, + { + "$ref": "#/components/schemas/session.synthetic" + }, + { + "$ref": "#/components/schemas/session.skill.activated" + }, + { + "$ref": "#/components/schemas/session.shell.started" + }, + { + "$ref": "#/components/schemas/session.shell.ended" + }, + { + "$ref": "#/components/schemas/session.step.started" + }, + { + "$ref": "#/components/schemas/session.step.ended" + }, + { + "$ref": "#/components/schemas/session.step.failed" + }, + { + "$ref": "#/components/schemas/session.text.started" + }, + { + "$ref": "#/components/schemas/session.text.ended" + }, + { + "$ref": "#/components/schemas/session.reasoning.started" + }, + { + "$ref": "#/components/schemas/session.reasoning.ended" + }, + { + "$ref": "#/components/schemas/session.tool.input.started" + }, + { + "$ref": "#/components/schemas/session.tool.input.ended" + }, + { + "$ref": "#/components/schemas/session.tool.called" + }, + { + "$ref": "#/components/schemas/session.tool.progress" + }, + { + "$ref": "#/components/schemas/session.tool.success" + }, + { + "$ref": "#/components/schemas/session.tool.failed" + }, + { + "$ref": "#/components/schemas/session.retried" + }, + { + "$ref": "#/components/schemas/session.compaction.started" + }, + { + "$ref": "#/components/schemas/session.compaction.ended" + }, + { + "$ref": "#/components/schemas/session.revert.staged" + }, + { + "$ref": "#/components/schemas/session.revert.cleared" + }, + { + "$ref": "#/components/schemas/session.revert.committed" + } + ] + }, + "EventLog.Synced": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "log.synced" + ] + }, + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "type", + "aggregateID" + ], + "additionalProperties": false, + "description": "Marker emitted once when a log read reaches its captured watermark. The reader holds every event committed at or below seq." + }, + "SessionLogItem": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionDurableEvent" + }, + { + "$ref": "#/components/schemas/EventLog.Synced" + } + ] + }, + "SessionLogItemStream": { + "type": "string", + "contentSchema": { + "$ref": "#/components/schemas/SessionLogItem" + }, + "contentMediaType": "application/json" + }, + "SessionMessagesResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Session.Message" + } + }, + "watermark": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "cursor": { + "type": "object", + "properties": { + "previous": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "next": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "required": [ + "data", + "cursor" + ], + "additionalProperties": false + }, + "Model.Api": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "aisdk" + ] + }, + "package": { + "type": "string" + }, + "url": { + "type": "string" + }, + "settings": { + "type": "object" + } + }, + "required": [ + "id", + "type", + "package" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "native" + ] + }, + "url": { + "type": "string" + }, + "settings": { + "type": "object" + } + }, + "required": [ + "id", + "type", + "settings" + ], + "additionalProperties": false + } + ] + }, + "Model.Capabilities": { + "type": "object", + "properties": { + "tools": { + "type": "boolean" + }, + "input": { + "type": "array", + "items": { + "type": "string" + } + }, + "output": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "tools", + "input", + "output" + ], + "additionalProperties": false + }, + "Model.Cost": { + "type": "object", + "properties": { + "tier": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "context" + ] + }, + "size": { + "type": "integer" + } + }, + "required": [ + "type", + "size" + ], + "additionalProperties": false + }, + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ], + "additionalProperties": false + } + }, + "required": [ + "input", + "output", + "cache" + ], + "additionalProperties": false + }, + "ModelV2.Info": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "family": { + "type": "string" + }, + "name": { + "type": "string" + }, + "api": { + "$ref": "#/components/schemas/Model.Api" + }, + "capabilities": { + "$ref": "#/components/schemas/Model.Capabilities" + }, + "request": { + "type": "object", + "properties": { + "settings": { + "$ref": "#/components/schemas/Provider.Settings" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + }, + "variant": { + "type": "string" + } + }, + "required": [ + "settings", + "headers", + "body" + ], + "additionalProperties": false + }, + "variants": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "settings": { + "$ref": "#/components/schemas/Provider.Settings" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + } + }, + "required": [ + "id", + "settings", + "headers", + "body" + ], + "additionalProperties": false + } + }, + "time": { + "type": "object", + "properties": { + "released": { + "type": "number" + } + }, + "required": [ + "released" + ], + "additionalProperties": false + }, + "cost": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Model.Cost" + } + }, + "status": { + "type": "string", + "enum": [ + "alpha", + "beta", + "deprecated", + "active" + ] + }, + "enabled": { + "type": "boolean" + }, + "limit": { + "type": "object", + "properties": { + "context": { + "type": "integer" + }, + "input": { + "type": "integer" + }, + "output": { + "type": "integer" + } + }, + "required": [ + "context", + "output" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "providerID", + "name", + "api", + "capabilities", + "request", + "variants", + "time", + "cost", + "status", + "enabled", + "limit" + ], + "additionalProperties": false + }, + "GenerateTextResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ], + "additionalProperties": false + } + }, + "required": [ + "data" + ], + "additionalProperties": false + }, + "Provider.AISDK": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "aisdk" + ] + }, + "package": { + "type": "string" + }, + "url": { + "type": "string" + }, + "settings": { + "type": "object" + } + }, + "required": [ + "type", + "package" + ], + "additionalProperties": false + }, + "Provider.Native": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "native" + ] + }, + "url": { + "type": "string" + }, + "settings": { + "type": "object" + } + }, + "required": [ + "type", + "settings" + ], + "additionalProperties": false + }, + "Provider.Api": { + "anyOf": [ + { + "$ref": "#/components/schemas/Provider.AISDK" + }, + { + "$ref": "#/components/schemas/Provider.Native" + } + ] + }, + "ProviderV2.Info": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "integrationID": { + "type": "string" + }, + "name": { + "type": "string" + }, + "disabled": { + "type": "boolean" + }, + "api": { + "$ref": "#/components/schemas/Provider.Api" + }, + "request": { + "$ref": "#/components/schemas/Provider.Request" + } + }, + "required": [ + "id", + "name", + "api", + "request" + ], + "additionalProperties": false + }, + "ProviderNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ProviderNotFoundError" + ] + }, + "providerID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "providerID", + "message" + ], + "additionalProperties": false + }, + "Integration.When": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "op": { + "type": "string", + "enum": [ + "eq", + "neq" + ] + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "op", + "value" + ], + "additionalProperties": false + }, + "Integration.TextPrompt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "key": { + "type": "string" + }, + "message": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "when": { + "$ref": "#/components/schemas/Integration.When" + } + }, + "required": [ + "type", + "key", + "message" + ], + "additionalProperties": false + }, + "Integration.SelectPrompt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "select" + ] + }, + "key": { + "type": "string" + }, + "message": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + }, + "hint": { + "type": "string" + } + }, + "required": [ + "label", + "value" + ], + "additionalProperties": false + } + }, + "when": { + "$ref": "#/components/schemas/Integration.When" + } + }, + "required": [ + "type", + "key", + "message", + "options" + ], + "additionalProperties": false + }, + "Integration.OAuthMethod": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "oauth" + ] + }, + "label": { + "type": "string" + }, + "prompts": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Integration.TextPrompt" + }, + { + "$ref": "#/components/schemas/Integration.SelectPrompt" + } + ] + } + } + }, + "required": [ + "id", + "type", + "label" + ], + "additionalProperties": false + }, + "Integration.KeyMethod": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "key" + ] + }, + "label": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "Integration.EnvMethod": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "env" + ] + }, + "names": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "names" + ], + "additionalProperties": false + }, + "Integration.Method": { + "anyOf": [ + { + "$ref": "#/components/schemas/Integration.OAuthMethod" + }, + { + "$ref": "#/components/schemas/Integration.KeyMethod" + }, + { + "$ref": "#/components/schemas/Integration.EnvMethod" + } + ] + }, + "Connection.CredentialInfo": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "credential" + ] + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + } + }, + "required": [ + "type", + "id", + "label" + ], + "additionalProperties": false + }, + "Connection.EnvInfo": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "env" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "type", + "name" + ], + "additionalProperties": false + }, + "Connection.Info": { + "anyOf": [ + { + "$ref": "#/components/schemas/Connection.CredentialInfo" + }, + { + "$ref": "#/components/schemas/Connection.EnvInfo" + } + ] + }, + "Integration.Info": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "methods": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Integration.Method" + } + }, + "connections": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Connection.Info" + } + } + }, + "required": [ + "id", + "name", + "methods", + "connections" + ], + "additionalProperties": false + }, + "Integration.Attempt": { + "type": "object", + "properties": { + "attemptID": { + "type": "string" + }, + "url": { + "type": "string" + }, + "instructions": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "auto", + "code" + ] + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "expires": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "created", + "expires" + ], + "additionalProperties": false + } + }, + "required": [ + "attemptID", + "url", + "instructions", + "mode", + "time" + ], + "additionalProperties": false + }, + "Integration.AttemptStatus": { + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "pending" + ] + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "expires": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "created", + "expires" + ], + "additionalProperties": false + } + }, + "required": [ + "status", + "time" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "complete" + ] + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "expires": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "created", + "expires" + ], + "additionalProperties": false + } + }, + "required": [ + "status", + "time" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "failed" + ] + }, + "message": { + "type": "string" + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "expires": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "created", + "expires" + ], + "additionalProperties": false + } + }, + "required": [ + "status", + "message", + "time" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "expired" + ] + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "expires": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "created", + "expires" + ], + "additionalProperties": false + } + }, + "required": [ + "status", + "time" + ], + "additionalProperties": false + } + ] + }, + "Mcp.Status.Connected": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "connected" + ] + } + }, + "required": [ + "status" + ], + "additionalProperties": false + }, + "Mcp.Status.Pending": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "pending" + ] + } + }, + "required": [ + "status" + ], + "additionalProperties": false + }, + "Mcp.Status.Disabled": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "disabled" + ] + } + }, + "required": [ + "status" + ], + "additionalProperties": false + }, + "Mcp.Status.Failed": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "failed" + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "status", + "error" + ], + "additionalProperties": false + }, + "Mcp.Status.NeedsAuth": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "needs_auth" + ] + } + }, + "required": [ + "status" + ], + "additionalProperties": false + }, + "Mcp.Status.NeedsClientRegistration": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "needs_client_registration" + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "status", + "error" + ], + "additionalProperties": false + }, + "Mcp.Server": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "status": { + "anyOf": [ + { + "$ref": "#/components/schemas/Mcp.Status.Connected" + }, + { + "$ref": "#/components/schemas/Mcp.Status.Pending" + }, + { + "$ref": "#/components/schemas/Mcp.Status.Disabled" + }, + { + "$ref": "#/components/schemas/Mcp.Status.Failed" + }, + { + "$ref": "#/components/schemas/Mcp.Status.NeedsAuth" + }, + { + "$ref": "#/components/schemas/Mcp.Status.NeedsClientRegistration" + } + ] + }, + "integrationID": { + "type": "string" + } + }, + "required": [ + "name", + "status" + ], + "additionalProperties": false + }, + "Project.Vcs": { + "type": "string", + "enum": [ + "git", + "hg" + ] + }, + "Project.Icon": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "override": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Project.Commands": { + "type": "object", + "properties": { + "start": { + "type": "string", + "description": "Startup script to run when creating a new workspace (worktree)" + } + }, + "additionalProperties": false + }, + "Project.Time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "updated": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "initialized": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "created", + "updated" + ], + "additionalProperties": false + }, + "Project": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "worktree": { + "type": "string" + }, + "vcs": { + "$ref": "#/components/schemas/Project.Vcs" + }, + "name": { + "type": "string" + }, + "icon": { + "$ref": "#/components/schemas/Project.Icon" + }, + "commands": { + "$ref": "#/components/schemas/Project.Commands" + }, + "time": { + "$ref": "#/components/schemas/Project.Time" + }, + "sandboxes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "worktree", + "time", + "sandboxes" + ], + "additionalProperties": false + }, + "Project.Current": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "directory": { + "type": "string" + } + }, + "required": [ + "id", + "directory" + ], + "additionalProperties": false + }, + "Project.Directory": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "strategy": { + "type": "string" + } + }, + "required": [ + "directory" + ], + "additionalProperties": false + }, + "Project.Directories": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Project.Directory" + } + }, + "Form.Metadata": { + "type": "object" + }, + "Form.When": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "op": { + "type": "string", + "enum": [ + "eq", + "neq" + ] + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + { + "type": "boolean" + } + ] + } + }, + "required": [ + "key", + "op", + "value" + ], + "additionalProperties": false + }, + "Form.Option": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "value", + "label" + ], + "additionalProperties": false + }, + "Form.StringField": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "when": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.When" + } + }, + "type": { + "type": "string", + "enum": [ + "string" + ] + }, + "format": { + "type": "string", + "enum": [ + "email", + "uri", + "date", + "date-time" + ] + }, + "minLength": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "maxLength": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "pattern": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "default": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.Option" + } + }, + "custom": { + "type": "boolean" + } + }, + "required": [ + "key", + "type" + ], + "additionalProperties": false + }, + "Form.NumberField": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "when": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.When" + } + }, + "type": { + "type": "string", + "enum": [ + "number" + ] + }, + "minimum": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "maximum": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "default": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "key", + "type" + ], + "additionalProperties": false + }, + "Form.IntegerField": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "when": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.When" + } + }, + "type": { + "type": "string", + "enum": [ + "integer" + ] + }, + "minimum": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "maximum": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "default": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "key", + "type" + ], + "additionalProperties": false + }, + "Form.BooleanField": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "when": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.When" + } + }, + "type": { + "type": "string", + "enum": [ + "boolean" + ] + }, + "default": { + "type": "boolean" + } + }, + "required": [ + "key", + "type" + ], + "additionalProperties": false + }, + "Form.MultiselectField": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "when": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.When" + } + }, + "type": { + "type": "string", + "enum": [ + "multiselect" + ] + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.Option" + } + }, + "minItems": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "maxItems": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "custom": { + "type": "boolean" + }, + "default": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "key", + "type", + "options" + ], + "additionalProperties": false + }, + "Form.FormInfo": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + "sessionID": { + "type": "string" + }, + "title": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Form.Metadata" + }, + "mode": { + "type": "string", + "enum": [ + "form" + ] + }, + "fields": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Form.StringField" + }, + { + "$ref": "#/components/schemas/Form.NumberField" + }, + { + "$ref": "#/components/schemas/Form.IntegerField" + }, + { + "$ref": "#/components/schemas/Form.BooleanField" + }, + { + "$ref": "#/components/schemas/Form.MultiselectField" + } + ] + } + } + }, + "required": [ + "id", + "sessionID", + "mode", + "fields" + ], + "additionalProperties": false + }, + "Form.UrlInfo": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + "sessionID": { + "type": "string" + }, + "title": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Form.Metadata" + }, + "mode": { + "type": "string", + "enum": [ + "url" + ] + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "sessionID", + "mode", + "url" + ], + "additionalProperties": false + }, + "Form.CreatePayload": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + { + "type": "null" + } + ] + }, + "title": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Form.Metadata" + }, + "mode": { + "type": "string", + "enum": [ + "form", + "url" + ] + }, + "fields": { + "anyOf": [ + { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Form.StringField" + }, + { + "$ref": "#/components/schemas/Form.NumberField" + }, + { + "$ref": "#/components/schemas/Form.IntegerField" + }, + { + "$ref": "#/components/schemas/Form.BooleanField" + }, + { + "$ref": "#/components/schemas/Form.MultiselectField" + } + ] + } + }, + { + "type": "null" + } + ] + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "mode" + ], + "additionalProperties": false + }, + "FormNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "FormNotFoundError" + ] + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "id", + "message" + ], + "additionalProperties": false + }, + "Form.Value": { + "anyOf": [ + { + "type": "string" + }, + { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "Form.Answer": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Form.Value" + } + }, + "Form.State": { + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "pending" + ] + } + }, + "required": [ + "status" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "answered" + ] + }, + "answer": { + "$ref": "#/components/schemas/Form.Answer" + } + }, + "required": [ + "status", + "answer" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "cancelled" + ] + } + }, + "required": [ + "status" + ], + "additionalProperties": false + } + ] + }, + "Form.Reply": { + "type": "object", + "properties": { + "answer": { + "$ref": "#/components/schemas/Form.Answer" + } + }, + "required": [ + "answer" + ], + "additionalProperties": false + }, + "FormAlreadySettledError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "FormAlreadySettledError" + ] + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "id", + "message" + ], + "additionalProperties": false + }, + "FormInvalidAnswerError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "FormInvalidAnswerError" + ] + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "id", + "message" + ], + "additionalProperties": false + }, + "PermissionV2.Source": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "tool" + ] + }, + "messageID": { + "type": "string" + }, + "callID": { + "type": "string" + } + }, + "required": [ + "type", + "messageID", + "callID" + ], + "additionalProperties": false + } + ] + }, + "PermissionV2.Request": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^per" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "action": { + "type": "string" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + } + }, + "save": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object" + }, + "source": { + "$ref": "#/components/schemas/PermissionV2.Source" + } + }, + "required": [ + "id", + "sessionID", + "action", + "resources" + ], + "additionalProperties": false + }, + "PermissionSaved.Info": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "projectID": { + "type": "string" + }, + "action": { + "type": "string" + }, + "resource": { + "type": "string" + } + }, + "required": [ + "id", + "projectID", + "action", + "resource" + ], + "additionalProperties": false + }, + "PermissionNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "PermissionNotFoundError" + ] + }, + "requestID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "requestID", + "message" + ], + "additionalProperties": false + }, + "PermissionV2.Reply": { + "type": "string", + "enum": [ + "once", + "always", + "reject" + ] + }, + "FileSystem.Entry": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "file", + "directory" + ] + } + }, + "required": [ + "path", + "type" + ], + "additionalProperties": false + }, + "CommandV2.Info": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "template": { + "type": "string" + }, + "description": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + }, + "subtask": { + "type": "boolean" + } + }, + "required": [ + "name", + "template" + ], + "additionalProperties": false + }, + "SkillV2.Info": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "slash": { + "type": "boolean" + }, + "autoinvoke": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "content": { + "type": "string" + } + }, + "required": [ + "name", + "location", + "content" + ], + "additionalProperties": false + }, + "models-dev.refreshed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "models-dev.refreshed" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "integration.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "integration.updated" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "integration.connection.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "integration.connection.updated" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "integrationID": { + "type": "string" + } + }, + "required": [ + "integrationID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "catalog.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "catalog.updated" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "agent.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "agent.updated" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "SnapshotFileDiff": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "patch": { + "type": "string" + }, + "additions": { + "type": "number" + }, + "deletions": { + "type": "number" + }, + "status": { + "type": "string", + "enum": [ + "added", + "deleted", + "modified" + ] + } + }, + "required": [ + "additions", + "deletions" + ], + "additionalProperties": false + }, + "PermissionAction": { + "type": "string", + "enum": [ + "allow", + "deny", + "ask" + ] + }, + "PermissionRule": { + "type": "object", + "properties": { + "permission": { + "type": "string" + }, + "pattern": { + "type": "string" + }, + "action": { + "$ref": "#/components/schemas/PermissionAction" + } + }, + "required": [ + "permission", + "pattern", + "action" + ], + "additionalProperties": false + }, + "PermissionRuleset": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionRule" + } + }, + "Session": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "slug": { + "type": "string" + }, + "projectID": { + "type": "string" + }, + "workspaceID": { + "type": "string", + "allOf": [ + { + "pattern": "^wrk" + } + ] + }, + "directory": { + "type": "string" + }, + "path": { + "type": "string" + }, + "parentID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "summary": { + "type": "object", + "properties": { + "additions": { + "type": "number" + }, + "deletions": { + "type": "number" + }, + "files": { + "type": "number" + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": [ + "additions", + "deletions", + "files" + ], + "additionalProperties": false + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ], + "additionalProperties": false + } + }, + "required": [ + "input", + "output", + "reasoning", + "cache" + ], + "additionalProperties": false + }, + "share": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": [ + "url" + ], + "additionalProperties": false + }, + "title": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": [ + "id", + "providerID" + ], + "additionalProperties": false + }, + "version": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "updated": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "compacting": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "archived": { + "type": "number" + } + }, + "required": [ + "created", + "updated" + ], + "additionalProperties": false + }, + "permission": { + "$ref": "#/components/schemas/PermissionRuleset" + }, + "revert": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "partID": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" + } + }, + "required": [ + "messageID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "slug", + "projectID", + "directory", + "title", + "version", + "time" + ], + "additionalProperties": false + }, + "session.created": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.created" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": [ + "sessionID", + "info" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.updated" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": [ + "sessionID", + "info" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.deleted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.deleted" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": [ + "sessionID", + "info" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "JSONSchema": { + "type": "object" + }, + "OutputFormat": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "json_schema" + ] + }, + "schema": { + "$ref": "#/components/schemas/JSONSchema" + }, + "retryCount": { + "anyOf": [ + { + "anyOf": [ + { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "schema" + ], + "additionalProperties": false + } + ] + }, + "UserMessage": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "role": { + "type": "string", + "enum": [ + "user" + ] + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "format": { + "anyOf": [ + { + "$ref": "#/components/schemas/OutputFormat" + }, + { + "type": "null" + } + ] + }, + "summary": { + "anyOf": [ + { + "type": "object", + "properties": { + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "body": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": [ + "diffs" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + }, + "variant": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "providerID", + "modelID" + ], + "additionalProperties": false + }, + "system": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "tools": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "sessionID", + "role", + "time", + "agent", + "model" + ], + "additionalProperties": false + }, + "ProviderAuthError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "ProviderAuthError" + ] + }, + "data": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "providerID", + "message" + ], + "additionalProperties": false + } + }, + "required": [ + "name", + "data" + ], + "additionalProperties": false + }, + "UnknownError1": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "UnknownError" + ] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "message" + ], + "additionalProperties": false + } + }, + "required": [ + "name", + "data" + ], + "additionalProperties": false + }, + "MessageOutputLengthError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "MessageOutputLengthError" + ] + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": [ + "name", + "data" + ], + "additionalProperties": false + }, + "MessageAbortedError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "MessageAbortedError" + ] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "additionalProperties": false + } + }, + "required": [ + "name", + "data" + ], + "additionalProperties": false + }, + "StructuredOutputError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "StructuredOutputError" + ] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "retries": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "message", + "retries" + ], + "additionalProperties": false + } + }, + "required": [ + "name", + "data" + ], + "additionalProperties": false + }, + "ContextOverflowError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "ContextOverflowError" + ] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "responseBody": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "message" + ], + "additionalProperties": false + } + }, + "required": [ + "name", + "data" + ], + "additionalProperties": false + }, + "ContentFilterError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "ContentFilterError" + ] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "additionalProperties": false + } + }, + "required": [ + "name", + "data" + ], + "additionalProperties": false + }, + "APIError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "APIError" + ] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "statusCode": { + "anyOf": [ + { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + { + "type": "null" + } + ] + }, + "isRetryable": { + "type": "boolean" + }, + "responseHeaders": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "responseBody": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "metadata": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "message", + "isRetryable" + ], + "additionalProperties": false + } + }, + "required": [ + "name", + "data" + ], + "additionalProperties": false + }, + "AssistantMessage": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "role": { + "type": "string", + "enum": [ + "assistant" + ] + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "completed": { + "anyOf": [ + { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "error": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/components/schemas/ProviderAuthError" + }, + { + "$ref": "#/components/schemas/UnknownError1" + }, + { + "$ref": "#/components/schemas/MessageOutputLengthError" + }, + { + "$ref": "#/components/schemas/MessageAbortedError" + }, + { + "$ref": "#/components/schemas/StructuredOutputError" + }, + { + "$ref": "#/components/schemas/ContextOverflowError" + }, + { + "$ref": "#/components/schemas/ContentFilterError" + }, + { + "$ref": "#/components/schemas/APIError" + } + ] + }, + { + "type": "null" + } + ] + }, + "parentID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "modelID": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "path": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "root": { + "type": "string" + } + }, + "required": [ + "cwd", + "root" + ], + "additionalProperties": false + }, + "summary": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "total": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ], + "additionalProperties": false + } + }, + "required": [ + "input", + "output", + "reasoning", + "cache" + ], + "additionalProperties": false + }, + "structured": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + }, + "variant": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "finish": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "sessionID", + "role", + "time", + "parentID", + "modelID", + "providerID", + "mode", + "agent", + "path", + "cost", + "tokens" + ], + "additionalProperties": false + }, + "Message": { + "anyOf": [ + { + "$ref": "#/components/schemas/UserMessage" + }, + { + "$ref": "#/components/schemas/AssistantMessage" + } + ] + }, + "message.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "message.updated" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "info": { + "$ref": "#/components/schemas/Message" + } + }, + "required": [ + "sessionID", + "info" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "message.removed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "message.removed" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + } + }, + "required": [ + "sessionID", + "messageID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "TextPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string" + }, + "synthetic": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ignored": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "time": { + "anyOf": [ + { + "type": "object", + "properties": { + "start": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "end": { + "anyOf": [ + { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "start" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "metadata": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "text" + ], + "additionalProperties": false + }, + "SubtaskPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "subtask" + ] + }, + "prompt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "anyOf": [ + { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": [ + "providerID", + "modelID" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "prompt", + "description", + "agent" + ], + "additionalProperties": false + }, + "ReasoningPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "reasoning" + ] + }, + "text": { + "type": "string" + }, + "metadata": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "end": { + "anyOf": [ + { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "start" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "text", + "time" + ], + "additionalProperties": false + }, + "FilePartSourceText": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "start": { + "type": "number" + }, + "end": { + "type": "number" + } + }, + "required": [ + "value", + "start", + "end" + ], + "additionalProperties": false + }, + "FileSource": { + "type": "object", + "properties": { + "text": { + "$ref": "#/components/schemas/FilePartSourceText" + }, + "type": { + "type": "string", + "enum": [ + "file" + ] + }, + "path": { + "type": "string" + } + }, + "required": [ + "text", + "type", + "path" + ], + "additionalProperties": false + }, + "Range": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "character": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "character": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + }, + "SymbolSource": { + "type": "object", + "properties": { + "text": { + "$ref": "#/components/schemas/FilePartSourceText" + }, + "type": { + "type": "string", + "enum": [ + "symbol" + ] + }, + "path": { + "type": "string" + }, + "range": { + "$ref": "#/components/schemas/Range" + }, + "name": { + "type": "string" + }, + "kind": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "text", + "type", + "path", + "range", + "name", + "kind" + ], + "additionalProperties": false + }, + "ResourceSource": { + "type": "object", + "properties": { + "text": { + "$ref": "#/components/schemas/FilePartSourceText" + }, + "type": { + "type": "string", + "enum": [ + "resource" + ] + }, + "clientName": { + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "required": [ + "text", + "type", + "clientName", + "uri" + ], + "additionalProperties": false + }, + "FilePartSource": { + "anyOf": [ + { + "$ref": "#/components/schemas/FileSource" + }, + { + "$ref": "#/components/schemas/SymbolSource" + }, + { + "$ref": "#/components/schemas/ResourceSource" + } + ] + }, + "FilePart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "file" + ] + }, + "mime": { + "type": "string" + }, + "filename": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "url": { + "type": "string" + }, + "source": { + "anyOf": [ + { + "$ref": "#/components/schemas/FilePartSource" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "mime", + "url" + ], + "additionalProperties": false + }, + "ToolStatePending": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "pending" + ] + }, + "input": { + "type": "object" + }, + "raw": { + "type": "string" + } + }, + "required": [ + "status", + "input", + "raw" + ], + "additionalProperties": false + }, + "ToolStateRunning": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "running" + ] + }, + "input": { + "type": "object" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "metadata": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "start" + ], + "additionalProperties": false + } + }, + "required": [ + "status", + "input", + "time" + ], + "additionalProperties": false + }, + "ToolStateCompleted": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "completed" + ] + }, + "input": { + "type": "object" + }, + "output": { + "type": "string" + }, + "title": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "end": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "compacted": { + "anyOf": [ + { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + }, + "attachments": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/components/schemas/FilePart" + } + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "status", + "input", + "output", + "title", + "metadata", + "time" + ], + "additionalProperties": false + }, + "ToolStateError": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "error" + ] + }, + "input": { + "type": "object" + }, + "error": { + "type": "string" + }, + "metadata": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "end": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + } + }, + "required": [ + "status", + "input", + "error", + "time" + ], + "additionalProperties": false + }, + "ToolState": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolStatePending" + }, + { + "$ref": "#/components/schemas/ToolStateRunning" + }, + { + "$ref": "#/components/schemas/ToolStateCompleted" + }, + { + "$ref": "#/components/schemas/ToolStateError" + } + ] + }, + "ToolPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "tool" + ] + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/ToolState" + }, + "metadata": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "callID", + "tool", + "state" + ], + "additionalProperties": false + }, + "StepStartPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "step-start" + ] + }, + "snapshot": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type" + ], + "additionalProperties": false + }, + "StepFinishPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "step-finish" + ] + }, + "reason": { + "type": "string" + }, + "snapshot": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "total": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ], + "additionalProperties": false + } + }, + "required": [ + "input", + "output", + "reasoning", + "cache" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "reason", + "cost", + "tokens" + ], + "additionalProperties": false + }, + "SnapshotPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "snapshot" + ] + }, + "snapshot": { + "type": "string" + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "snapshot" + ], + "additionalProperties": false + }, + "PatchPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "patch" + ] + }, + "hash": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "hash", + "files" + ], + "additionalProperties": false + }, + "AgentPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "agent" + ] + }, + "name": { + "type": "string" + }, + "source": { + "anyOf": [ + { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "start": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "end": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "value", + "start", + "end" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "name" + ], + "additionalProperties": false + }, + "RetryPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "retry" + ] + }, + "attempt": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "error": { + "$ref": "#/components/schemas/APIError" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "created" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "attempt", + "error", + "time" + ], + "additionalProperties": false + }, + "CompactionPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "compaction" + ] + }, + "auto": { + "type": "boolean" + }, + "overflow": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "tail_start_id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "auto" + ], + "additionalProperties": false + }, + "Part": { + "anyOf": [ + { + "$ref": "#/components/schemas/TextPart" + }, + { + "$ref": "#/components/schemas/SubtaskPart" + }, + { + "$ref": "#/components/schemas/ReasoningPart" + }, + { + "$ref": "#/components/schemas/FilePart" + }, + { + "$ref": "#/components/schemas/ToolPart" + }, + { + "$ref": "#/components/schemas/StepStartPart" + }, + { + "$ref": "#/components/schemas/StepFinishPart" + }, + { + "$ref": "#/components/schemas/SnapshotPart" + }, + { + "$ref": "#/components/schemas/PatchPart" + }, + { + "$ref": "#/components/schemas/AgentPart" + }, + { + "$ref": "#/components/schemas/RetryPart" + }, + { + "$ref": "#/components/schemas/CompactionPart" + } + ] + }, + "message.part.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "message.part.updated" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "part": { + "$ref": "#/components/schemas/Part" + }, + "time": { + "type": "number" + } + }, + "required": [ + "sessionID", + "part", + "time" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "message.part.removed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "message.part.removed" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "partID": { + "type": "string", + "allOf": [ + { + "pattern": "^prt" + } + ] + } + }, + "required": [ + "sessionID", + "messageID", + "partID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.execution.settled": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.execution.settled" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "outcome": { + "type": "string", + "enum": [ + "success", + "failure", + "interrupted" + ] + }, + "error": { + "$ref": "#/components/schemas/Session.Error.Unknown" + } + }, + "required": [ + "sessionID", + "outcome" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "session.text.delta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.text.delta" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "textID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": [ + "sessionID", + "assistantMessageID", + "textID", + "delta" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "session.reasoning.delta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.reasoning.delta" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "reasoningID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": [ + "sessionID", + "assistantMessageID", + "reasoningID", + "delta" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "session.tool.input.delta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.tool.input.delta" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "callID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": [ + "sessionID", + "assistantMessageID", + "callID", + "delta" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "session.compaction.delta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.compaction.delta" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "text": { + "type": "string" + } + }, + "required": [ + "sessionID", + "text" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "filesystem.changed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "filesystem.changed" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "type": "string", + "enum": [ + "add", + "change", + "unlink" + ] + } + }, + "required": [ + "file", + "event" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "reference.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "reference.updated" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "permission.v2.asked": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "permission.v2.asked" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^per" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "action": { + "type": "string" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + } + }, + "save": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object" + }, + "source": { + "$ref": "#/components/schemas/PermissionV2.Source" + } + }, + "required": [ + "id", + "sessionID", + "action", + "resources" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "permission.v2.replied": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "permission.v2.replied" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "requestID": { + "type": "string", + "allOf": [ + { + "pattern": "^per" + } + ] + }, + "reply": { + "$ref": "#/components/schemas/PermissionV2.Reply" + } + }, + "required": [ + "sessionID", + "requestID", + "reply" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "plugin.added": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "plugin.added" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "plugin.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "plugin.updated" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "project.directories.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "project.directories.updated" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "projectID": { + "type": "string" + } + }, + "required": [ + "projectID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "command.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "command.updated" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "config.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "config.updated" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "skill.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "skill.updated" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "Pty": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^pty" + } + ] + }, + "title": { + "type": "string" + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "cwd": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "running", + "exited" + ] + }, + "pid": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "exitCode": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "id", + "title", + "command", + "args", + "cwd", + "status", + "pid" + ], + "additionalProperties": false + }, + "pty.created": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "pty.created" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": [ + "info" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "pty.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "pty.updated" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": [ + "info" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "pty.exited": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "pty.exited" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^pty" + } + ] + }, + "exitCode": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "id", + "exitCode" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "pty.deleted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "pty.deleted" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^pty" + } + ] + } + }, + "required": [ + "id" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "shell.created": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "shell.created" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Shell1" + } + }, + "required": [ + "info" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "shell.exited": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "shell.exited" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "exit": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + "status": { + "type": "string", + "enum": [ + "running", + "exited", + "timeout", + "killed" + ] + } + }, + "required": [ + "id", + "status" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "shell.deleted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "shell.deleted" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + } + }, + "required": [ + "id" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "QuestionV2.Option": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "Display text (1-5 words, concise)" + }, + "description": { + "type": "string", + "description": "Explanation of choice" + } + }, + "required": [ + "label", + "description" + ], + "additionalProperties": false + }, + "QuestionV2.Info": { + "type": "object", + "properties": { + "question": { + "type": "string", + "description": "Complete question" + }, + "header": { + "type": "string", + "description": "Very short label (max 30 chars)" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2.Option" + }, + "description": "Available choices" + }, + "multiple": { + "type": "boolean" + }, + "custom": { + "type": "boolean" + } + }, + "required": [ + "question", + "header", + "options" + ], + "additionalProperties": false + }, + "QuestionV2.Tool": { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "callID": { + "type": "string" + } + }, + "required": [ + "messageID", + "callID" + ], + "additionalProperties": false + }, + "question.v2.asked": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "question.v2.asked" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^que" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2.Info" + }, + "description": "Questions to ask" + }, + "tool": { + "$ref": "#/components/schemas/QuestionV2.Tool" + } + }, + "required": [ + "id", + "sessionID", + "questions" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "QuestionV2.Answer": { + "type": "array", + "items": { + "type": "string" + } + }, + "question.v2.replied": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "question.v2.replied" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "requestID": { + "type": "string", + "allOf": [ + { + "pattern": "^que" + } + ] + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2.Answer" + } + } + }, + "required": [ + "sessionID", + "requestID", + "answers" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "question.v2.rejected": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "question.v2.rejected" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "requestID": { + "type": "string", + "allOf": [ + { + "pattern": "^que" + } + ] + } + }, + "required": [ + "sessionID", + "requestID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "Form.Metadata1": { + "type": "object" + }, + "Form.When1": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "op": { + "type": "string", + "enum": [ + "eq", + "neq" + ] + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "boolean" + } + ] + } + }, + "required": [ + "key", + "op", + "value" + ], + "additionalProperties": false + }, + "Form.StringField1": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "when": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.When1" + } + }, + "type": { + "type": "string", + "enum": [ + "string" + ] + }, + "format": { + "type": "string", + "enum": [ + "email", + "uri", + "date", + "date-time" + ] + }, + "minLength": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "maxLength": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "pattern": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "default": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.Option" + } + }, + "custom": { + "type": "boolean" + } + }, + "required": [ + "key", + "type" + ], + "additionalProperties": false + }, + "Form.NumberField1": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "when": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.When1" + } + }, + "type": { + "type": "string", + "enum": [ + "number" + ] + }, + "minimum": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + "maximum": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + "default": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + } + }, + "required": [ + "key", + "type" + ], + "additionalProperties": false + }, + "Form.IntegerField1": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "when": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.When1" + } + }, + "type": { + "type": "string", + "enum": [ + "integer" + ] + }, + "minimum": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + "maximum": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + "default": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + } + }, + "required": [ + "key", + "type" + ], + "additionalProperties": false + }, + "Form.BooleanField1": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "when": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.When1" + } + }, + "type": { + "type": "string", + "enum": [ + "boolean" + ] + }, + "default": { + "type": "boolean" + } + }, + "required": [ + "key", + "type" + ], + "additionalProperties": false + }, + "Form.MultiselectField1": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "when": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.When1" + } + }, + "type": { + "type": "string", + "enum": [ + "multiselect" + ] + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Form.Option" + } + }, + "minItems": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "maxItems": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "custom": { + "type": "boolean" + }, + "default": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "key", + "type", + "options" + ], + "additionalProperties": false + }, + "Form.FormInfo1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + "sessionID": { + "type": "string" + }, + "title": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Form.Metadata1" + }, + "mode": { + "type": "string", + "enum": [ + "form" + ] + }, + "fields": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Form.StringField1" + }, + { + "$ref": "#/components/schemas/Form.NumberField1" + }, + { + "$ref": "#/components/schemas/Form.IntegerField1" + }, + { + "$ref": "#/components/schemas/Form.BooleanField1" + }, + { + "$ref": "#/components/schemas/Form.MultiselectField1" + } + ] + } + } + }, + "required": [ + "id", + "sessionID", + "mode", + "fields" + ], + "additionalProperties": false + }, + "Form.UrlInfo1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + "sessionID": { + "type": "string" + }, + "title": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Form.Metadata1" + }, + "mode": { + "type": "string", + "enum": [ + "url" + ] + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "sessionID", + "mode", + "url" + ], + "additionalProperties": false + }, + "form.created": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "form.created" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "form": { + "anyOf": [ + { + "$ref": "#/components/schemas/Form.FormInfo1" + }, + { + "$ref": "#/components/schemas/Form.UrlInfo1" + } + ] + } + }, + "required": [ + "form" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "Form.Value1": { + "anyOf": [ + { + "type": "string" + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "Form.Answer1": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Form.Value1" + } + }, + "form.replied": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "form.replied" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + "sessionID": { + "type": "string" + }, + "answer": { + "$ref": "#/components/schemas/Form.Answer1" + } + }, + "required": [ + "id", + "sessionID", + "answer" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "form.cancelled": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "form.cancelled" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^frm_" + } + ] + }, + "sessionID": { + "type": "string" + } + }, + "required": [ + "id", + "sessionID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "Todo": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "Brief description of the task" + }, + "status": { + "type": "string", + "description": "Current status of the task: pending, in_progress, completed, cancelled" + }, + "priority": { + "type": "string", + "description": "Priority level of the task: high, medium, low" + } + }, + "required": [ + "content", + "status", + "priority" + ], + "additionalProperties": false + }, + "todo.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "todo.updated" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "todos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "required": [ + "sessionID", + "todos" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "SessionStatus": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "idle" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "retry" + ] + }, + "attempt": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "message": { + "type": "string" + }, + "action": { + "type": "object", + "properties": { + "reason": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "label": { + "type": "string" + }, + "link": { + "type": "string" + } + }, + "required": [ + "reason", + "provider", + "title", + "message", + "label" + ], + "additionalProperties": false + }, + "next": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "type", + "attempt", + "message", + "next" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "busy" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, + "session.status": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.status" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "status": { + "$ref": "#/components/schemas/SessionStatus" + } + }, + "required": [ + "sessionID", + "status" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "session.idle": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.idle" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "tui.prompt.append": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "tui.prompt.append" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "tui.command.execute": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "tui.command.execute" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "command": { + "anyOf": [ + { + "type": "string", + "enum": [ + "session.list", + "session.new", + "session.share", + "session.interrupt", + "session.background", + "session.compact", + "session.page.up", + "session.page.down", + "session.line.up", + "session.line.down", + "session.half.page.up", + "session.half.page.down", + "session.first", + "session.last", + "prompt.clear", + "prompt.submit", + "agent.cycle" + ] + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "command" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "tui.toast.show": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "tui.toast.show" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": [ + "info", + "success", + "warning", + "error" + ] + }, + "duration": { + "anyOf": [ + { + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "message", + "variant" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "tui.session.select": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "tui.session.select" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses", + "description": "Session ID to navigate to" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "installation.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "installation.updated" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": [ + "version" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "installation.update-available": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "installation.update-available" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": [ + "version" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "vcs.branch.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "vcs.branch.updated" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "branch": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "mcp.status.changed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "mcp.status.changed" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "server": { + "type": "string" + } + }, + "required": [ + "server" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "permission.asked": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "permission.asked" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^per" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "permission": { + "type": "string" + }, + "patterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object" + }, + "always": { + "type": "array", + "items": { + "type": "string" + } + }, + "tool": { + "anyOf": [ + { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "callID": { + "type": "string" + } + }, + "required": [ + "messageID", + "callID" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "sessionID", + "permission", + "patterns", + "metadata", + "always" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "permission.replied": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "permission.replied" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "requestID": { + "type": "string", + "allOf": [ + { + "pattern": "^per" + } + ] + }, + "reply": { + "type": "string", + "enum": [ + "once", + "always", + "reject" + ] + } + }, + "required": [ + "sessionID", + "requestID", + "reply" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "QuestionOption": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "Display text (1-5 words, concise)" + }, + "description": { + "type": "string", + "description": "Explanation of choice" + } + }, + "required": [ + "label", + "description" + ], + "additionalProperties": false + }, + "QuestionInfo": { + "type": "object", + "properties": { + "question": { + "type": "string", + "description": "Complete question" + }, + "header": { + "type": "string", + "description": "Very short label (max 30 chars)" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionOption" + }, + "description": "Available choices" + }, + "multiple": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Allow selecting multiple choices" + }, + "custom": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Allow typing a custom answer (default: true)" + } + }, + "required": [ + "question", + "header", + "options" + ], + "additionalProperties": false + }, + "QuestionTool": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg" + } + ] + }, + "callID": { + "type": "string" + } + }, + "required": [ + "messageID", + "callID" + ], + "additionalProperties": false + }, + "question.asked": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "question.asked" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^que" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionInfo" + }, + "description": "Questions to ask" + }, + "tool": { + "anyOf": [ + { + "$ref": "#/components/schemas/QuestionTool" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "sessionID", + "questions" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "QuestionAnswer": { + "type": "array", + "items": { + "type": "string" + } + }, + "question.replied": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "question.replied" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "requestID": { + "type": "string", + "allOf": [ + { + "pattern": "^que" + } + ] + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionAnswer" + } + } + }, + "required": [ + "sessionID", + "requestID", + "answers" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "question.rejected": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "question.rejected" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "requestID": { + "type": "string", + "allOf": [ + { + "pattern": "^que" + } + ] + } + }, + "required": [ + "sessionID", + "requestID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "session.error": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.error" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + { + "type": "null" + } + ] + }, + "error": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/components/schemas/ProviderAuthError" + }, + { + "$ref": "#/components/schemas/UnknownError1" + }, + { + "$ref": "#/components/schemas/MessageOutputLengthError" + }, + { + "$ref": "#/components/schemas/MessageAbortedError" + }, + { + "$ref": "#/components/schemas/StructuredOutputError" + }, + { + "$ref": "#/components/schemas/ContextOverflowError" + }, + { + "$ref": "#/components/schemas/ContentFilterError" + }, + { + "$ref": "#/components/schemas/APIError" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "V2Event.server.connected": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "metadata": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + }, + "location": { + "anyOf": [ + { + "$ref": "#/components/schemas/Location.Ref" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "server.connected" + ] + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": [ + "id", + "type", + "data" + ], + "additionalProperties": false + }, + "V2Event": { + "anyOf": [ + { + "$ref": "#/components/schemas/models-dev.refreshed" + }, + { + "$ref": "#/components/schemas/integration.updated" + }, + { + "$ref": "#/components/schemas/integration.connection.updated" + }, + { + "$ref": "#/components/schemas/catalog.updated" + }, + { + "$ref": "#/components/schemas/agent.updated" + }, + { + "$ref": "#/components/schemas/session.created" + }, + { + "$ref": "#/components/schemas/session.updated" + }, + { + "$ref": "#/components/schemas/session.deleted" + }, + { + "$ref": "#/components/schemas/message.updated" + }, + { + "$ref": "#/components/schemas/message.removed" + }, + { + "$ref": "#/components/schemas/message.part.updated" + }, + { + "$ref": "#/components/schemas/message.part.removed" + }, + { + "$ref": "#/components/schemas/session.agent.selected" + }, + { + "$ref": "#/components/schemas/session.model.selected" + }, + { + "$ref": "#/components/schemas/session.moved" + }, + { + "$ref": "#/components/schemas/session.renamed" + }, + { + "$ref": "#/components/schemas/session.forked" + }, + { + "$ref": "#/components/schemas/session.prompt.promoted" + }, + { + "$ref": "#/components/schemas/session.prompt.admitted" + }, + { + "$ref": "#/components/schemas/session.execution.settled" + }, + { + "$ref": "#/components/schemas/session.context.updated" + }, + { + "$ref": "#/components/schemas/session.synthetic" + }, + { + "$ref": "#/components/schemas/session.skill.activated" + }, + { + "$ref": "#/components/schemas/session.shell.started" + }, + { + "$ref": "#/components/schemas/session.shell.ended" + }, + { + "$ref": "#/components/schemas/session.step.started" + }, + { + "$ref": "#/components/schemas/session.step.ended" + }, + { + "$ref": "#/components/schemas/session.step.failed" + }, + { + "$ref": "#/components/schemas/session.text.started" + }, + { + "$ref": "#/components/schemas/session.text.delta" + }, + { + "$ref": "#/components/schemas/session.text.ended" + }, + { + "$ref": "#/components/schemas/session.reasoning.started" + }, + { + "$ref": "#/components/schemas/session.reasoning.delta" + }, + { + "$ref": "#/components/schemas/session.reasoning.ended" + }, + { + "$ref": "#/components/schemas/session.tool.input.started" + }, + { + "$ref": "#/components/schemas/session.tool.input.delta" + }, + { + "$ref": "#/components/schemas/session.tool.input.ended" + }, + { + "$ref": "#/components/schemas/session.tool.called" + }, + { + "$ref": "#/components/schemas/session.tool.progress" + }, + { + "$ref": "#/components/schemas/session.tool.success" + }, + { + "$ref": "#/components/schemas/session.tool.failed" + }, + { + "$ref": "#/components/schemas/session.retried" + }, + { + "$ref": "#/components/schemas/session.compaction.started" + }, + { + "$ref": "#/components/schemas/session.compaction.delta" + }, + { + "$ref": "#/components/schemas/session.compaction.ended" + }, + { + "$ref": "#/components/schemas/session.revert.staged" + }, + { + "$ref": "#/components/schemas/session.revert.cleared" + }, + { + "$ref": "#/components/schemas/session.revert.committed" + }, + { + "$ref": "#/components/schemas/filesystem.changed" + }, + { + "$ref": "#/components/schemas/reference.updated" + }, + { + "$ref": "#/components/schemas/permission.v2.asked" + }, + { + "$ref": "#/components/schemas/permission.v2.replied" + }, + { + "$ref": "#/components/schemas/plugin.added" + }, + { + "$ref": "#/components/schemas/plugin.updated" + }, + { + "$ref": "#/components/schemas/project.directories.updated" + }, + { + "$ref": "#/components/schemas/command.updated" + }, + { + "$ref": "#/components/schemas/config.updated" + }, + { + "$ref": "#/components/schemas/skill.updated" + }, + { + "$ref": "#/components/schemas/pty.created" + }, + { + "$ref": "#/components/schemas/pty.updated" + }, + { + "$ref": "#/components/schemas/pty.exited" + }, + { + "$ref": "#/components/schemas/pty.deleted" + }, + { + "$ref": "#/components/schemas/shell.created" + }, + { + "$ref": "#/components/schemas/shell.exited" + }, + { + "$ref": "#/components/schemas/shell.deleted" + }, + { + "$ref": "#/components/schemas/question.v2.asked" + }, + { + "$ref": "#/components/schemas/question.v2.replied" + }, + { + "$ref": "#/components/schemas/question.v2.rejected" + }, + { + "$ref": "#/components/schemas/form.created" + }, + { + "$ref": "#/components/schemas/form.replied" + }, + { + "$ref": "#/components/schemas/form.cancelled" + }, + { + "$ref": "#/components/schemas/todo.updated" + }, + { + "$ref": "#/components/schemas/session.status" + }, + { + "$ref": "#/components/schemas/session.idle" + }, + { + "$ref": "#/components/schemas/tui.prompt.append" + }, + { + "$ref": "#/components/schemas/tui.command.execute" + }, + { + "$ref": "#/components/schemas/tui.toast.show" + }, + { + "$ref": "#/components/schemas/tui.session.select" + }, + { + "$ref": "#/components/schemas/installation.updated" + }, + { + "$ref": "#/components/schemas/installation.update-available" + }, + { + "$ref": "#/components/schemas/vcs.branch.updated" + }, + { + "$ref": "#/components/schemas/mcp.status.changed" + }, + { + "$ref": "#/components/schemas/permission.asked" + }, + { + "$ref": "#/components/schemas/permission.replied" + }, + { + "$ref": "#/components/schemas/question.asked" + }, + { + "$ref": "#/components/schemas/question.replied" + }, + { + "$ref": "#/components/schemas/question.rejected" + }, + { + "$ref": "#/components/schemas/session.error" + }, + { + "$ref": "#/components/schemas/V2Event.server.connected" + } + ] + }, + "V2EventStream": { + "type": "string", + "contentSchema": { + "$ref": "#/components/schemas/V2Event" + }, + "contentMediaType": "application/json" + }, + "EventLog.Hint": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "log.hint" + ] + }, + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "type", + "aggregateID", + "seq" + ], + "additionalProperties": false, + "description": "Payload-free change hint: the aggregate's durable log advanced to at least seq. Hints coalesce under backpressure (latest per aggregate) and are never a delivery guarantee." + }, + "EventLog.SweepRequired": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "log.sweep_required" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false, + "description": "Hints may have been lost; treat every aggregate as potentially dirty and recover via bounded sweep plus durable log reads. Emitted first on every (re)subscribe." + }, + "EventLog.Change": { + "anyOf": [ + { + "$ref": "#/components/schemas/EventLog.Hint" + }, + { + "$ref": "#/components/schemas/EventLog.SweepRequired" + } + ] + }, + "EventLog.ChangeStream": { + "type": "string", + "contentSchema": { + "$ref": "#/components/schemas/EventLog.Change" + }, + "contentMediaType": "application/json" + }, + "PtyNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "PtyNotFoundError" + ] + }, + "ptyID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "ptyID", + "message" + ], + "additionalProperties": false + }, + "PtyTicket.ConnectToken": { + "type": "object", + "properties": { + "ticket": { + "type": "string" + }, + "expires_in": { + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] + } + }, + "required": [ + "ticket", + "expires_in" + ], + "additionalProperties": false + }, + "ForbiddenError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ForbiddenError" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "ShellNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ShellNotFoundError" + ] + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "id", + "message" + ], + "additionalProperties": false + }, + "QuestionV2.Request": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^que" + } + ] + }, + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2.Info" + }, + "description": "Questions to ask" + }, + "tool": { + "$ref": "#/components/schemas/QuestionV2.Tool" + } + }, + "required": [ + "id", + "sessionID", + "questions" + ], + "additionalProperties": false + }, + "QuestionV2.Reply": { + "type": "object", + "properties": { + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2.Answer" + }, + "description": "User answers in order of questions (each answer is an array of selected labels)" + } + }, + "required": [ + "answers" + ], + "additionalProperties": false + }, + "QuestionNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "QuestionNotFoundError" + ] + }, + "requestID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "requestID", + "message" + ], + "additionalProperties": false + }, + "Reference.LocalSource": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "local" + ] + }, + "path": { + "type": "string" + }, + "description": { + "type": "string" + }, + "hidden": { + "type": "boolean" + } + }, + "required": [ + "type", + "path" + ], + "additionalProperties": false + }, + "Reference.GitSource": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "git" + ] + }, + "repository": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "description": { + "type": "string" + }, + "hidden": { + "type": "boolean" + } + }, + "required": [ + "type", + "repository" + ], + "additionalProperties": false + }, + "Reference.Source": { + "anyOf": [ + { + "$ref": "#/components/schemas/Reference.LocalSource" + }, + { + "$ref": "#/components/schemas/Reference.GitSource" + } + ] + }, + "Reference.Info": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "description": { + "type": "string" + }, + "hidden": { + "type": "boolean" + }, + "source": { + "$ref": "#/components/schemas/Reference.Source" + } + }, + "required": [ + "name", + "path", + "source" + ], + "additionalProperties": false + }, + "ProjectCopy.Copy": { + "type": "object", + "properties": { + "directory": { + "type": "string" + } + }, + "required": [ + "directory" + ], + "additionalProperties": false + }, + "ProjectCopyError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "ProjectCopyError" + ] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "forceRequired": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "message" + ], + "additionalProperties": false + } + }, + "required": [ + "name", + "data" + ], + "additionalProperties": false + }, + "Vcs.FileStatus": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "additions": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "deletions": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "status": { + "type": "string", + "enum": [ + "added", + "deleted", + "modified" + ] + } + }, + "required": [ + "file", + "additions", + "deletions", + "status" + ], + "additionalProperties": false + }, + "Vcs.Mode": { + "type": "string", + "enum": [ + "working", + "branch" + ] + } + }, + "securitySchemes": {} + }, + "security": [], + "tags": [ + { + "name": "health" + }, + { + "name": "location" + }, + { + "name": "agent" + }, + { + "name": "plugin", + "description": "Experimental plugin routes." + }, + { + "name": "session", + "description": "Experimental session routes." + }, + { + "name": "session", + "description": "Experimental message routes." + }, + { + "name": "model", + "description": "Experimental model routes." + }, + { + "name": "generate", + "description": "Experimental one-shot generation routes." + }, + { + "name": "provider", + "description": "Experimental provider routes." + }, + { + "name": "integration", + "description": "Integration discovery and authentication routes." + }, + { + "name": "mcp", + "description": "MCP server status routes." + }, + { + "name": "credential" + }, + { + "name": "project", + "description": "Location-scoped project routes." + }, + { + "name": "form", + "description": "Session form routes." + }, + { + "name": "permission", + "description": "Experimental permission routes." + }, + { + "name": "filesystem", + "description": "Experimental location-scoped filesystem routes." + }, + { + "name": "command", + "description": "Experimental command routes." + }, + { + "name": "skill", + "description": "Experimental skill routes." + }, + { + "name": "event", + "description": "Experimental event stream routes." + }, + { + "name": "pty", + "description": "Experimental location-scoped PTY routes." + }, + { + "name": "shell", + "description": "Experimental location-scoped shell command routes." + }, + { + "name": "question", + "description": "Experimental session question routes." + }, + { + "name": "reference", + "description": "Location-scoped project references." + }, + { + "name": "projectCopy", + "description": "Project copy management routes." + }, + { + "name": "vcs", + "description": "Location-scoped version control routes." + }, + { + "name": "debug" + } + ] +} diff --git a/packages/docs/plugins.mdx b/packages/docs/plugins.mdx new file mode 100644 index 0000000000..cdc38a8808 --- /dev/null +++ b/packages/docs/plugins.mdx @@ -0,0 +1,4 @@ +--- +title: "Plugins" +description: "Extend OpenCode with plugins." +--- diff --git a/packages/docs/quickstart.mdx b/packages/docs/quickstart.mdx deleted file mode 100644 index 52243fba6c..0000000000 --- a/packages/docs/quickstart.mdx +++ /dev/null @@ -1,81 +0,0 @@ ---- -title: "Quickstart" -description: "Start building awesome documentation in minutes" ---- - -## Get started in three steps - -Get your documentation site running locally and make your first customization. - -### Step 1: Set up your local environment - - - - During the onboarding process, you created a GitHub repository with your docs content if you didn't already have - one. You can find a link to this repository in your [dashboard](https://dashboard.mintlify.com). To clone the - repository locally so that you can make and preview changes to your docs, follow the [Cloning a - repository](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) guide - in the GitHub docs. - - - 1. Install the Mintlify CLI: `npm i -g mint` 2. Navigate to your docs directory and run: `mint dev` 3. Open - `http://localhost:3000` to see your docs live! - Your preview updates automatically as you edit files. - - - -### Step 2: Deploy your changes - - - - Install the Mintlify GitHub app from your [dashboard](https://dashboard.mintlify.com/settings/organization/github-app). - - Our GitHub app automatically deploys your changes to your docs site, so you don't need to manage deployments yourself. - - - For a first change, let's update the name and colors of your docs site. - - 1. Open `docs.json` in your editor. - 2. Change the `"name"` field to your project name. - 3. Update the `"colors"` to match your brand. - 4. Save and see your changes instantly at `http://localhost:3000`. - - Try changing the primary color to see an immediate difference! - - - - -### Step 3: Go live - - - 1. Commit and push your changes. 2. Your docs will update and be live in moments! - - -## Next steps - -Now that you have your docs running, explore these key features: - - - - - Learn MDX syntax and start writing your documentation. - - - - Make your docs match your brand perfectly. - - - - Include syntax-highlighted code blocks. - - - - Auto-generate API docs from OpenAPI specs. - - - - - - **Need help?** See our [full documentation](https://mintlify.com/docs) or join our - [community](https://mintlify.com/community). - diff --git a/packages/docs/sdk/index.mdx b/packages/docs/sdk/index.mdx new file mode 100644 index 0000000000..81f71854bb --- /dev/null +++ b/packages/docs/sdk/index.mdx @@ -0,0 +1,4 @@ +--- +title: "SDK" +description: "Build with OpenCode." +--- diff --git a/packages/docs/snippets/snippet-intro.mdx b/packages/docs/snippets/snippet-intro.mdx deleted file mode 100644 index e20fbb6fc9..0000000000 --- a/packages/docs/snippets/snippet-intro.mdx +++ /dev/null @@ -1,4 +0,0 @@ -One of the core principles of software development is DRY (Don't Repeat -Yourself). This is a principle that applies to documentation as -well. If you find yourself repeating the same content in multiple places, you -should consider creating a custom snippet to keep your content in sync. diff --git a/packages/docs/troubleshooting.mdx b/packages/docs/troubleshooting.mdx new file mode 100644 index 0000000000..57856c9d71 --- /dev/null +++ b/packages/docs/troubleshooting.mdx @@ -0,0 +1,177 @@ +--- +title: "Troubleshooting" +description: "Diagnose OpenCode startup, server, and session issues." +--- + + + You can ask OpenCode to debug itself. Describe the problem and ask it to use this troubleshooting page; it can read the + steps below, inspect its service and logs, and help identify the issue. + + +OpenCode runs as two processes: the TUI is a client, while a background server owns sessions, plugins, permissions, and +other application state. Start by determining whether an issue is in the client, the shared server, or a specific project. + +## Check the background service + +Show the current server status: + +```bash +opencode2 service status +``` + +Verify that its API is healthy: + +```bash +opencode2 api get /api/health +``` + +If the service is stuck or unhealthy, restart it: + +```bash +opencode2 service restart +``` + +From inside the TUI, run `/reload` to restart the managed service and reconnect: + +```text +/reload +``` + +You can also stop and start it explicitly: + +```bash +opencode2 service stop +opencode2 service start +``` + + + OpenCode normally discovers or starts the shared background service automatically. The service commands are only needed + when diagnosing its lifecycle. + + +## Run an isolated session + +Use standalone mode to run the TUI with a private server that exits with it: + +```bash +opencode2 --standalone +``` + +If an issue disappears in standalone mode, it is likely related to the shared background service rather than the TUI or +project itself. + +## Inspect the API + +The `api` command uses the same discovery and authentication flow as the TUI. It accepts either an HTTP method and path or +an OpenAPI operation ID. + +See the [API reference](/api) for all endpoints and operation IDs. + +Pass a JSON request body with `--data` or `-d`, and add headers with `--header` or `-H`. + + + Running `opencode2 api` may start the background service when no compatible healthy service is available. + + +## Read logs + +Installed builds write logs to: + +```text +~/.local/share/opencode/log/opencode.log +``` + +Follow the log while reproducing the problem: + +```bash +tail -f ~/.local/share/opencode/log/opencode.log +``` + +Each line includes a process `run` ID and a `role` field. Use `role=cli` for TUI and command startup, and `role=server` for +session, provider, plugin, permission, and tool activity. + +```bash +grep 'role=cli' ~/.local/share/opencode/log/opencode.log +grep 'role=server' ~/.local/share/opencode/log/opencode.log +grep 'run=8fc3b1d5' ~/.local/share/opencode/log/opencode.log +``` + +Increase verbosity for one reproduction: + +```bash +OPENCODE_LOG_LEVEL=DEBUG opencode2 +``` + +## Service files + +The shared server registers itself at: + +```text +~/.local/state/opencode/service.json +``` + +Its private service configuration is stored separately at: + +```text +~/.config/opencode/service.json +``` + +The database normally lives at: + +```text +~/.local/share/opencode/opencode-next.db +``` + +`OPENCODE_DB` can override the database location. + + + Do not delete or edit service files or the database while troubleshooting. Use the service commands to manage the daemon, + and make a backup before inspecting persistent data with external tools. + + +## Explicit servers + +When connecting with `--server`, set `OPENCODE_PASSWORD` if the server requires authentication: + +```bash +OPENCODE_PASSWORD=secret opencode2 --server http://127.0.0.1:4096 +``` + +The CLI checks the server before opening the TUI and reports whether it is unreachable, requires a password, or rejected +the supplied password. + +## Report an issue + +Include the following when reporting a reproducible problem: + +- Output from `opencode2 --version` +- Output from `opencode2 service status` +- The smallest sequence of steps that reproduces the issue +- Whether the issue also occurs with `opencode2 --standalone` +- Relevant log lines, including their `run` and `role` fields + +Remove API keys, authorization headers, prompts, file contents, and other sensitive data before sharing logs. + +## Local development + +When working from the OpenCode repository, run V2 commands from the repository root: + +```bash +bun dev +``` + +The local development channel keeps its logs, SQLite database, and service registration separate from installed builds: + +```text +~/.local/share/opencode/log/opencode-local.log +~/.local/share/opencode/opencode-local.db +~/.local/state/opencode/service-local.json +``` + +Use the same diagnostics through the package development command: + +```bash +bun dev service status +bun dev service restart +bun dev api get /api/health +``` diff --git a/packages/httpapi-codegen/package.json b/packages/httpapi-codegen/package.json index 84aa736531..f2a5e4da79 100644 --- a/packages/httpapi-codegen/package.json +++ b/packages/httpapi-codegen/package.json @@ -1,6 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/httpapi-codegen", + "version": "0.0.0", "private": true, "type": "module", "exports": { diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index 656f8179d6..eb3b7141c6 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -153,11 +153,11 @@ const OpenAIChatChoice = Schema.Struct({ finish_reason: optionalNull(Schema.String), }) -const OpenAIChatEvent = Schema.Struct({ +export const OpenAIChatEvent = Schema.Struct({ choices: Schema.Array(OpenAIChatChoice), usage: optionalNull(OpenAIChatUsage), }) -type OpenAIChatEvent = Schema.Schema.Type +export type OpenAIChatEvent = Schema.Schema.Type type OpenAIChatRequestMessage = LLMRequest["messages"][number] interface ParserState { diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 651e426336..bea2dc5973 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -85,6 +85,7 @@ "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", "@opencode-ai/client": "workspace:*", + "@opencode-ai/cli": "workspace:*", "@opencode-ai/llm": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/protocol": "workspace:*", diff --git a/packages/opencode/specs/simulation/simulated-network-llm.md b/packages/opencode/specs/simulation/simulated-network-llm.md new file mode 100644 index 0000000000..c7631cf4cb --- /dev/null +++ b/packages/opencode/specs/simulation/simulated-network-llm.md @@ -0,0 +1,150 @@ +# Simulated Network And Driver-Scripted LLM + +Status: design for the Phase 2 network and LLM items in `simulation-phases.md`. + +## Summary + +Simulation replaces the `HttpClient.HttpClient` platform node with a simulated network. The LLM is not a separate fake: it is one registered route in that network (`api.openai.com`), answered by the **external driver** over the existing control WebSocket. When the app issues a provider request, the backend forwards it to the driver and the driver streams response chunks back. There is no enqueueing and no scripted-response store; the driver is the model. + +Everything above the HTTP boundary runs real: catalog and auth resolution, `LLMClient`, request body construction, SSE framing, the OpenAI protocol event schema, the `step` state machine, `Lifecycle` grammar, tool-argument accumulation, the session runner, tools, and permissions. + +## Why the network seam + +`LLMClient.stream` sits on a stack that ends in one platform node: + +``` +LLMClient.stream(request) + route.body.from LLMRequest -> OpenAI JSON body (real) + transport.prepare body + endpoint + auth -> HttpRequest (real) + RequestExecutor.execute status/error taxonomy (real) + HttpClient.HttpClient <- replaced by the simulated network + Framing.sse bytes -> frames (real) + protocol.stream.event frame -> OpenAIChatEvent, validated (real) + protocol.stream.step state machine -> LLMEvents (real) +``` + +Replacing `httpClient` (already a `LayerNode` in `app-node-platform.ts`, already used by `simulationReplacements` mechanics) keeps the entire pipeline under test and gives wire-fidelity observation of what would have been sent to the provider. Failure injection (429s, malformed SSE, truncated streams) exercises real error paths that a typed `LLMClient` fake cannot reach. + +## Components + +### 1. Simulated network (`packages/simulation/src/backend/network.ts`) + +Replaces `httpClient` in `simulationReplacements`. An in-memory route table: + +- `register(matcher, responder)` where matcher is method + URL pattern and responder is `(HttpClientRequest) => Effect`. +- Unknown requests fail loudly with a typed simulation error (spec: deny unknown external network by default). +- Optional loopback allowance for the app's own server is not required server-side (the server does not call itself over HTTP); revisit if a consumer needs it. +- Every request/response summary is traced. + +### 2. OpenAI endpoint route (`packages/simulation/src/backend/openai.ts`) + +Registered in the network at startup for `POST {DEFAULT_BASE_URL}{PATH}` from `protocols/openai-chat.ts` (`https://api.openai.com/v1/chat/completions`). + +On request: + +1. Allocate an exchange id. Parse the real OpenAI request body (available to the driver for assertions). +2. Publish a `request` record to the LLM exchange service (below) and create a chunk `Queue`. +3. Return `HttpClientResponse` with `content-type: text/event-stream` whose body stream reads from the queue, encoding each item as an SSE `data:` frame, terminated by `[DONE]`. + +Chunks are constructed through the `OpenAIChatEvent` schema so drift in the protocol schema breaks the build, not the runtime. + +The response stream is interruptible like a real HTTP response: if the runner cancels (user interrupt), the exchange closes and the driver is notified. + +### 3. LLM exchange service (`packages/simulation/src/backend/llm-exchange.ts`) + +Process-global simulation service owning pending exchanges: + +``` +Exchange = { id, body, queue: Queue, deferred lifecycle } +``` + +- `requests()` — stream of newly opened exchanges (consumed by the control route). +- `push(id, item)` — append one response item to an open exchange. +- `finish(id, reason)` / `fail(id, failure)` — terminate the exchange. +- Exchanges that receive no driver within a configurable timeout fail the provider request with a simulation error (surfaces in the real provider-error path). + +### 4. Backend control WebSocket (simulation-gated) + +Started when the simulation module loads (lazy import, `OPENCODE_SIMULATION` only): a loopback JSON-RPC 2.0 WebSocket on `127.0.0.1:40950+`, hosted by the backend process. Drivers connect to it directly — the standalone topology has exactly one backend per TUI, so there is no proxying through the frontend. This socket is also the headless-simulation interface: it works with no TUI at all. + +Server -> driver notification (after `llm.attach`; pending exchanges are replayed on attach so late-attaching drivers miss nothing): + +``` +{ "jsonrpc": "2.0", "method": "llm.request", + "params": { "id": "ex_1", "url": "...", "body": { ...openai request body... } } } +``` + +Driver -> server methods: + +``` +llm.attach subscribe to llm.request notifications +llm.chunk { id, items: Item[] } append response items +llm.finish { id, reason?: "stop" | ... } finish the exchange +llm.pending list open exchanges +network.log simulated network request log +``` + +`Item` is the response vocabulary the driver speaks: + +``` +{ type: "textDelta", text } +{ type: "reasoningDelta", text } +{ type: "toolCall", id, name, input } +{ type: "raw", chunk } // escape hatch: raw OpenAIChatEvent JSON +``` + +The backend compiles items to OpenAI chunks (`delta.content`, `delta.tool_calls[].function.arguments`, `finish_reason`); `raw` passes through unmodified. Streaming granularity is the driver's choice: many small `llm.chunk` calls stream word by word; one call with many items plus `llm.finish` responds at once. + +Failure injection (`llm.fail`: HTTP status instead of SSE) is specced but not yet implemented. + +### 5. Driver topology + +A driver manages two loopback WebSocket connections: + +- TUI control server (`127.0.0.1:40900+`) — UI state, actions, render, trace. +- Backend control server (`127.0.0.1:40950+`) — LLM exchanges, network log. + +Both speak the same JSON-RPC shape. Headless drivers use only the backend socket plus the normal HTTP API. Multiple drivers are out of scope; last attach wins. + +### 6. Pacing and the clock + +No server-side pacing by default: the driver controls timing by when it sends chunks, which is the point of driver-in-the-loop. A convenience `llm.chunk` option `{ delayMs }` may sleep via `Effect.sleep` between items server-side; because that uses the fiber `Clock`, scoping a controllable clock to the exchange stream (`Stream.provideService(Clock.Clock, simClock)`) remains available for deterministic replay without touching app time. Defer until replay work needs it. + +### 7. Catalog and auth seeding + +The driver-facing model must be selectable in the TUI. Simulation seeds config (via the snapshot filesystem) defining a provider on the openai-chat route with `baseURL` left at the OpenAI default and a dummy `apiKey` (satisfies `Catalog.available()`). No catalog code changes. + +## End-to-end flow + +``` +driver TUI sim server (40900+) backend + control WS (40950+) + | | | + |-- ui.action (submit) ----->| | + | |-- (normal app HTTP) ---->| session runner starts + | | | llm.stream -> HttpClient + | | | simulated network matches openai route + |<================== llm.request {ex_1} ===============| exchange ex_1 opened + |-- llm.chunk {ex_1,[...]} ============================>| SSE frames flow into the real + |-- llm.chunk {ex_1,[...]} ============================>| decode -> step -> LLMEvents -> + |-- llm.finish {ex_1} =================================>| runner publishes, TUI renders + | | | + | (if toolCall was sent: runner executes the real tool against the + | fake filesystem, then issues the next provider turn -> new exchange + | ex_2 -> driver decides the next response) +``` + +The driver observes the TUI through `ui.state` while chunks stream, so mid-stream UI assertions need no clock control at all: the driver simply has not sent the rest yet. + +## Implementation order + +1. `network.ts`: simulated `HttpClient` + route table + deny-unknown + trace. Replace `httpClient` in `simulationReplacements`. +2. `llm-exchange.ts` + `openai.ts`: exchange service and the OpenAI SSE route (schema-constructed chunks, `[DONE]`, interruption). +3. `control.ts`: backend-hosted control WebSocket (`llm.attach|chunk|finish|pending`, `network.log`), started when the simulation module loads. +4. Config seeding for the sim provider; end-to-end verification via `packages/server/script/e2e-sim.ts` (headless) and `packages/tui/script/sim-llm-driver.ts` (TUI + backend sockets). +5. Trace records for network and LLM exchange activity. + +## Consequences + +- No enqueue/script store to keep consistent; the driver is the single source of model behavior. +- Deterministic tests write drivers (respond to `llm.request` programmatically) instead of pre-baked scripts; replay (Phase 4) records exchanges and replays them as an automatic driver. +- Provider-coupling is confined to `openai.ts` (one wire encoder against a schema that lives in the repo); a second simulated provider (e.g. Anthropic) is another route file if ever needed. diff --git a/packages/opencode/specs/simulation/simulation-phases.md b/packages/opencode/specs/simulation/simulation-phases.md index ef7aa41e3f..ebc4bd3743 100644 --- a/packages/opencode/specs/simulation/simulation-phases.md +++ b/packages/opencode/specs/simulation/simulation-phases.md @@ -51,6 +51,28 @@ Out of scope: Goal: make the app safe and controlled by swapping the lowest layers, not app logic. +Implementation checklist: + +- [x] Add `packages/simulation/src/backend` as the home for backend simulation layer replacements, exported from `backend/index.ts` as `simulationReplacements`; `@opencode-ai/simulation` is private/non-published and depends on logic/framework packages (`core`, `llm`, `effect`, OpenTUI), while `server` and `tui` consume it. +- [x] Wire simulation replacements through the server's `makeRoutes` via `Layer.unwrap` + dynamic `import("@opencode-ai/simulation/backend")` gated on `OPENCODE_SIMULATION`, so the simulation module is never loaded eagerly and `makeRoutes` stays synchronous. +- [x] Implement in-memory `FileSystem.FileSystem` (`simulation/filesystem.ts`) replacing the `NodeFileSystem` platform node. Backed by a flat path map; implements the operations the app uses (stat, access, chmod, realPath, read/write file, make/read directory, remove, rename, copy, copyFile, temp dirs, read-only open handles); unused operations die with a clear defect; `watch` fails as unsupported. +- [x] Root the fake filesystem at `OPENCODE_SIMULATION_ROOT` (falling back to `process.cwd()` at layer-build time). The anchor is a real, empty host directory the runner creates and cds into. +- [x] Deny host filesystem escapes loudly: content/mutation operations outside the root fail with `PermissionDenied` simulation errors. Probe operations (`stat`/`access`/`exists`) report `NotFound` outside the root so walk-up loops (project discovery, `findUp`, `globUp`) terminate naturally. +- [x] Add `SimulationFSUtil` replacement (`simulation/fs-util.ts`): wraps the real `FSUtil` layer and reroutes `readDirectoryEntries`, `glob`, and `globUp` — which bypass the injected `FileSystem` via node `fs/promises` and the `glob` package — through the simulated filesystem. +- [x] Fix `LayerNode.hoist` conflict detection to compare node implementations instead of object identity; replacement rewriting produces dependency-rewritten copies of the same node, which previously false-positived as "conflicting implementations". +- [x] Add snapshot seeding from `OPENCODE_SIMULATION_STATE`: `project/` contents of the snapshot directory are read from the host once at layer-build time and seeded into the in-memory tree joined onto the anchor root. +- [x] Verify end to end: `opencode serve` boots with `OPENCODE_SIMULATION=1` + `OPENCODE_SIMULATION_ROOT` + path/DB env seams (`OPENCODE_CONFIG_DIR`, `OPENCODE_TEST_HOME`, `OPENCODE_DB=:memory:`); `fs.list`/`fs.read` observe only seeded in-memory files; the anchor directory on the host remains empty after the run. +- [ ] Create the anchor directory + `chdir` + env seam setup automatically in CLI startup when simulation mode is enabled (currently set manually by the runner; a full run needs `OPENCODE_SIMULATION_ROOT/STATE`, `OPENCODE_CONFIG_DIR`, `OPENCODE_TEST_HOME`, `OPENCODE_DB=:memory:`, and `XDG_*_HOME` pointed into the anchor, plus Bun's `--preload=@opentui/solid/preload` when launched outside `packages/cli`). +- [ ] Assert the anchor directory is still empty at the end of the run (KV/log/flock still write through real XDG paths; they are contained in the anchor by the env seams but not yet in-memory). +- [x] Add simulated network registry (`packages/simulation/src/backend/network.ts`): replaces the `httpClient` platform node, resolves all outbound HTTP against an in-memory route table, denies unknown destinations loudly, and keeps a bounded request log (design: `simulated-network-llm.md`). +- [x] Add driver-answered LLM as an OpenAI route in the simulated network (`openai.ts` + `llm-exchange.ts`): provider requests open exchanges; the driver streams chunks back which are encoded as real OpenAI Chat SSE (schema-checked against `OpenAIChatEvent`) and consumed by the real protocol pipeline. No enqueue store — the driver is the model. +- [x] Add backend-hosted simulation control WebSocket (`control.ts`): JSON-RPC on `127.0.0.1:40950+`, started when the simulation module loads. Drivers connect directly (standalone topology — no frontend proxy): `llm.attach` (replays pending exchanges), `llm.chunk`, `llm.finish`, `llm.pending`, `network.log`; `llm.request` notifications push opened exchanges. This is also the headless-simulation interface. Drivers manage two sockets: TUI control (40900+) for UI, backend control (40950+) for LLM/network. +- [x] Answer `https://models.dev/api.json` with an empty catalog in the simulated network; providers come from seeded config (`opencode.json` in the snapshot defines an openai-compatible provider with a dummy `apiKey`, which passes the catalog availability gate and resolves onto the real openai-chat route). +- [x] Fix `buildLocationServiceMap` to apply replacements when compiling hoisted global nodes; platform-node replacements (filesystem, httpClient) were silently ignored inside hoisted globals. +- [x] Verify end to end headless (real route stack in-process + backend control WS: prompt -> `llm.request` -> driver chunks -> assistant message contains driver text; script: `packages/server/script/e2e-sim.ts`) and through the TUI (fake renderer, both sockets: type + submit via TUI WS, answer `llm.request` via backend WS, assistant reply rendered on screen; script: `packages/tui/script/sim-llm-driver.ts`). +- [ ] Add simulated process registry (shell via `just-bash`, minimal fake `git`, deny unsupported spawns). +- [ ] Trace filesystem, process, and LLM exchange activity (network requests are traced in the backend network log ring buffer; LLM exchange trace records moved out with the frontend proxy and need re-adding on the backend control server). + Scope: - Wire simulation replacements through `AppNodeBuilder.build(...)` and `AppNodeBuilderV1.build(...)`. diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 536a642fe4..cd0eb32f7c 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -29,8 +29,8 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Reference } from "@opencode-ai/core/reference" +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" import { Location } from "@opencode-ai/core/location" -import { PluginV2 } from "@opencode-ai/core/plugin" export const Info = Schema.Struct({ name: Schema.String, @@ -101,7 +101,7 @@ const layer = Layer.effect( const skillDirs = yield* skill.dirs() const referenceDirs = Object.keys(cfg.references ?? cfg.reference ?? {}).length ? yield* Effect.gen(function* () { - yield* (yield* PluginV2.Service).wait(PluginV2.ID.make("core/config-reference")) + yield* (yield* PluginSupervisor.Service).ready return (yield* (yield* Reference.Service).list()).map((reference) => reference.path) }).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })))) : [] diff --git a/packages/opencode/src/cli/cmd/mini.ts b/packages/opencode/src/cli/cmd/mini.ts deleted file mode 100644 index 96faa2e88e..0000000000 --- a/packages/opencode/src/cli/cmd/mini.ts +++ /dev/null @@ -1,172 +0,0 @@ -import type { Argv } from "yargs" -import { cmd } from "./cmd" -import { UI } from "@/cli/ui" -import { resolveThreadDirectory } from "./tui" - -type ReplayArgs = { - replay?: boolean - noReplay?: boolean -} - -type MiniArgs = ReplayArgs & { - continue?: boolean - session?: string - fork?: boolean - replayLimit?: number -} - -type MiniLocalArgs = MiniArgs & { - project?: string - model?: string - agent?: string - prompt?: string - demo?: boolean -} - -type MiniAttachArgs = MiniArgs & { - url: string - dir?: string - password?: string - username?: string -} - -function replay(args: ReplayArgs) { - if (args.replay === true) { - UI.error("--replay is not supported; replay is enabled by default") - process.exitCode = 1 - return "invalid" as const - } - return args.replay === false || args.noReplay === true ? false : undefined -} - -function miniOptions(yargs: Argv) { - return yargs - .option("continue", { - alias: ["c"], - describe: "continue the last session", - type: "boolean", - }) - .option("session", { - alias: ["s"], - describe: "session id to continue", - type: "string", - }) - .option("fork", { - type: "boolean", - describe: "fork the session when continuing (use with --continue or --session)", - }) - .option("replay", { - type: "boolean", - hidden: true, - }) - .option("no-replay", { - type: "boolean", - describe: "disable session history replay on resume and after resize", - }) - .option("replay-limit", { - type: "number", - describe: "cap visible replay to the newest N messages", - }) -} - -/** @internal Exported for CLI parser tests. */ -export const MiniLocalCommand = cmd<{}, MiniLocalArgs>({ - command: "$0 [project]", - describe: "start the minimal interactive interface", - builder: (yargs) => - miniOptions( - yargs - .positional("project", { - type: "string", - describe: "path to start opencode in", - }) - .option("model", { - type: "string", - alias: ["m"], - describe: "model to use in the format of provider/model", - }) - .option("agent", { - type: "string", - describe: "agent to use", - }) - .option("prompt", { - type: "string", - describe: "prompt to use", - }) - .option("demo", { - type: "boolean", - hidden: true, - }), - ), - handler: async (args) => { - const shouldReplay = replay(args) - if (shouldReplay === "invalid") return - - const { runMini } = await import("./run") - await runMini({ - directory: resolveThreadDirectory(args.project), - continue: args.continue, - session: args.session, - fork: args.fork, - model: args.model, - agent: args.agent, - prompt: args.prompt, - replay: shouldReplay, - replayLimit: args.replayLimit, - demo: args.demo, - }) - }, -}) - -/** @internal Exported for CLI parser tests. */ -export const MiniAttachCommand = cmd<{}, MiniAttachArgs>({ - command: "attach ", - describe: "attach to a running opencode server with the minimal interface", - builder: (yargs) => - miniOptions( - yargs - .positional("url", { - type: "string", - describe: "http://localhost:4096", - demandOption: true, - }) - .option("dir", { - type: "string", - describe: "directory on the remote server", - }) - .option("password", { - alias: ["p"], - type: "string", - describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)", - }) - .option("username", { - alias: ["u"], - type: "string", - describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')", - }), - ), - handler: async (args) => { - const shouldReplay = replay(args) - if (shouldReplay === "invalid") return - - const { runMini } = await import("./run") - await runMini({ - attach: args.url, - directory: args.dir, - password: args.password, - username: args.username, - continue: args.continue, - session: args.session, - fork: args.fork, - replay: shouldReplay, - replayLimit: args.replayLimit, - }) - }, -}) - -export const MiniCommand = cmd({ - command: "mini", - describe: "start the minimal interactive interface", - builder: (yargs) => yargs.command(MiniLocalCommand).command(MiniAttachCommand).demandCommand(), - handler: async () => {}, -}) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index fc495213c4..2021aadadd 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1,149 +1,12 @@ -import type { PermissionV1 } from "@opencode-ai/core/v1/permission" -import { FSUtil } from "@opencode-ai/core/fs-util" -// CLI entry point for `opencode run` and `opencode mini`. -// -// Handles three modes: -// 1. Non-interactive (default): sends a single prompt, streams events to -// stdout, and exits when the session goes idle. -// 2. Interactive local (`opencode mini`): boots the split-footer direct mode -// with an in-process server (no external HTTP). -// 3. Interactive attach (`opencode mini attach`): connects to a running -// opencode server and runs interactive mode against it. -// -// Also supports `--command` for slash-command execution, `--format json` for -// raw event streaming, `--continue` / `--session` for session resumption, -// and `--fork` for forking before continuing. -import type { Argv } from "yargs" -import path from "path" -import { pathToFileURL } from "url" -import { open } from "node:fs/promises" import { Effect } from "effect" -import { UI } from "../ui" import { effectCmd } from "../effect-cmd" -import { EOL } from "os" -import { Filesystem } from "@/util/filesystem" -import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" -import { FormatError, FormatUnknownError } from "../error" -import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin" -import { isImageAttachment, isPdfAttachment } from "@/util/media" -import { loadRunAgents, waitForCatalogReady } from "./run/catalog.shared" - -type ModelInput = Parameters[0]["model"] - -function pick(value: string | undefined): ModelInput | undefined { - if (!value) return undefined - const [providerID, ...rest] = value.split("/") - return { - providerID, - modelID: rest.join("/"), - } as ModelInput -} - -function resolveRunInput(value?: string, piped?: string): string | undefined { - if (!value) { - return piped - } - - if (!piped) { - return value - } - - return value + "\n" + piped -} - -function isBinaryContent(bytes: Uint8Array) { - if (bytes.length === 0) return false - if (bytes.includes(0)) return true - return ( - bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0) / bytes.length > 0.3 - ) -} - -type FilePart = { - type: "file" - url: string - filename: string - mime: string -} - -const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024 - -type Inline = { - icon: string - title: string - description?: string -} - -type SessionInfo = { - id: string - title?: string - directory?: string - current?: boolean -} - -function inline(info: Inline) { - const suffix = info.description ? UI.Style.TEXT_DIM + ` ${info.description}` + UI.Style.TEXT_NORMAL : "" - UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title + suffix) -} - -function block(info: Inline, output?: string) { - UI.empty() - inline(info) - if (!output?.trim()) return - UI.println(output) - UI.empty() -} - -function formatRunError(error: unknown) { - return FormatError(error) ?? FormatUnknownError(error) -} - -async function tool(part: ToolPart) { - try { - const { toolInlineInfo } = await import("./run/tool") - const next = toolInlineInfo(part) - if (next.mode === "block") { - block(next, next.body) - return - } - - inline(next) - } catch { - inline({ - icon: "\u2699", - title: part.tool, - }) - } -} - -async function toolError(part: ToolPart) { - try { - const { toolInlineInfo } = await import("./run/tool") - const next = toolInlineInfo(part) - inline({ - icon: "✗", - title: `${next.title} failed`, - ...(next.description && { description: next.description }), - }) - return - } catch { - inline({ - icon: "✗", - title: `${part.tool} failed`, - }) - } -} +import { v2ServerCommand } from "./v2-server-command" export const RunCommand = effectCmd({ command: "run [message..]", describe: "run opencode with a message", - // --attach connects to a remote server (no local instance needed); the - // default path runs an in-process server and needs the project instance. - instance: (args) => !args.attach, - // For --dir without --attach, load instance for the resolved target dir. - // The handler also chdirs (preserving the legacy order: chdir → file resolution). - directory: (args) => (args.dir && !args.attach ? path.resolve(process.cwd(), args.dir) : process.cwd()), - builder: (yargs: Argv) => + instance: false, + builder: (yargs) => yargs .positional("message", { describe: "message to send", @@ -151,10 +14,6 @@ export const RunCommand = effectCmd({ array: true, default: [], }) - .option("command", { - describe: "the command to run, use message for args", - type: "string", - }) .option("continue", { alias: ["c"], describe: "continue the last session", @@ -180,8 +39,8 @@ export const RunCommand = effectCmd({ }) .option("format", { type: "string", - choices: ["default", "json"], - default: "default", + choices: ["default", "json"] as const, + default: "default" as const, describe: "format: default (formatted) or json (raw JSON events)", }) .option("file", { @@ -194,53 +53,36 @@ export const RunCommand = effectCmd({ type: "string", describe: "title for the session (uses truncated prompt if no value provided)", }) + .option("server", { + type: "string", + describe: "connect to a running opencode server", + }) .option("attach", { type: "string", - describe: "attach to a running opencode server (e.g., http://localhost:4096)", + hidden: true, }) .option("password", { alias: ["p"], type: "string", - describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)", + hidden: true, }) .option("username", { alias: ["u"], type: "string", - describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')", + hidden: true, }) .option("dir", { type: "string", - describe: "directory to run in, path on remote server if attaching", - }) - .option("port", { - type: "number", - describe: "port for the local server (defaults to random port if no value provided)", + describe: "directory to run in, or a path on the remote server", }) .option("variant", { type: "string", - describe: "model variant (provider-specific reasoning effort, e.g., high, max, minimal)", + describe: "model variant", }) .option("thinking", { type: "boolean", describe: "show thinking blocks", }) - .option("replay", { - type: "boolean", - default: true, - hidden: true, - describe: "replay interactive session history on resume and after resize (use --no-replay to disable)", - }) - .option("replay-limit", { - type: "number", - hidden: true, - describe: "cap visible interactive replay to the newest N messages", - }) - .option("interactive", { - alias: ["i"], - type: "boolean", - describe: "run in direct interactive split-footer mode", - default: false, - }) .option("auto", { type: "boolean", describe: "auto-approve permissions that are not explicitly denied (dangerous!)", @@ -255,898 +97,29 @@ export const RunCommand = effectCmd({ type: "boolean", hidden: true, default: false, - }) - .option("demo", { - type: "boolean", - default: false, - hidden: true, - describe: "enable direct interactive demo slash commands; pass one as the message to run it immediately", }), handler: Effect.fn("Cli.run")(function* (args) { - const { Agent } = yield* Effect.promise(() => import("@/agent/agent")) - const { RuntimeFlags } = yield* Effect.promise(() => import("@/effect/runtime-flags")) - const { InstanceRef } = yield* Effect.promise(() => import("@/effect/instance-ref")) - const { ServerAuth } = yield* Effect.promise(() => import("@/server/auth")) - const agentSvc = yield* Agent.Service - const flags = yield* RuntimeFlags.Service - const localInstance = yield* InstanceRef - yield* Effect.promise(async () => { - const rawMessage = [...args.message, ...(args["--"] || [])].join(" ") - const interactive = (args as typeof args & { mini?: boolean }).mini === true - const auto = args.auto || args.yolo || args["dangerously-skip-permissions"] - const thinking = interactive ? (args.thinking ?? true) : (args.thinking ?? false) - const die = (message: string): never => { - UI.error(message) - process.exit(1) - } - const dieInteractive = (error: unknown): never => { - if (error instanceof Error && error.message === INTERACTIVE_INPUT_ERROR) { - die(error.message) - } - - throw error - } - - let message = [...args.message, ...(args["--"] || [])] - .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) - .join(" ") - - if (interactive && args.command) { - die("opencode mini cannot be used with --command") - } - - if (interactive && args._?.[0] !== "mini") { - die("opencode mini must be run with the mini command") - } - - if (args.demo && !interactive) { - die("--demo requires opencode mini") - } - - if (interactive && args.format === "json") { - die("opencode mini cannot be used with --format json") - } - - if (args["replay-limit"] !== undefined && !interactive) { - die("--replay-limit requires opencode mini") - } - - if ( - args["replay-limit"] !== undefined && - (!Number.isInteger(args["replay-limit"]) || args["replay-limit"] <= 0) - ) { - die("--replay-limit must be a positive integer") - } - - if (interactive && !process.stdout.isTTY) { - die("opencode mini requires a TTY stdout") - } - - if (interactive) { - try { - resolveInteractiveStdin().cleanup?.() - } catch (error) { - dieInteractive(error) - } - } - - const replay = args.replay === false ? false : args.replay || args["replay-limit"] !== undefined - - const root = Filesystem.resolve(process.env.PWD ?? process.cwd()) - const directory = (() => { - if (!args.dir) return args.attach ? undefined : root - if (args.attach) return args.dir - - try { - process.chdir(path.isAbsolute(args.dir) ? args.dir : path.join(root, args.dir)) - return process.cwd() - } catch { - UI.error("Failed to change directory to " + args.dir) - process.exit(1) - } - })() - const attachHeaders = args.attach - ? ServerAuth.headers({ password: args.password, username: args.username }) - : undefined - const attachSDK = (dir?: string) => { - return createOpencodeClient({ - baseUrl: args.attach!, - directory: dir, - headers: attachHeaders, - }) - } - - const files: FilePart[] = [] - const fileInputs: Array<{ - filePath: string - resolvedPath: string - stat: ReturnType - isDirectory: boolean - }> = [] - if (args.file) { - const list = Array.isArray(args.file) ? args.file : [args.file] - - for (const filePath of list) { - const resolvedPath = path.resolve(args.attach ? root : (directory ?? root), filePath) - if (!(await Filesystem.exists(resolvedPath))) { - UI.error(`File not found: ${filePath}`) - process.exit(1) - } - - const stat = Filesystem.stat(resolvedPath) - const isDirectory = stat?.isDirectory() ?? false - if (args.attach && isDirectory) { - UI.error(`Cannot attach local directory without a shared filesystem: ${filePath}`) - process.exit(1) - } - fileInputs.push({ filePath, resolvedPath, stat, isDirectory }) - } - } - - const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text() - message = resolveRunInput(message, piped) ?? "" - const initialInput = resolveRunInput(rawMessage, piped) - - if (message.trim().length === 0 && !args.command && !interactive) { - UI.error("You must provide a message or a command") - process.exit(1) - } - - if (args.fork && !args.continue && !args.session) { - UI.error("--fork requires --continue or --session") - process.exit(1) - } - - const rules: PermissionV1.Ruleset = interactive - ? [] - : [ - { - permission: "question", - action: "deny", - pattern: "*", - }, - { - permission: "plan_enter", - action: "deny", - pattern: "*", - }, - { - permission: "plan_exit", - action: "deny", - pattern: "*", - }, - ] - const currentPrompt = !interactive && !args.command && fileInputs.every((file) => !file.isDirectory) - - const inlineFiles = interactive || currentPrompt - for (const file of fileInputs) { - const content = await (async () => { - if (file.isDirectory || !inlineFiles) return - if (!file.stat?.isFile() || file.stat.size > ATTACH_FILE_MAX_BYTES) { - UI.error(`Cannot attach local file larger than 10 MiB or a special file: ${file.filePath}`) - process.exit(1) - } - const handle = await open(file.resolvedPath, "r") - try { - const opened = await handle.stat() - if (!opened.isFile() || Number(opened.size) > ATTACH_FILE_MAX_BYTES) { - UI.error(`Cannot attach local file larger than 10 MiB or a special file: ${file.filePath}`) - process.exit(1) - } - if (opened.size === 0) return Buffer.alloc(0) - const buffer = Buffer.alloc(Number(opened.size)) - let offset = 0 - while (offset < buffer.length) { - const read = await handle.read(buffer, offset, buffer.length - offset, offset) - if (read.bytesRead === 0) break - offset += read.bytesRead - } - return buffer.subarray(0, offset) - } finally { - await handle.close() - } - })() - const detected = FSUtil.mimeType(file.resolvedPath) - const text = content?.toString("utf8") - const mime = file.isDirectory - ? "application/x-directory" - : isImageAttachment(detected) || isPdfAttachment(detected) - ? detected - : content && !isBinaryContent(content) && text !== undefined && Buffer.from(text, "utf8").equals(content) - ? "text/plain" - : detected - - files.push({ - type: "file", - url: content ? `data:${mime};base64,${content.toString("base64")}` : pathToFileURL(file.resolvedPath).href, - filename: path.basename(file.resolvedPath), - mime, - }) - } - - function title() { - if (args.title === undefined) return - if (args.title !== "") return args.title - return message.slice(0, 50) + (message.length > 50 ? "..." : "") - } - - async function currentSession(sdk: OpencodeClient, sessionID: string): Promise { - const listed = await sdk.v2.session - .list({ - directory: await current(sdk), - limit: 50, - order: "desc", - }) - .then((result) => result.data?.data.find((item) => item.id === sessionID)) - .catch(() => undefined) - const selected = - listed ?? - (await sdk.v2.session - .get({ sessionID }) - .then((result) => result.data?.data) - .catch(() => undefined)) - const legacy = - selected ?? - (await sdk.session - .get({ sessionID }) - .then((result) => result.data) - .catch(() => undefined)) - const transcript = await transcriptKind(sdk, legacy?.id ?? sessionID) - if (!legacy && transcript === "empty") { - return - } - if (interactive && transcript === "legacy") { - throw new Error("Mini cannot resume a legacy Session transcript") - } - - return { - id: legacy?.id ?? sessionID, - title: legacy?.title, - directory: legacy ? ("location" in legacy ? legacy.location.directory : legacy.directory) : await current(sdk), - current: transcript !== "legacy", - } - } - - async function forkSession(sdk: OpencodeClient, session: SessionInfo): Promise { - if (session.current !== false) { - const forked = await sdk.v2.session.fork( - { sessionID: session.id, messageID: undefined }, - { throwOnError: true }, - ) - await waitForFork(sdk, session.id, forked.data.data.id) - return { - id: forked.data.data.id, - title: forked.data.data.title, - directory: forked.data.data.location.directory, - current: true, - } - } - - const forked = await sdk.session.fork({ - sessionID: session.id, - }) - const id = forked.data?.id - if (!id) { - return - } - - return { - id, - title: forked.data?.title ?? session.title, - directory: forked.data?.directory ?? session.directory, - current: false, - } - } - - async function waitForFork(sdk: OpencodeClient, parentID: string, sessionID: string) { - const parentHasMessages = await sdk.v2.session - .messages({ sessionID: parentID, limit: 1 }) - .then((result) => (result.data?.data.length ?? 0) > 0) - .catch(() => false) - if (!parentHasMessages) { - return - } - - const deadline = Date.now() + 3000 - while (Date.now() < deadline) { - const forkedHasMessages = await sdk.v2.session - .messages({ sessionID, limit: 1 }) - .then((result) => (result.data?.data.length ?? 0) > 0) - .catch(() => false) - if (forkedHasMessages) { - return - } - - await Bun.sleep(25) - } - } - - async function session(sdk: OpencodeClient): Promise { - if (args.session) { - const current = await currentSession(sdk, args.session) - if (!current) { - UI.error("Session not found") - process.exit(1) - } - if (!interactive && !currentPrompt && current.current !== false) { - throw new Error("This operation is not available for a current Session transcript") - } - - if (args.fork) { - return forkSession(sdk, current) - } - - return current - } - - const base = args.continue ? await currentRootSession(sdk) : undefined - if (base && !interactive && !currentPrompt && base.current !== false) { - throw new Error("This operation is not available for a current Session transcript") - } - - if (base && args.fork) { - return forkSession(sdk, base) - } - - if (base) { - return { - id: base.id, - title: base.title, - directory: base.directory, - current: "current" in base ? base.current : false, - } - } - - if (interactive || currentPrompt) { - const name = title() - const result = await sdk.v2.session.create({ - location: { directory: await current(sdk) }, - }) - const created = result.data?.data - if (!created) return - if (name) await sdk.v2.session.rename({ sessionID: created.id, title: name }) - return { - id: created.id, - title: name ?? created.title, - directory: created.location.directory, - current: true, - } - } - - const name = title() - const result = await sdk.session.create({ - title: name, - permission: [...rules], - }) - const id = result.data?.id - if (!id) { - return - } - - return { - id, - title: result.data?.title ?? name, - directory: result.data?.directory, - current: false, - } - } - - async function currentRootSession(sdk: OpencodeClient): Promise { - const response = await sdk.v2.session.list({ - directory: await current(sdk), - limit: 50, - order: "desc", - }) - const root = (response.data?.data ?? []) - .filter((session) => !session.parentID) - .toSorted((a, b) => b.time.updated - a.time.updated)[0] - if (!root) return - return currentSession(sdk, root.id) - } - - async function transcriptKind(sdk: OpencodeClient, sessionID: string) { - const current = await sdk.v2.session.messages({ sessionID, limit: 1 }).then((result) => (result.data?.data.length ?? 0) > 0) - // Ordinary prompt flows assume a transcript with current messages is - // current-owned; only legacy-only modes (--command, directory - // attachments) still probe legacy history for mixed transcripts. - if (current && (interactive || currentPrompt)) return "current" as const - - const legacy = await sdk.session.messages({ sessionID, limit: 1 }).then((result) => (result.data?.length ?? 0) > 0) - if (current) { - if (legacy) throw new Error("Session contains mixed legacy and current transcripts") - return "current" as const - } - if (legacy) return "legacy" as const - return "empty" as const - } - - async function createFreshSession( - sdk: OpencodeClient, - input: { agent: string | undefined; model: ModelInput | undefined; variant: string | undefined }, - ): Promise { - const name = args.title !== undefined && args.title !== "" ? args.title : undefined - const result = await sdk.v2.session.create({ - agent: input.agent, - model: input.model - ? { - providerID: input.model.providerID, - id: input.model.modelID, - variant: input.variant, - } - : undefined, - location: { directory: await current(sdk) }, - }) - const created = result.data?.data - const id = created?.id - if (!id) { - throw new Error("Failed to create session") - } - if (name) await sdk.v2.session.rename({ sessionID: id, title: name }) - - return { - id, - title: name ?? created.title, - } - } - - async function current(sdk: OpencodeClient): Promise { - if (!args.attach) { - return directory ?? root - } - - const next = await sdk.v2.location - .get(undefined, { throwOnError: true }) - .then((x) => x.data?.directory) - .catch(() => undefined) - if (next) { - return next - } - - UI.error("Failed to resolve remote directory") - process.exit(1) - } - - async function localAgent() { - if (!args.agent) return undefined - const name = args.agent - - const entry = await Effect.runPromise( - agentSvc.get(name).pipe(Effect.provideService(InstanceRef, localInstance)), - ) - if (!entry) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" not found. Falling back to default agent`, - ) - return undefined - } - if (entry.mode === "subagent") { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, - ) - return undefined - } - return name - } - - async function attachAgent(sdk: OpencodeClient) { - if (!args.agent) return undefined - const name = args.agent - - const modes = await loadRunAgents(sdk, await current(sdk)).catch(() => undefined) - - if (!modes) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `failed to list agents from ${args.attach}. Falling back to default agent`, - ) - return undefined - } - - const agent = modes.find((item) => item.name === name) - if (!agent) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" not found. Falling back to default agent`, - ) - return undefined - } - - if (agent.mode === "subagent") { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, - ) - return undefined - } - - return name - } - - async function pickAgent(sdk: OpencodeClient) { - if (!args.agent) return undefined - if (args.attach) { - return attachAgent(sdk) - } - - return localAgent() - } - - async function execute(sdk: OpencodeClient) { - const sess = await session(sdk) - if (!sess?.id) { - UI.error("Session not found") - process.exit(1) - } - const sessionID = sess.id - - function emit(type: string, data: Record) { - if (args.format === "json") { - process.stdout.write( - JSON.stringify({ - type, - timestamp: Date.now(), - sessionID, - ...data, - }) + EOL, - ) - return true - } - return false - } - - // Consume one subscribed event stream for the active session and mirror it - // to stdout/UI. `client` is passed explicitly because attach mode may - // rebind the SDK to the session's directory after the subscription is - // created, and replies issued from inside the loop must use that client. - async function loop(client: OpencodeClient, events: Awaited>) { - const toggles = new Map() - let error: string | undefined - - for await (const event of events.stream) { - if ( - event.type === "message.updated" && - event.properties.sessionID === sessionID && - event.properties.info.role === "assistant" && - args.format !== "json" && - toggles.get("start") !== true - ) { - UI.empty() - UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`) - UI.empty() - toggles.set("start", true) - } - - if (event.type === "message.part.updated") { - const part = event.properties.part - if (part.sessionID !== sessionID) continue - - if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { - if (emit("tool_use", { part })) continue - if (part.state.status === "completed") { - await tool(part) - continue - } - await toolError(part) - UI.error(part.state.error) - } - - if ( - part.type === "tool" && - part.tool === "task" && - part.state.status === "running" && - args.format !== "json" - ) { - if (toggles.get(part.id) === true) continue - await tool(part) - toggles.set(part.id, true) - } - - if (part.type === "step-start") { - if (emit("step_start", { part })) continue - } - - if (part.type === "step-finish") { - if (emit("step_finish", { part })) continue - } - - if (part.type === "text" && part.time?.end) { - if (emit("text", { part })) continue - const text = part.text.trim() - if (!text) continue - if (!process.stdout.isTTY) { - process.stdout.write(text + EOL) - continue - } - UI.empty() - UI.println(text) - UI.empty() - } - - if (part.type === "reasoning" && part.time?.end && thinking) { - if (emit("reasoning", { part })) continue - const text = part.text.trim() - if (!text) continue - const line = `Thinking: ${text}` - if (process.stdout.isTTY) { - UI.empty() - UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`) - UI.empty() - continue - } - process.stdout.write(line + EOL) - } - } - - if (event.type === "session.error") { - const props = event.properties - if (props.sessionID !== sessionID || !props.error) continue - let err = String(props.error.name) - if ("data" in props.error && props.error.data && "message" in props.error.data) { - err = String(props.error.data.message) - } - error = error ? error + EOL + err : err - if (emit("error", { error: props.error })) continue - UI.error(err) - } - - if ( - event.type === "session.status" && - event.properties.sessionID === sessionID && - event.properties.status.type === "idle" - ) { - break - } - - if (event.type === "permission.asked") { - const permission = event.properties - if (permission.sessionID !== sessionID) continue - - if (auto) { - await client.permission.reply({ - requestID: permission.id, - reply: "once", - }) - } else { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL + - `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, - ) - await client.permission.reply({ - requestID: permission.id, - reply: "reject", - }) - } - } - } - return error - } - const cwd = args.attach ? (directory ?? sess.directory ?? (await current(sdk))) : (directory ?? root) - const client = args.attach ? attachSDK(cwd) : sdk - - // Current-session flows resolve explicit --model refs from a location - // catalog that populates asynchronously after boot. Wait only when the - // user supplied a model; default-model races. - const selectedModel = pick(args.model) - if (selectedModel && (interactive || (currentPrompt && sess.current !== false))) { - await waitForCatalogReady({ sdk: client, directory: cwd, model: selectedModel }) - } - - // Validate agent if specified - const agent = await pickAgent(client) - - if (!interactive) { - if (currentPrompt && sess.current !== false) { - const model = selectedModel - const { runNonInteractivePrompt } = await import("./run/noninteractive") - try { - await runNonInteractivePrompt({ - client, - sessionID, - message, - files, - agent, - model, - variant: args.variant, - thinking, - format: args.format === "json" ? "json" : "default", - dangerouslySkipPermissions: args["dangerously-skip-permissions"], - renderTool: tool, - renderToolError: toolError, - }) - } catch (error) { - const output = error instanceof Error ? { type: "unknown", message: error.message } : error - if (!emit("error", { error: output })) UI.error(formatRunError(error)) - process.exitCode = 1 - } - return - } - - const events = await client.event.subscribe() - const completed = loop(client, events).catch((e) => { - console.error(e) - process.exitCode = 1 - }) - async function finish() { - if (args.attach) return - const error = await completed - if (error) process.exitCode = 1 - } - - if (args.command) { - const result = await client.session.command({ - sessionID, - agent, - model: args.model, - command: args.command, - arguments: message, - variant: args.variant, - }) - if (result.error) { - if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error)) - process.exitCode = 1 - return - } - await finish() - return - } - - const model = pick(args.model) - const result = await client.session.prompt({ - sessionID, - agent, - model, - variant: args.variant, - parts: [...files, { type: "text", text: message }], - }) - if (result.error) { - if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error)) - process.exitCode = 1 - return - } - await finish() - return - } - - const model = pick(args.model) - const { runInteractiveMode } = await import("./run/runtime") - try { - await runInteractiveMode({ - sdk: client, - directory: cwd, - sessionID, - sessionTitle: sess.title, - resume: Boolean(args.session || args.continue), - replay, - replayLimit: args["replay-limit"], - agent, - model, - variant: args.variant, - files, - initialInput, - createSession: createFreshSession, - thinking, - backgroundSubagents: flags.experimentalBackgroundSubagents, - demo: args.demo, - }) - } catch (error) { - dieInteractive(error) - } - return - } - - if (interactive && !args.attach && !args.session && !args.continue) { - const model = pick(args.model) - const { runInteractiveLocalMode } = await import("./run/runtime") - const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { - const { Server } = await import("@/server/server") - const request = new Request(input, init) - const headers = new Headers(request.headers) - const auth = ServerAuth.header() - if (auth) headers.set("Authorization", auth) - return Server.Default().app.fetch(new Request(request, { headers })) - }) as typeof globalThis.fetch - - try { - return await runInteractiveLocalMode({ - directory: directory ?? root, - fetch: fetchFn, - resolveAgent: localAgent, - session, - createSession: createFreshSession, - agent: args.agent, - model, - variant: args.variant, - replay, - replayLimit: args["replay-limit"], - files, - initialInput, - thinking, - backgroundSubagents: flags.experimentalBackgroundSubagents, - demo: args.demo, - }) - } catch (error) { - dieInteractive(error) - } - } - - if (args.attach) { - const sdk = attachSDK(directory) - return await execute(sdk) - } - - const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { - const { Server } = await import("@/server/server") - const request = new Request(input, init) - const headers = new Headers(request.headers) - const auth = ServerAuth.header() - if (auth) headers.set("Authorization", auth) - return Server.Default().app.fetch(new Request(request, { headers })) - }) as typeof globalThis.fetch - const sdk = createOpencodeClient({ - baseUrl: "http://opencode.internal", - fetch: fetchFn, - directory, - }) - await execute(sdk) - }) + const { runNonInteractive } = yield* Effect.promise(() => import("@opencode-ai/cli/mini")) + yield* Effect.promise(() => + runNonInteractive({ + message: [...args.message, ...(args["--"] || [])], + continue: args.continue, + session: args.session, + fork: args.fork, + model: args.model, + agent: args.agent, + format: args.format, + file: args.file ?? [], + title: args.title, + server: args.server ?? args.attach, + password: args.password ?? process.env.OPENCODE_PASSWORD ?? process.env.OPENCODE_SERVER_PASSWORD, + username: args.username ?? process.env.OPENCODE_SERVER_USERNAME, + directory: args.dir, + variant: args.variant, + thinking: args.thinking, + dangerouslySkipPermissions: args.auto || args.yolo || args["dangerously-skip-permissions"], + standaloneCommand: args.server || args.attach ? undefined : v2ServerCommand(), + }), + ) }), }) - -type MiniCommandInput = { - directory?: string - attach?: string - password?: string - username?: string - continue?: boolean - session?: string - fork?: boolean - model?: string - agent?: string - prompt?: string - replay?: boolean - replayLimit?: number - demo?: boolean -} - -export async function runMini(input: MiniCommandInput) { - if (!RunCommand.handler) throw new Error("Mini command handler is unavailable") - await RunCommand.handler({ - $0: "opencode", - _: ["mini"], - message: input.prompt ? [input.prompt] : [], - command: undefined, - continue: input.continue, - session: input.session, - fork: input.fork, - model: input.model, - agent: input.agent, - format: "default", - file: undefined, - title: undefined, - attach: input.attach, - password: input.password, - username: input.username, - dir: input.directory, - port: undefined, - variant: undefined, - thinking: undefined, - mini: true, - interactive: false, - replay: input.replay ?? true, - "replay-limit": input.replayLimit, - replayLimit: input.replayLimit, - auto: false, - yolo: false, - "dangerously-skip-permissions": false, - dangerouslySkipPermissions: false, - demo: input.demo ?? false, - } as Parameters>[0] & { mini: boolean }) -} diff --git a/packages/opencode/src/cli/cmd/v2-serve.ts b/packages/opencode/src/cli/cmd/v2-serve.ts new file mode 100644 index 0000000000..e5eeb470d1 --- /dev/null +++ b/packages/opencode/src/cli/cmd/v2-serve.ts @@ -0,0 +1,31 @@ +import { ServerProcess } from "@opencode-ai/cli/server-process" +import { Effect } from "effect" +import { cmd } from "./cmd" + +export const V2ServeCommand = cmd({ + command: "__v2-serve", + describe: false, + builder: (yargs) => + yargs + .option("stdio", { type: "boolean", hidden: true }) + .option("port", { type: "number", hidden: true }), + handler: async (args) => { + const controller = new AbortController() + const interrupt = () => controller.abort() + process.once("SIGINT", interrupt) + process.once("SIGTERM", interrupt) + process.once("SIGHUP", interrupt) + try { + await Effect.runPromise( + ServerProcess.run({ mode: args.stdio ? "stdio" : "service", port: args.port }), + { signal: controller.signal }, + ) + } catch (error) { + if (!controller.signal.aborted) throw error + } finally { + process.off("SIGINT", interrupt) + process.off("SIGTERM", interrupt) + process.off("SIGHUP", interrupt) + } + }, +}) diff --git a/packages/opencode/src/cli/cmd/v2-server-command.ts b/packages/opencode/src/cli/cmd/v2-server-command.ts new file mode 100644 index 0000000000..ef04997868 --- /dev/null +++ b/packages/opencode/src/cli/cmd/v2-server-command.ts @@ -0,0 +1,8 @@ +import path from "node:path" + +export function v2ServerCommand() { + const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun" + const entrypoint = compiled ? [] : process.argv[1] ? [process.argv[1]] : [] + if (!compiled && entrypoint.length === 0) throw new Error("Failed to resolve CLI entrypoint") + return [process.execPath, ...entrypoint, "__v2-serve"] +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index f17904bd49..daa99d43c0 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -19,7 +19,7 @@ import { GithubCommand } from "./cli/cmd/github" import { ExportCommand } from "./cli/cmd/export" import { ImportCommand } from "./cli/cmd/import" import { AttachCommand } from "./cli/cmd/attach" -import { MiniCommand } from "./cli/cmd/mini" +import { V2ServeCommand } from "./cli/cmd/v2-serve" import { TuiThreadCommand } from "./cli/cmd/tui" import { AcpCommand } from "./cli/cmd/acp" import { EOL } from "os" @@ -81,7 +81,7 @@ const cli = yargs(args) .completion("completion", "generate shell completion script") .command(AcpCommand) .command(McpCommand) - .command(MiniCommand) + .command(V2ServeCommand) .command(TuiThreadCommand) .command(AttachCommand) .command(RunCommand) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 7edefdacd4..108888d4f6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -294,8 +294,6 @@ export function createRoutes( HttpServer.layerServices, ]), Layer.provide(Layer.succeed(CorsConfig)(corsOptions)), - Layer.provideMerge(Observability.layer), - Layer.provide(formLocationLayer), Layer.provide(sessionLocationLayer), Layer.provide(locationLayer), @@ -312,6 +310,7 @@ export function createRoutes( Layer.provide(locationServiceMapV2), Layer.provide(AppNodeBuilderV1.build(app)), + Layer.provideMerge(Observability.layer), ) } diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 143c04a12a..641ae2ddf8 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -1,3 +1,6 @@ +export { isDefaultTitle } from "./title" +import { createDefaultTitle } from "./title" + import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { Slug } from "@opencode-ai/core/util/slug" @@ -43,15 +46,6 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { SessionMessage } from "@opencode-ai/schema/session-message" -const parentTitlePrefix = "New session - " -const childTitlePrefix = "Child session - " - -export function isDefaultTitle(title: string) { - return new RegExp( - `^(${parentTitlePrefix}|${childTitlePrefix})\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$`, - ).test(title) -} - type SessionRow = typeof SessionTable.$inferSelect export function fromRow(row: SessionRow): Info { @@ -518,7 +512,7 @@ const layer: Layer.Layer< path: input.path, workspaceID: input.workspaceID, parentID: input.parentID, - title: input.title ?? (input.parentID ? childTitlePrefix : parentTitlePrefix) + new Date().toISOString(), + title: input.title ?? createDefaultTitle(!!input.parentID), agent: input.agent, model: input.model, metadata: input.metadata, diff --git a/packages/opencode/src/session/title.ts b/packages/opencode/src/session/title.ts new file mode 100644 index 0000000000..f90012eb72 --- /dev/null +++ b/packages/opencode/src/session/title.ts @@ -0,0 +1,11 @@ +const parentTitlePrefix = "New session - " +const childTitlePrefix = "Child session - " +const defaultTitle = /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ + +export function createDefaultTitle(child: boolean) { + return (child ? childTitlePrefix : parentTitlePrefix) + new Date().toISOString() +} + +export function isDefaultTitle(title: string) { + return defaultTitle.test(title) +} diff --git a/packages/opencode/src/temporary.ts b/packages/opencode/src/temporary.ts index b100ba9d10..8ece4b89ef 100644 --- a/packages/opencode/src/temporary.ts +++ b/packages/opencode/src/temporary.ts @@ -1,6 +1,6 @@ import yargs from "yargs" -import { MiniCommand } from "./cli/cmd/mini" import { TuiThreadCommand } from "./cli/cmd/tui" +import { V2ServeCommand } from "./cli/cmd/v2-serve" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { hideBin } from "yargs/helpers" const cli = yargs(hideBin(process.argv)) @@ -28,6 +28,6 @@ const cli = yargs(hideBin(process.argv)) if (opts.printLogs) process.env.OPENCODE_PRINT_LOGS = "1" if (opts.logLevel) process.env.OPENCODE_LOG_LEVEL = opts.logLevel }) - .command(MiniCommand) + .command(V2ServeCommand) .command(TuiThreadCommand) .parse() diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index f9201be8a7..312bef9f4f 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -12,6 +12,7 @@ import { LSP } from "@/lsp/lsp" import { FSUtil } from "@opencode-ai/core/fs-util" import DESCRIPTION from "./apply_patch.txt" import { FileSystem } from "@opencode-ai/core/filesystem" +import { FileSystemV1 } from "@opencode-ai/schema/filesystem-v1" import { Format } from "../format" import * as Bom from "@/util/bom" @@ -253,7 +254,7 @@ export const ApplyPatchTool = Tool.define( if (yield* format.file(edited)) { yield* Bom.syncFile(afs, edited, change.bom) } - yield* events.publish(FileSystem.Event.Edited, { file: edited }) + yield* events.publish(FileSystemV1.Event.Edited, { file: edited }) } } diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index a92e4720c0..7e13b85997 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -10,6 +10,7 @@ import { LSP } from "@/lsp/lsp" import { createTwoFilesPatch, diffLines } from "diff" import DESCRIPTION from "./edit.txt" import { FileSystem } from "@opencode-ai/core/filesystem" +import { FileSystemV1 } from "@opencode-ai/schema/filesystem-v1" import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { EventV2Bridge } from "@/event-v2-bridge" import { Format } from "../format" @@ -112,7 +113,7 @@ export const EditTool = Tool.define( if (yield* format.file(filePath)) { contentNew = yield* Bom.syncFile(afs, filePath, desiredBom) } - yield* events.publish(FileSystem.Event.Edited, { file: filePath }) + yield* events.publish(FileSystemV1.Event.Edited, { file: filePath }) yield* events.publish(Watcher.Event.Updated, { file: filePath, event: "add", @@ -156,7 +157,7 @@ export const EditTool = Tool.define( if (yield* format.file(filePath)) { contentNew = yield* Bom.syncFile(afs, filePath, desiredBom) } - yield* events.publish(FileSystem.Event.Edited, { file: filePath }) + yield* events.publish(FileSystemV1.Event.Edited, { file: filePath }) yield* events.publish(Watcher.Event.Updated, { file: filePath, event: "change", diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 37be6d8c47..7d9aa8326a 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -7,6 +7,7 @@ import { createTwoFilesPatch } from "diff" import DESCRIPTION from "./write.txt" import { EventV2Bridge } from "@/event-v2-bridge" import { FileSystem } from "@opencode-ai/core/filesystem" +import { FileSystemV1 } from "@opencode-ai/schema/filesystem-v1" import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { Format } from "../format" import { FSUtil } from "@opencode-ai/core/fs-util" @@ -65,7 +66,7 @@ export const WriteTool = Tool.define( if (yield* format.file(filepath)) { yield* Bom.syncFile(fs, filepath, desiredBom) } - yield* events.publish(FileSystem.Event.Edited, { file: filepath }) + yield* events.publish(FileSystemV1.Event.Edited, { file: filepath }) yield* events.publish(Watcher.Event.Updated, { file: filepath, event: exists ? "change" : "add", diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index bd5dc88e7e..5c84f0d389 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -41,34 +41,6 @@ Options: --pure run without external plugins [boolean]" `; -exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mini --help 1`] = ` -"opencode mini - -start the minimal interactive interface - -Commands: - opencode mini [project] start the minimal interactive interface [default] - opencode mini attach attach to a running opencode server with the minimal interface - -Positionals: - project path to start opencode in [string] - -Options: - -h, --help show help [boolean] - -v, --version show version number [boolean] - --print-logs print logs to stderr [boolean] - --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] - --pure run without external plugins [boolean] - -m, --model model to use in the format of provider/model [string] - --agent agent to use [string] - --prompt prompt to use [string] - -c, --continue continue the last session [boolean] - -s, --session session id to continue [string] - --fork fork the session when continuing (use with --continue or --session) [boolean] - --no-replay disable session history replay on resume and after resize [boolean] - --replay-limit cap visible replay to the newest N messages [number]" -`; - exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode attach --help 1`] = ` "opencode attach @@ -100,33 +72,25 @@ Positionals: message message to send [array] [default: []] Options: - -h, --help show help [boolean] - -v, --version show version number [boolean] - --print-logs print logs to stderr [boolean] - --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] - --pure run without external plugins [boolean] - --command the command to run, use message for args [string] - -c, --continue continue the last session [boolean] - -s, --session session id to continue [string] - --fork fork the session before continuing (requires --continue or --session) [boolean] - -m, --model model to use in the format of provider/model [string] - --agent agent to use [string] - --format format: default (formatted) or json (raw JSON events) + -h, --help show help [boolean] + -v, --version show version number [boolean] + --print-logs print logs to stderr [boolean] + --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] + --pure run without external plugins [boolean] + -c, --continue continue the last session [boolean] + -s, --session session id to continue [string] + --fork fork the session before continuing (requires --continue or --session) [boolean] + -m, --model model to use in the format of provider/model [string] + --agent agent to use [string] + --format format: default (formatted) or json (raw JSON events) [string] [choices: "default", "json"] [default: "default"] - -f, --file file(s) to attach to message [array] - --title title for the session (uses truncated prompt if no value provided) [string] - --attach attach to a running opencode server (e.g., http://localhost:4096) [string] - -p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string] - -u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode') - [string] - --dir directory to run in, path on remote server if attaching [string] - --port port for the local server (defaults to random port if no value provided) - [number] - --variant model variant (provider-specific reasoning effort, e.g., high, max, minimal) - [string] - --thinking show thinking blocks [boolean] - -i, --interactive run in direct interactive split-footer mode [boolean] [default: false] - --auto auto-approve permissions that are not explicitly denied (dangerous!) + -f, --file file(s) to attach to message [array] + --title title for the session (uses truncated prompt if no value provided) [string] + --server connect to a running opencode server [string] + --dir directory to run in, or a path on the remote server [string] + --variant model variant [string] + --thinking show thinking blocks [boolean] + --auto auto-approve permissions that are not explicitly denied (dangerous!) [boolean] [default: false]" `; @@ -426,31 +390,6 @@ Options: --format Output format [string] [choices: "json", "tsv"] [default: "tsv"]" `; -exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mini attach --help 1`] = ` -"opencode mini attach - -attach to a running opencode server with the minimal interface - -Positionals: - url http://localhost:4096 [string] [required] - -Options: - -h, --help show help [boolean] - -v, --version show version number [boolean] - --print-logs print logs to stderr [boolean] - --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] - --pure run without external plugins [boolean] - --dir directory on the remote server [string] - -p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string] - -u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode') - [string] - -c, --continue continue the last session [boolean] - -s, --session session id to continue [string] - --fork fork the session when continuing (use with --continue or --session) [boolean] - --no-replay disable session history replay on resume and after resize [boolean] - --replay-limit cap visible replay to the newest N messages [number]" -`; - exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp list --help 1`] = ` "opencode mcp list diff --git a/packages/opencode/test/cli/help/help-snapshots.test.ts b/packages/opencode/test/cli/help/help-snapshots.test.ts index fc3dabeef3..ec3dd902cb 100644 --- a/packages/opencode/test/cli/help/help-snapshots.test.ts +++ b/packages/opencode/test/cli/help/help-snapshots.test.ts @@ -57,7 +57,6 @@ function normalize(text: string): string { const TOP_LEVEL = [ "acp", "mcp", - "mini", "attach", "run", "debug", @@ -82,7 +81,6 @@ const TOP_LEVEL = [ // distinct argv shape, not every leaf. Add new entries when a subcommand // gains user-visible flags that we want to lock in. const SUBCOMMANDS = [ - ["mini", "attach"], ["mcp", "list"], ["mcp", "add"], ["mcp", "auth"], @@ -115,7 +113,7 @@ describe("opencode CLI help-text snapshots", () => { const topLevel = yield* opencode.spawn(["--help"], { env: SNAPSHOT_ENV }) expect(topLevel.exitCode).toBe(0) expect(topLevel.stderr.endsWith("\n")).toBe(true) - expect(topLevel.stderr).toContain("opencode mini") + expect(topLevel.stderr).not.toContain("opencode mini") expect(topLevel.stderr).not.toContain("--mini") expect(topLevel.stderr).not.toContain("--thinking") expect(topLevel.stderr).not.toContain("--variant") diff --git a/packages/opencode/test/cli/run/catalog.shared.test.ts b/packages/opencode/test/cli/run/catalog.shared.test.ts index bdf57ef7d1..0f5ef225bf 100644 --- a/packages/opencode/test/cli/run/catalog.shared.test.ts +++ b/packages/opencode/test/cli/run/catalog.shared.test.ts @@ -1,20 +1,36 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" -import { OpencodeClient } from "@opencode-ai/sdk/v2" -import { loadRunReferences, runProviders } from "@/cli/cmd/run/catalog.shared" +import { OpenCode } from "@opencode-ai/client/promise" +import { loadRunReferences, runProviders, waitForDefaultModel } from "@opencode-ai/cli/mini/catalog.shared" afterEach(() => { mock.restore() }) describe("run catalog shared", () => { - test("loads visible project references from the current reference catalog", async () => { - const client = new OpencodeClient() - const list = spyOn(client.v2.reference, "list").mockImplementation( + test("resolves the catalog-selected model for the footer", async () => { + const client = OpenCode.make({ baseUrl: "https://opencode.test" }) + const selected = spyOn(client.model, "default").mockImplementation( () => Promise.resolve({ - data: { - location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, - data: [ + location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, + data: { id: "gpt-5", providerID: "openai" }, + }) as never, + ) + + await expect(waitForDefaultModel({ sdk: client, directory: "/tmp" })).resolves.toEqual({ + providerID: "openai", + modelID: "gpt-5", + }) + expect(selected).toHaveBeenCalledWith({ location: { directory: "/tmp" } }) + }) + + test("loads visible project references from the current reference catalog", async () => { + const client = OpenCode.make({ baseUrl: "https://opencode.test" }) + const list = spyOn(client.reference, "list").mockImplementation( + () => + Promise.resolve({ + location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, + data: [ { name: "effect", path: "/repos/effect", @@ -27,17 +43,13 @@ describe("run catalog shared", () => { hidden: true, source: { type: "local", path: "/repos/secret" }, }, - ], - }, - error: undefined, - request: new Request("https://opencode.test"), - response: new Response(), + ], }) as never, ) const references = await loadRunReferences(client, "/tmp") - expect(list).toHaveBeenCalledWith({ location: { directory: "/tmp" } }, { throwOnError: true }) + expect(list).toHaveBeenCalledWith({ location: { directory: "/tmp" } }) expect(references).toMatchObject([{ name: "effect", path: "/repos/effect", description: "Effect v4 sources" }]) }) diff --git a/packages/opencode/test/cli/run/entry.body.test.ts b/packages/opencode/test/cli/run/entry.body.test.ts index 17659113c5..714d0eab25 100644 --- a/packages/opencode/test/cli/run/entry.body.test.ts +++ b/packages/opencode/test/cli/run/entry.body.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import type { ToolPart } from "@opencode-ai/sdk/v2" -import { entryBody, entryCanStream, entryDone } from "@/cli/cmd/run/entry.body" -import type { StreamCommit, ToolSnapshot } from "@/cli/cmd/run/types" +import { entryBody, entryCanStream, entryDone } from "@opencode-ai/cli/mini/entry.body" +import type { StreamCommit, ToolSnapshot } from "@opencode-ai/cli/mini/types" function commit(input: Partial & Pick): StreamCommit { return input @@ -50,6 +50,23 @@ function structured(next: StreamCommit) { } describe("run entry body", () => { + test("renders a failed direct shell as an error instead of completed success", () => { + expect( + entryBody( + commit({ + kind: "tool", + text: "Shell exited with code 7", + phase: "final", + source: "tool", + tool: "bash", + toolState: "error", + toolError: "Shell exited with code 7", + shell: { callID: "sh_failed", command: "false" }, + }), + ), + ).toEqual({ type: "text", content: "✖ bash failed: Shell exited with code 7" }) + }) + test("renders assistant, reasoning, and user entries in their display formats", () => { expect( entryBody( diff --git a/packages/opencode/test/cli/run/footer.menu.test.ts b/packages/opencode/test/cli/run/footer.menu.test.ts index edfa59be88..4086155acb 100644 --- a/packages/opencode/test/cli/run/footer.menu.test.ts +++ b/packages/opencode/test/cli/run/footer.menu.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test" import { createRoot } from "solid-js" -import { FOOTER_MENU_ROWS, createFooterMenuState } from "@/cli/cmd/run/footer.menu" +import { FOOTER_MENU_ROWS, createFooterMenuState } from "@opencode-ai/cli/mini/footer.menu" function mount(count: number, limit = FOOTER_MENU_ROWS) { let dispose!: () => void diff --git a/packages/opencode/test/cli/run/footer.view.test.tsx b/packages/opencode/test/cli/run/footer.view.test.tsx index 83042040f2..4c3139ce9c 100644 --- a/packages/opencode/test/cli/run/footer.view.test.tsx +++ b/packages/opencode/test/cli/run/footer.view.test.tsx @@ -15,10 +15,10 @@ import { RunSkillSelectBody, RunSubagentSelectBody, RunVariantSelectBody, -} from "@/cli/cmd/run/footer.command" -import { RunFooterView } from "@/cli/cmd/run/footer.view" -import { RunEntryContent } from "@/cli/cmd/run/scrollback.writer" -import { RUN_THEME_FALLBACK, type RunTheme } from "@/cli/cmd/run/theme" +} from "@opencode-ai/cli/mini/footer.command" +import { RunFooterView } from "@opencode-ai/cli/mini/footer.view" +import { RunEntryContent } from "@opencode-ai/cli/mini/scrollback.writer" +import { RUN_THEME_FALLBACK, type RunTheme } from "@opencode-ai/cli/mini/theme" import type { FooterState, FooterSubagentState, @@ -30,9 +30,10 @@ import type { RunProvider, RunTuiConfig, StreamCommit, -} from "@/cli/cmd/run/types" -import { RunQuestionBody } from "@/cli/cmd/run/footer.question" -import { RejectField } from "@/cli/cmd/run/footer.permission" +} from "@opencode-ai/cli/mini/types" +import { RunQuestionBody } from "@opencode-ai/cli/mini/footer.question" +import { selectedCommand } from "@opencode-ai/cli/mini/footer.prompt" +import { RejectField } from "@opencode-ai/cli/mini/footer.permission" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" const tuiConfig = createTuiResolvedConfig() @@ -832,6 +833,52 @@ test("direct footer slash autocomplete keeps a real skills command", async () => } }) +test("selectedCommand backfills the catalog source for bound drafts", () => { + const catalog = [command({ name: "opencode-ts", description: "TS skill", source: "skill" })] + + // The skill picker binds `/name ` drafts; older drafts may lack source. + expect(selectedCommand("/opencode-ts fix it", { name: "opencode-ts", arguments: "" }, catalog)).toEqual({ + name: "opencode-ts", + arguments: "fix it", + source: "skill", + }) + // An explicit source wins without a catalog lookup. + expect(selectedCommand("/opencode-ts", { name: "opencode-ts", arguments: "", source: "skill" })).toEqual({ + name: "opencode-ts", + arguments: "", + source: "skill", + }) + // Plain commands stay untagged. + expect(selectedCommand("/deploy prod", { name: "deploy", arguments: "" }, [ + command({ name: "deploy", description: "Deploy" }), + ])).toEqual({ name: "deploy", arguments: "prod" }) +}) + +test("direct footer tags skill slash submissions with their catalog source", async () => { + const submits: RunPrompt[] = [] + const app = await renderFooter({ + commands: [command({ name: "formatter", description: "Apply formatter fixes", source: "skill" })], + onSubmit(prompt) { + submits.push(prompt) + return true + }, + }) + + try { + await app.renderOnce() + "/formatter src".split("").forEach((key) => app.mockInput.pressKey(key)) + await app.renderOnce() + app.mockInput.pressEnter() + await app.renderOnce() + + expect(submits).toEqual([ + { text: "/formatter src", parts: [], command: { name: "formatter", arguments: "src", source: "skill" } }, + ]) + } finally { + app.cleanup() + } +}) + // OpenTUI currently segfaults Bun while tearing down this composer-to-skill-panel transition. // Re-enable after the upstream renderer teardown fix lands. test.skip("direct footer skill picker inserts an editable bound skill command", async () => { @@ -864,7 +911,7 @@ test.skip("direct footer skill picker inserts an editable bound skill command", app.mockInput.pressEnter() await app.renderOnce() - expect(submits).toEqual([{ text: "/new task", parts: [], command: { name: "new", arguments: "task" } }]) + expect(submits).toEqual([{ text: "/new task", parts: [], command: { name: "new", arguments: "task", source: "skill" } }]) } finally { app.cleanup() } diff --git a/packages/opencode/test/cli/run/footer.width.test.ts b/packages/opencode/test/cli/run/footer.width.test.ts index 75efcb8aaf..8feabba6a8 100644 --- a/packages/opencode/test/cli/run/footer.width.test.ts +++ b/packages/opencode/test/cli/run/footer.width.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { footerWidthPolicy } from "@/cli/cmd/run/footer.width" +import { footerWidthPolicy } from "@opencode-ai/cli/mini/footer.width" describe("run footer width", () => { test("preserves shared dialog and statusline breakpoints", () => { diff --git a/packages/opencode/test/cli/run/noninteractive.test.ts b/packages/opencode/test/cli/run/noninteractive.test.ts new file mode 100644 index 0000000000..e77d28b62b --- /dev/null +++ b/packages/opencode/test/cli/run/noninteractive.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" +import { OpenCode, type EventSubscribeOutput } from "@opencode-ai/client/promise" +import { runNonInteractivePrompt } from "@opencode-ai/cli/mini/noninteractive" + +type V2Event = EventSubscribeOutput +type FormInfo = Extract["data"]["form"] + +function ok(data: T) { + return Promise.resolve(data) +} + +function form(id: string, sessionID: string): FormInfo { + return { id, sessionID, mode: "form", fields: [] } +} + +function formCreated(info: FormInfo): V2Event { + return { id: `evt_${info.id}`, created: 0, type: "form.created", data: { form: info } } +} + +function prompted(inputID: string): V2Event { + return { + id: "evt_prompted", + created: 0, + type: "session.prompt.promoted", + durable: { aggregateID: "ses_1", seq: 0, version: 1 }, + data: { sessionID: "ses_1", inputID }, + } +} + +function settled(outcome: "success" | "interrupted" = "success"): V2Event { + return { + id: "evt_settled", + created: 0, + type: "session.execution.settled", + data: { sessionID: "ses_1", outcome }, + } +} + +// Runs one non-interactive prompt against a mocked SDK. `turn` produces the +// live events the prompt admission triggers, keyed by the generated message ID. +async function run(input: { turn: (inputID: string) => V2Event[]; pendingForms?: FormInfo[]; attached?: boolean }) { + const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) + const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }] + let wake: (() => void) | undefined + const stream = (async function* (): AsyncGenerator { + while (true) { + const value = values.shift() + if (!value) { + await new Promise((resolve) => { + wake = resolve + }) + continue + } + yield value + } + })() + spyOn(sdk.event, "subscribe").mockImplementation(() => stream) + spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never) + spyOn(sdk.question, "list").mockImplementation(() => ok([]) as never) + spyOn(sdk.form, "list").mockImplementation( + (request) => + ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never, + ) + spyOn(sdk.form, "cancel").mockImplementation(() => ok(undefined) as never) + spyOn(sdk.session, "prompt").mockImplementation((request) => { + const messageID = request.id ?? "msg_prompt" + values.push(...input.turn(messageID)) + wake?.() + wake = undefined + return ok({ admittedSeq: 1, id: messageID, sessionID: "ses_1", timeCreated: 1 }) as never + }) + await runNonInteractivePrompt({ + client: sdk, + sessionID: "ses_1", + message: "hello", + files: [], + thinking: false, + format: "default", + dangerouslySkipPermissions: false, + attached: input.attached ?? false, + renderTool: () => Promise.resolve(), + renderToolError: () => Promise.resolve(), + }) + return sdk +} + +afterEach(() => { + mock.restore() +}) + +describe("runNonInteractivePrompt", () => { + test("cancels session and global form blockers and exits on pre-promotion interrupt", async () => { + const sdk = await run({ + pendingForms: [form("frm_pending", "ses_1"), form("frm_pending_global", "global")], + // No prompted event: the execution settles interrupted before promotion, + // which must not leave the consume loop waiting forever. + turn: () => [formCreated(form("frm_live", "global")), settled("interrupted")], + }) + expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }) + expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" }) + expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }) + }) + + test("attach mode cancels only session-owned forms", async () => { + const sdk = await run({ + attached: true, + pendingForms: [form("frm_pending", "ses_1"), form("frm_pending_global", "global")], + turn: (messageID) => [formCreated(form("frm_live", "global")), prompted(messageID), settled()], + }) + expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" }) + expect(sdk.form.list).not.toHaveBeenCalledWith({ sessionID: "global" }) + expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }) + expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }) + }) +}) diff --git a/packages/opencode/test/cli/run/permission.shared.test.ts b/packages/opencode/test/cli/run/permission.shared.test.ts index 03be4220f2..84ad63207c 100644 --- a/packages/opencode/test/cli/run/permission.shared.test.ts +++ b/packages/opencode/test/cli/run/permission.shared.test.ts @@ -8,7 +8,7 @@ import { permissionInfo, permissionReject, permissionRun, -} from "@/cli/cmd/run/permission.shared" +} from "@opencode-ai/cli/mini/permission.shared" function req(input: Partial = {}): PermissionRequest { return { diff --git a/packages/opencode/test/cli/run/prompt.editor.test.ts b/packages/opencode/test/cli/run/prompt.editor.test.ts index 872c603047..5506217479 100644 --- a/packages/opencode/test/cli/run/prompt.editor.test.ts +++ b/packages/opencode/test/cli/run/prompt.editor.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" -import { realignEditorPromptParts, resolveEditorSlashValue } from "@/cli/cmd/run/prompt.editor" -import type { RunPromptPart } from "@/cli/cmd/run/types" +import { realignEditorPromptParts, resolveEditorSlashValue } from "@opencode-ai/cli/mini/prompt.editor" +import type { RunPromptPart } from "@opencode-ai/cli/mini/types" describe("run prompt editor helpers", () => { test("strips the local /editor command from the initial editor text", () => { diff --git a/packages/opencode/test/cli/run/prompt.shared.test.ts b/packages/opencode/test/cli/run/prompt.shared.test.ts index fcb1bfe425..e6da0b155e 100644 --- a/packages/opencode/test/cli/run/prompt.shared.test.ts +++ b/packages/opencode/test/cli/run/prompt.shared.test.ts @@ -5,8 +5,8 @@ import { isNewCommand, movePromptHistory, pushPromptHistory, -} from "@/cli/cmd/run/prompt.shared" -import type { RunPrompt } from "@/cli/cmd/run/types" +} from "@opencode-ai/cli/mini/prompt.shared" +import type { RunPrompt } from "@opencode-ai/cli/mini/types" function prompt(text: string, parts: RunPrompt["parts"] = []): RunPrompt { return { text, parts } diff --git a/packages/opencode/test/cli/run/question.shared.test.ts b/packages/opencode/test/cli/run/question.shared.test.ts index 27c8cecdb3..552fc9a65c 100644 --- a/packages/opencode/test/cli/run/question.shared.test.ts +++ b/packages/opencode/test/cli/run/question.shared.test.ts @@ -10,7 +10,7 @@ import { questionStoreCustom, questionSubmit, questionSync, -} from "@/cli/cmd/run/question.shared" +} from "@opencode-ai/cli/mini/question.shared" function req(input: Partial = {}): QuestionRequest { return { diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index 30ae27b68d..6f4b01a849 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -363,11 +363,11 @@ describe("opencode run (non-interactive subprocess)", () => { ) cliIt.concurrent( - "applies a variant to the configured default model", + "applies a variant to the selected model", ({ llm, opencode }) => Effect.gen(function* () { yield* llm.text("variant response") - const result = yield* opencode.spawn(["run", "--variant", "default", "use the default model"], { + const result = yield* opencode.spawn(["run", "--model", "test/test-model", "--variant", "default", "use the model"], { config: { ...testProviderConfig(llm.url), model: "test/test-model" }, }) diff --git a/packages/opencode/test/cli/run/runtime.boot.test.ts b/packages/opencode/test/cli/run/runtime.boot.test.ts index 59a0d8e7c5..f642c47c08 100644 --- a/packages/opencode/test/cli/run/runtime.boot.test.ts +++ b/packages/opencode/test/cli/run/runtime.boot.test.ts @@ -1,17 +1,11 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" -import { OpencodeClient } from "@opencode-ai/sdk/v2" +import { OpenCode } from "@opencode-ai/client/promise" import type { Resolved } from "@opencode-ai/tui/config" -import { TuiConfig } from "@/config/tui" -import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@/cli/cmd/run/runtime.boot" +import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@opencode-ai/cli/mini/runtime.boot" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" function ok(data: T) { - return Promise.resolve({ - data, - error: undefined, - request: new Request("https://opencode.test"), - response: new Response(), - }) + return Promise.resolve(data) } function provider(id: string, name: string) { @@ -108,23 +102,21 @@ describe("run runtime boot", () => { }) test("reads footer keybinds from resolved keybind config", async () => { - spyOn(TuiConfig, "get").mockResolvedValue( - config({ - leader: "ctrl+g", - bindings: { - commandList: ["ctrl+p"], - variantCycle: ["ctrl+t", "alt+t"], - interrupt: ["ctrl+c"], - historyPrevious: ["k"], - historyNext: ["j"], - inputClear: ["ctrl+l"], - inputSubmit: ["ctrl+s"], - inputNewline: ["alt+return"], - }, - }), - ) + const input = config({ + leader: "ctrl+g", + bindings: { + commandList: ["ctrl+p"], + variantCycle: ["ctrl+t", "alt+t"], + interrupt: ["ctrl+c"], + historyPrevious: ["k"], + historyNext: ["j"], + inputClear: ["ctrl+l"], + inputSubmit: ["ctrl+s"], + inputNewline: ["alt+return"], + }, + }) - const result = await resolveRunTuiConfig() + const result = await resolveRunTuiConfig(input) expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+g") expect(result.leader_timeout).toBe(2000) @@ -139,9 +131,7 @@ describe("run runtime boot", () => { }) test("falls back to default tui keymap config when config load fails", async () => { - spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom")) - - const result = await resolveRunTuiConfig() + const result = await resolveRunTuiConfig(Promise.reject(new Error("boom"))) expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+x") expect(result.leader_timeout).toBe(2000) @@ -157,31 +147,23 @@ describe("run runtime boot", () => { }) test("preserves disabled leader from resolved tui config", async () => { - spyOn(TuiConfig, "get").mockResolvedValue(config({ leader: "none" })) - - const result = await resolveRunTuiConfig() + const result = await resolveRunTuiConfig(config({ leader: "none" })) expect(result.keybinds.get("leader")).toEqual([]) }) test("reads diff style and falls back to auto", async () => { - spyOn(TuiConfig, "get").mockResolvedValue(config({ diff_style: "stacked" })) - await expect(resolveDiffStyle()).resolves.toBe("stacked") + await expect(resolveDiffStyle(config({ diff_style: "stacked" }))).resolves.toBe("stacked") - mock.restore() - spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom")) - await expect(resolveDiffStyle()).resolves.toBe("auto") + await expect(resolveDiffStyle(Promise.reject(new Error("boom")))).resolves.toBe("auto") }) test("loads v2 providers and models for model selector data", async () => { - const sdk = new OpencodeClient() + const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) const providers = [provider("openai", "OpenAI")] const models = [model("gpt-5", "openai", 128000, ["high", "minimal"])] - // The generated methods have conditional return types for throwOnError; these mocks represent the successful branch. - // @ts-expect-error successful SDK response is valid for both modes at runtime - const providerList = spyOn(sdk.v2.provider, "list").mockImplementation(() => ok({ data: providers })) - // @ts-expect-error successful SDK response is valid for both modes at runtime - spyOn(sdk.v2.model, "list").mockImplementation(() => ok({ data: models })) + const providerList = spyOn(sdk.provider, "list").mockImplementation(() => ok({ data: providers }) as never) + spyOn(sdk.model, "list").mockImplementation(() => ok({ data: models }) as never) await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({ providers: [ @@ -230,19 +212,15 @@ describe("run runtime boot", () => { directory: "/workspace", }, }, - { throwOnError: true }, ) }) test("loads context limits across v2 providers", async () => { - const sdk = new OpencodeClient() + const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) const providers = [provider("openai", "OpenAI"), provider("anthropic", "Anthropic")] const models = [model("gpt-5", "openai", 128000, ["high", "minimal"]), model("sonnet", "anthropic", 200000)] - // The generated methods have conditional return types for throwOnError; these mocks represent the successful branch. - // @ts-expect-error successful SDK response is valid for both modes at runtime - spyOn(sdk.v2.provider, "list").mockImplementation(() => ok({ data: providers })) - // @ts-expect-error successful SDK response is valid for both modes at runtime - spyOn(sdk.v2.model, "list").mockImplementation(() => ok({ data: models })) + spyOn(sdk.provider, "list").mockImplementation(() => ok({ data: providers }) as never) + spyOn(sdk.model, "list").mockImplementation(() => ok({ data: models }) as never) await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({ providers: [ diff --git a/packages/opencode/test/cli/run/runtime.queue.test.ts b/packages/opencode/test/cli/run/runtime.queue.test.ts index 558373a35d..bdb80efc1d 100644 --- a/packages/opencode/test/cli/run/runtime.queue.test.ts +++ b/packages/opencode/test/cli/run/runtime.queue.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" -import { runPromptQueue } from "@/cli/cmd/run/runtime.queue" -import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "@/cli/cmd/run/types" +import { runPromptQueue } from "@opencode-ai/cli/mini/runtime.queue" +import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "@opencode-ai/cli/mini/types" function footer() { const prompts = new Set<(input: RunPrompt) => void>() diff --git a/packages/opencode/test/cli/run/runtime.stdin.test.ts b/packages/opencode/test/cli/run/runtime.stdin.test.ts index 7c4a10e82d..42e090e893 100644 --- a/packages/opencode/test/cli/run/runtime.stdin.test.ts +++ b/packages/opencode/test/cli/run/runtime.stdin.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { Readable } from "node:stream" -import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "@/cli/cmd/run/runtime.stdin" +import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "@opencode-ai/cli/mini/runtime.stdin" function stream(isTTY: boolean) { return Object.assign(new Readable({ read() {} }), { isTTY }) as NodeJS.ReadStream diff --git a/packages/opencode/test/cli/run/runtime.test.ts b/packages/opencode/test/cli/run/runtime.test.ts index 9ad7e003b2..fcba5842fe 100644 --- a/packages/opencode/test/cli/run/runtime.test.ts +++ b/packages/opencode/test/cli/run/runtime.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" -import { OpencodeClient } from "@opencode-ai/sdk/v2" -import { runInteractiveMode } from "@/cli/cmd/run/runtime" -import type { FooterApi, RunProvider } from "@/cli/cmd/run/types" +import { OpenCode } from "@opencode-ai/client/promise" +import { runInteractiveDeferredMode, runInteractiveMode } from "@opencode-ai/cli/mini/runtime" +import type { FooterApi, FooterEvent, RunProvider } from "@opencode-ai/cli/mini/types" const provider: RunProvider = { id: "openai", @@ -45,15 +45,10 @@ function defer() { } function ok(data: T) { - return Promise.resolve({ - data, - error: undefined, - request: new Request("https://opencode.test"), - response: new Response(), - }) + return Promise.resolve(data) } -function footer(): FooterApi { +function footer(events: FooterEvent[] = []): FooterApi { let closed = false const closes = new Set<() => void>() @@ -78,7 +73,9 @@ function footer(): FooterApi { closes.delete(fn) } }, - event() {}, + event(value) { + events.push(value) + }, append() {}, idle() { return Promise.resolve() @@ -108,15 +105,173 @@ afterEach(() => { }) describe("run interactive runtime", () => { + test("resolves the deferred session only after first paint", async () => { + const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) + const lifecycleStarted = defer() + const painted = defer() + const api = footer() + let resolved = 0 + api.idle = () => painted.promise + spyOn(sdk.provider, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.model, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + + const task = runInteractiveDeferredMode( + { + sdk, + directory: "/tmp", + resolveAgent: async () => "build", + session: async () => { + resolved++ + api.close() + return { id: "ses-deferred", title: "Deferred" } + }, + agent: "build", + model: { providerID: "openai", modelID: "gpt-5" }, + variant: undefined, + files: [], + thinking: false, + backgroundSubagents: false, + }, + { + createRuntimeLifecycle: async () => { + lifecycleStarted.resolve() + return { + footer: api, + onResize: () => () => {}, + refreshTheme: () => {}, + resetForReplay: () => Promise.resolve(), + close: () => Promise.resolve(), + } + }, + }, + ) + + await lifecycleStarted.promise + expect(resolved).toBe(0) + painted.resolve() + await task + expect(resolved).toBe(1) + }) + + test("restores deferred session history and model after first paint", async () => { + const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) + const lifecycleStarted = defer() + const painted = defer() + const events: FooterEvent[] = [] + const api = footer(events) + api.idle = () => painted.promise + const event = api.event + api.event = (value) => { + event(value) + if (value.type === "model") api.close() + } + spyOn(sdk.session, "get").mockImplementation( + () => + ok({ + id: "ses-resume", + projectID: "pro-1", + title: "Resume", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + location: { directory: "/tmp" }, + model: { providerID: "openai", id: "gpt-5", variant: "high" }, + }) as never, + ) + spyOn(sdk.message, "list").mockImplementation( + () => + ok({ + data: [{ id: "msg-user", type: "user", text: "previous prompt", time: { created: 1 } }], + cursor: {}, + }) as never, + ) + spyOn(sdk.provider, "list").mockImplementation( + () => + ok({ + location: { directory: "/tmp" }, + data: [{ id: "openai", name: "OpenAI", request: { headers: {}, body: {} } }], + }) as never, + ) + spyOn(sdk.model, "list").mockImplementation( + () => + ok({ + location: { directory: "/tmp" }, + data: [ + { + id: "gpt-5", + providerID: "openai", + name: "Little Frank", + capabilities: { tools: true, input: ["text"], output: ["text"] }, + request: { headers: {}, body: {} }, + variants: [{ id: "high", settings: {}, headers: {}, body: {} }], + time: { released: 1 }, + cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }], + status: "active", + enabled: true, + limit: { context: 128000, output: 8192 }, + }, + ], + }) as never, + ) + spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + + const task = runInteractiveDeferredMode( + { + sdk, + directory: "/tmp", + resolveAgent: async () => "build", + session: async () => ({ id: "ses-resume", title: "Resume", resume: true }), + agent: "build", + model: undefined, + variant: undefined, + files: [], + thinking: false, + backgroundSubagents: false, + }, + { + createRuntimeLifecycle: async () => { + lifecycleStarted.resolve() + return { + footer: api, + onResize: () => () => {}, + refreshTheme: () => {}, + resetForReplay: () => Promise.resolve(), + close: () => Promise.resolve(), + } + }, + }, + ) + + await lifecycleStarted.promise + expect(sdk.session.get).not.toHaveBeenCalled() + painted.resolve() + await task + + expect(events).toContainEqual({ + type: "history", + history: [{ text: "previous prompt", parts: [] }], + }) + expect(events).toContainEqual({ + type: "model", + model: "Little Frank · OpenAI · high", + selection: { providerID: "openai", modelID: "gpt-5" }, + }) + }) + test("waits for provider metadata before eager replay transport bootstrap", async () => { const providersStarted = defer() const providers = defer() + const lifecycleModels: unknown[] = [] - const sdk = new OpencodeClient() - const legacyProviders = spyOn(sdk.config, "providers").mockRejectedValue(new Error("legacy providers should stay unused")) - const legacyAgents = spyOn(sdk.app, "agents").mockRejectedValue(new Error("legacy agents should stay unused")) - const legacyCommands = spyOn(sdk.command, "list").mockRejectedValue(new Error("legacy commands should stay unused")) - spyOn(sdk.v2.provider, "list").mockImplementation(async () => { + const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) + spyOn(sdk.provider, "list").mockImplementation(async () => { providersStarted.resolve() await providers.promise return ok({ @@ -139,55 +294,56 @@ describe("run interactive runtime", () => { ], }) as never }) - spyOn(sdk.v2.model, "list").mockImplementation(() => - ok({ - location: { - directory: "/tmp", - }, - data: [ - { - id: "gpt-5", - providerID: "openai", - name: "Little Frank", - api: { - id: "openai", - type: "native", - settings: {}, - }, - capabilities: { - tools: true, - input: ["text"], - output: ["text"], - }, - request: { - headers: {}, - body: {}, - }, - variants: [], - time: { - released: 1, - }, - cost: [ - { - input: 0, - output: 0, - cache: { - read: 0, - write: 0, - }, - }, - ], - status: "active", - enabled: true, - limit: { - context: 128000, - output: 8192, - }, + spyOn(sdk.model, "list").mockImplementation( + () => + ok({ + location: { + directory: "/tmp", }, - ], - }) as never, + data: [ + { + id: "gpt-5", + providerID: "openai", + name: "Little Frank", + api: { + id: "openai", + type: "native", + settings: {}, + }, + capabilities: { + tools: true, + input: ["text"], + output: ["text"], + }, + request: { + headers: {}, + body: {}, + }, + variants: [], + time: { + released: 1, + }, + cost: [ + { + input: 0, + output: 0, + cache: { + read: 0, + write: 0, + }, + }, + ], + status: "active", + enabled: true, + limit: { + context: 128000, + output: 8192, + }, + }, + ], + }) as never, ) - spyOn(sdk.v2.session, "messages").mockImplementation(() => + spyOn(sdk.message, "list").mockImplementation(() => ok({ data: [ { @@ -202,9 +358,9 @@ describe("run interactive runtime", () => { cursor: {}, }), ) - spyOn(sdk.v2.session, "get").mockImplementation(() => - ok({ - data: { + spyOn(sdk.session, "get").mockImplementation( + () => + ok({ id: "ses-1", projectID: "pro-1", title: "Session", @@ -229,13 +385,12 @@ describe("run interactive runtime", () => { providerID: "openai", id: "gpt-5", }, - }, - }), + }) as never, ) - spyOn(sdk.v2.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) - spyOn(sdk.v2.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) - spyOn(sdk.v2.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) - spyOn(sdk.v2.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) const task = runInteractiveMode( { @@ -247,23 +402,23 @@ describe("run interactive runtime", () => { replay: true, replayLimit: 100, agent: "build", - model: { - providerID: "openai", - modelID: "gpt-5", - }, + model: undefined, variant: undefined, files: [], thinking: true, backgroundSubagents: false, }, { - createRuntimeLifecycle: async () => ({ - footer: footer(), - onResize: () => () => {}, - refreshTheme: () => {}, - resetForReplay: () => Promise.resolve(), - close: () => Promise.resolve(), - }), + createRuntimeLifecycle: async (input) => { + lifecycleModels.push(input.model) + return { + footer: footer(), + onResize: () => () => {}, + refreshTheme: () => {}, + resetForReplay: () => Promise.resolve(), + close: () => Promise.resolve(), + } + }, streamTransport: Promise.resolve({ createSessionTransport: async (input: { providers?: () => RunProvider[]; footer: FooterApi }) => { transportProviders.push(input.providers?.() ?? []) @@ -291,9 +446,355 @@ describe("run interactive runtime", () => { await task + expect(lifecycleModels).toEqual([{ providerID: "openai", modelID: "gpt-5" }]) expect(transportProviders).toEqual([[provider]]) - expect(legacyProviders).not.toHaveBeenCalled() - expect(legacyAgents).not.toHaveBeenCalled() - expect(legacyCommands).not.toHaveBeenCalled() + }) + + test("defers catalog-selected model resolution until after first paint", async () => { + const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) + const defaultStarted = defer() + const releaseDefault = defer() + const lifecycleStarted = defer() + const painted = defer() + const modelShown = defer() + let defaultRequested = false + const events: FooterEvent[] = [] + const api = footer(events) + api.idle = () => painted.promise + const event = api.event + api.event = (value) => { + event(value) + if (value.type !== "model") return + modelShown.resolve() + api.close() + } + + spyOn(sdk.model, "default").mockImplementation(async () => { + defaultRequested = true + defaultStarted.resolve() + await releaseDefault.promise + return ok({ + location: { directory: "/tmp" }, + data: { id: "gpt-5", providerID: "openai" }, + }) as never + }) + spyOn(sdk.provider, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.model, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + + const task = runInteractiveMode( + { + sdk, + directory: "/tmp", + sessionID: "ses-fresh", + resume: false, + agent: "build", + model: undefined, + variant: undefined, + files: [], + thinking: false, + backgroundSubagents: false, + }, + { + createRuntimeLifecycle: async (input) => { + expect(input.model).toBeUndefined() + lifecycleStarted.resolve() + return { + footer: api, + onResize: () => () => {}, + refreshTheme: () => {}, + resetForReplay: () => Promise.resolve(), + close: () => Promise.resolve(), + } + }, + streamTransport: Promise.resolve({ + createSessionTransport: async () => ({ + runPromptTurn: async () => {}, + interruptActiveTurn: async () => {}, + selectSubagent: () => {}, + replayOnResize: async () => false, + close: async () => {}, + }), + formatUnknownError: (error: unknown) => (error instanceof Error ? error.message : String(error)), + }), + }, + ) + + await lifecycleStarted.promise + expect(defaultRequested).toBe(false) + painted.resolve() + await defaultStarted.promise + releaseDefault.resolve() + await modelShown.promise + await task + + expect(events.find((event) => event.type === "model")).toEqual({ + type: "model", + model: "gpt-5 · openai", + selection: { providerID: "openai", modelID: "gpt-5" }, + }) + }) + + test("does not start deferred work after the footer closes", async () => { + const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) + const lifecycleStarted = defer() + const painted = defer() + const api = footer() + api.idle = () => painted.promise + const defaultModel = spyOn(sdk.model, "default") + + const task = runInteractiveMode( + { + sdk, + directory: "/tmp", + sessionID: "ses-closed", + resume: false, + agent: "build", + model: undefined, + variant: undefined, + files: [], + thinking: false, + backgroundSubagents: false, + }, + { + createRuntimeLifecycle: async () => { + lifecycleStarted.resolve() + return { + footer: api, + onResize: () => () => {}, + refreshTheme: () => {}, + resetForReplay: () => Promise.resolve(), + close: () => Promise.resolve(), + } + }, + }, + ) + + await lifecycleStarted.promise + api.close() + painted.resolve() + await task + + expect(defaultModel).not.toHaveBeenCalled() + }) + + test("searches files through the V2 file API", async () => { + const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) + const api = footer() + const find = spyOn(sdk.file, "find").mockImplementation( + () => + ok({ + location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, + data: [{ path: "src/index.ts", type: "file" }], + }) as never, + ) + + await runInteractiveMode( + { + sdk, + directory: "/tmp", + sessionID: "ses-files", + resume: false, + agent: "build", + model: undefined, + variant: undefined, + files: [], + thinking: false, + backgroundSubagents: false, + }, + { + createRuntimeLifecycle: async (input) => { + await expect(input.findFiles("index")).resolves.toEqual(["src/index.ts"]) + api.close() + return { + footer: api, + onResize: () => () => {}, + refreshTheme: () => {}, + resetForReplay: () => Promise.resolve(), + close: () => Promise.resolve(), + } + }, + }, + ) + + expect(find).toHaveBeenCalledWith({ query: "index", type: "file", location: { directory: "/tmp" } }) + }) + + test("retains last-known-good state across failed coalesced refreshes and retries later", async () => { + const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) + const refreshGate = defer() + let providerCalls = 0 + let modelCalls = 0 + let agentCalls = 0 + let referenceCalls = 0 + const events: FooterEvent[] = [] + const api = footer(events) + spyOn(sdk.provider, "list").mockImplementation(async () => { + providerCalls++ + if (providerCalls === 2) { + await refreshGate.promise + throw new Error("provider refresh failed") + } + return ok({ + location: { directory: "/tmp" }, + data: [ + { + id: "openai", + name: providerCalls >= 3 ? "OpenAI refreshed" : "OpenAI", + api: { type: "native", settings: {} }, + request: { headers: {}, body: {} }, + }, + ], + }) as never + }) + spyOn(sdk.model, "list").mockImplementation(() => { + modelCalls++ + return ok({ + location: { directory: "/tmp" }, + data: [ + { + id: "gpt-5", + providerID: "openai", + name: "Little Frank", + api: { id: "openai", type: "native", settings: {} }, + capabilities: { tools: true, input: ["text"], output: ["text"] }, + request: { headers: {}, body: {} }, + variants: + modelCalls >= 4 ? [] : [{ id: modelCalls >= 3 ? "high" : "low", settings: {}, headers: {}, body: {} }], + time: { released: 1 }, + cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }], + status: "active", + enabled: true, + limit: { context: modelCalls >= 3 ? 256000 : 128000, output: 8192 }, + }, + ], + }) as never + }) + spyOn(sdk.agent, "list").mockImplementation(async () => { + agentCalls++ + if (agentCalls === 2) throw new Error("agent refresh failed") + return ok({ + location: { directory: "/tmp" }, + data: [{ id: "build", description: agentCalls >= 3 ? "Refreshed agent" : "Agent", mode: "primary" }], + }) as never + }) + spyOn(sdk.reference, "list").mockImplementation(() => { + referenceCalls++ + return ok({ + location: { directory: "/tmp" }, + data: [ + { name: "effect", path: "/effect", description: referenceCalls >= 3 ? "Refreshed reference" : "Reference" }, + ], + }) as never + }) + spyOn(sdk.command, "list").mockImplementation( + () => ok({ location: { directory: "/tmp" }, data: [{ name: "check", description: "Check" }] }) as never, + ) + spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + let finalProviders: RunProvider[] = [] + let finalLimits: Record = {} + let retainedProviders: RunProvider[] = [] + let retainedLimits: Record = {} + let retainedCatalog: FooterEvent | undefined + let selectedDefault: unknown + let selectDefault: (() => unknown) | undefined + let selectVariant: ((variant: string | undefined) => unknown) | undefined + let defaultRefreshVariants: FooterEvent | undefined + + await runInteractiveMode( + { + sdk, + directory: "/tmp", + sessionID: "ses-1", + sessionTitle: "Session", + resume: false, + agent: "build", + model: { providerID: "openai", modelID: "gpt-5" }, + variant: "low", + files: [], + thinking: false, + backgroundSubagents: false, + }, + { + createRuntimeLifecycle: async (input) => { + selectDefault = () => input.onVariantSelect?.(undefined) + selectVariant = (variant) => input.onVariantSelect?.(variant) + return { + footer: api, + onResize: () => () => {}, + refreshTheme: () => {}, + resetForReplay: () => Promise.resolve(), + close: () => Promise.resolve(), + } + }, + streamTransport: Promise.resolve({ + createSessionTransport: async (input) => { + while ( + !events.some( + (event) => event.type === "variants" && event.variants.includes("low") && event.current === "low", + ) + ) + await Bun.sleep(0) + selectedDefault = await Promise.resolve(selectDefault?.()) + input.onCatalogRefresh?.() + input.onCatalogRefresh?.() + input.onCatalogRefresh?.() + while (providerCalls < 2) await Bun.sleep(0) + refreshGate.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + retainedProviders = input.providers?.() ?? [] + retainedLimits = input.limits() + retainedCatalog = events.filter((event) => event.type === "catalog").at(-1) + input.onCatalogRefresh?.() + input.onCatalogRefresh?.() + while (providerCalls < 3 || modelCalls < 3 || agentCalls < 3) await Bun.sleep(0) + await new Promise((resolve) => setTimeout(resolve, 0)) + defaultRefreshVariants = events.filter((event) => event.type === "variants").at(-1) + await Promise.resolve(selectVariant?.("high")) + input.onCatalogRefresh?.() + while (providerCalls < 4 || modelCalls < 4) await Bun.sleep(0) + await new Promise((resolve) => setTimeout(resolve, 0)) + finalProviders = input.providers?.() ?? [] + finalLimits = input.limits() + setTimeout(() => input.footer.close(), 0) + return { + runPromptTurn: async () => {}, + interruptActiveTurn: async () => {}, + selectSubagent: () => {}, + replayOnResize: async () => false, + close: async () => {}, + } + }, + formatUnknownError: (error: unknown) => (error instanceof Error ? error.message : String(error)), + }), + }, + ) + + expect(providerCalls).toBe(4) + expect(modelCalls).toBe(4) + expect(retainedProviders[0]?.name).toBe("OpenAI") + expect(retainedProviders[0]?.models["gpt-5"]?.variants).toEqual({ low: {} }) + expect(retainedLimits["openai/gpt-5"]).toBe(128000) + expect(retainedCatalog).toMatchObject({ + agents: [{ name: "build", description: "Agent" }], + references: [{ name: "effect", description: "Reference" }], + }) + expect(selectedDefault).toMatchObject({ variant: undefined }) + expect(defaultRefreshVariants).toMatchObject({ variants: ["high"], current: undefined }) + expect(finalProviders[0]?.name).toBe("OpenAI refreshed") + expect(finalProviders[0]?.models["gpt-5"]?.variants).toEqual({}) + expect(finalLimits["openai/gpt-5"]).toBe(256000) + expect(events.filter((event) => event.type === "variants").at(-1)).toMatchObject({ + variants: [], + current: undefined, + }) + expect(events.filter((event) => event.type === "catalog").at(-1)).toMatchObject({ + agents: [{ name: "build", description: "Refreshed agent" }], + references: [{ name: "effect", description: "Refreshed reference" }], + commands: [{ name: "check", description: "Check" }], + }) }) }) diff --git a/packages/opencode/test/cli/run/scrollback.surface.test.ts b/packages/opencode/test/cli/run/scrollback.surface.test.ts index 7406d36cbb..abe40559ed 100644 --- a/packages/opencode/test/cli/run/scrollback.surface.test.ts +++ b/packages/opencode/test/cli/run/scrollback.surface.test.ts @@ -2,9 +2,9 @@ import { afterEach, expect, test } from "bun:test" import type { ToolPart } from "@opencode-ai/sdk/v2" import { RGBA, SyntaxStyle } from "@opentui/core" import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing" -import { RunScrollbackStream } from "@/cli/cmd/run/scrollback.surface" -import { RUN_THEME_FALLBACK, type RunTheme } from "@/cli/cmd/run/theme" -import type { StreamCommit } from "@/cli/cmd/run/types" +import { RunScrollbackStream } from "@opencode-ai/cli/mini/scrollback.surface" +import { RUN_THEME_FALLBACK, type RunTheme } from "@opencode-ai/cli/mini/theme" +import type { StreamCommit } from "@opencode-ai/cli/mini/types" type ClaimedCommit = { snapshot: { diff --git a/packages/opencode/test/cli/run/session-data.test.ts b/packages/opencode/test/cli/run/session-data.test.ts index ec21cd007e..b1fa17df13 100644 --- a/packages/opencode/test/cli/run/session-data.test.ts +++ b/packages/opencode/test/cli/run/session-data.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import type { Event } from "@opencode-ai/sdk/v2" -import { createSessionData, reduceSessionData } from "@/cli/cmd/run/session-data" -import type { StreamCommit } from "@/cli/cmd/run/types" +import { createSessionData, reduceSessionData } from "@opencode-ai/cli/mini/session-data" +import type { StreamCommit } from "@opencode-ai/cli/mini/types" function reduce(data: ReturnType, event: unknown, thinking = true) { return reduceSessionData({ @@ -110,6 +110,39 @@ function tool(input: { id: string; messageID: string; tool: string; state: Recor } } +function shellInfo(id: string, status: "running" | "exited", completed?: number) { + return { + id, + status, + command: "pwd", + cwd: "/tmp/demo", + shell: "/bin/sh", + file: `/tmp/${id}.log`, + ...(status === "exited" ? { exit: 0 } : {}), + metadata: {}, + time: { started: 1, ...(completed === undefined ? {} : { completed }) }, + } +} + +function shellStarted(id = "call-1") { + return { + type: "session.shell.started", + properties: { sessionID: "session-1", shell: shellInfo(id, "running") }, + } +} + +function shellEnded(id = "call-1") { + const output = "/tmp/demo\n" + return { + type: "session.shell.ended", + properties: { + sessionID: "session-1", + shell: shellInfo(id, "exited", 2), + output: { output, cursor: Buffer.byteLength(output), size: Buffer.byteLength(output), truncated: false }, + }, + } +} + describe("run session data", () => { test("buffers delayed assistant text until the role is known", () => { let data = createSessionData() @@ -328,15 +361,7 @@ describe("run session data", () => { test("renders direct shell mode from first-class shell events", () => { let data = createSessionData() - const started = reduce(data, { - type: "session.next.shell.started", - properties: { - sessionID: "session-1", - timestamp: 1, - callID: "call-1", - command: "pwd", - }, - }) + const started = reduce(data, shellStarted()) expect(started.commits).toEqual([ expect.objectContaining({ @@ -352,15 +377,7 @@ describe("run session data", () => { ]) data = started.data - const ended = reduce(data, { - type: "session.next.shell.ended", - properties: { - sessionID: "session-1", - timestamp: 2, - callID: "call-1", - output: "/tmp/demo\n", - }, - }) + const ended = reduce(data, shellEnded()) expect(ended.commits).toEqual([ expect.objectContaining({ @@ -379,15 +396,7 @@ describe("run session data", () => { }) test("suppresses legacy bash part updates once shell events claim the call", () => { - let data = reduce(createSessionData(), { - type: "session.next.shell.started", - properties: { - sessionID: "session-1", - timestamp: 1, - callID: "call-1", - command: "pwd", - }, - }).data + let data = reduce(createSessionData(), shellStarted()).data expect( reduce( @@ -408,15 +417,7 @@ describe("run session data", () => { ).commits, ).toEqual([]) - data = reduce(data, { - type: "session.next.shell.ended", - properties: { - sessionID: "session-1", - timestamp: 2, - callID: "call-1", - output: "/tmp/demo\n", - }, - }).data + data = reduce(data, shellEnded()).data expect( reduce( @@ -462,15 +463,7 @@ describe("run session data", () => { ).data expect( - reduce(data, { - type: "session.next.shell.started", - properties: { - sessionID: "session-1", - timestamp: 1, - callID: "call-1", - command: "pwd", - }, - }).commits, + reduce(data, shellStarted()).commits, ).toEqual([]) data = reduce( @@ -496,15 +489,7 @@ describe("run session data", () => { ).data expect( - reduce(data, { - type: "session.next.shell.ended", - properties: { - sessionID: "session-1", - timestamp: 2, - callID: "call-1", - output: "/tmp/demo\n", - }, - }).commits, + reduce(data, shellEnded()).commits, ).toEqual([]) }) diff --git a/packages/opencode/test/cli/run/session.shared.test.ts b/packages/opencode/test/cli/run/session.shared.test.ts index a470f51014..2e098581ac 100644 --- a/packages/opencode/test/cli/run/session.shared.test.ts +++ b/packages/opencode/test/cli/run/session.shared.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" -import { OpencodeClient } from "@opencode-ai/sdk/v2" +import { OpenCode } from "@opencode-ai/client/promise" import { createSession, resolveCurrentSession, @@ -7,7 +7,7 @@ import { sessionVariant, type RunSession, type SessionMessages, -} from "@/cli/cmd/run/session.shared" +} from "@opencode-ai/cli/mini/session.shared" type Message = SessionMessages[number] type Part = Message["parts"][number] @@ -252,11 +252,10 @@ describe("run session shared", () => { }) test("restores current prompt history from stored text and file references", async () => { - const client = new OpencodeClient() - spyOn(client.v2.session, "messages").mockImplementation(() => + const client = OpenCode.make({ baseUrl: "https://opencode.test" }) + spyOn(client.message, "list").mockImplementation(() => Promise.resolve({ - data: { - data: [ + data: [ { id: "msg_prompt", type: "user", @@ -272,37 +271,27 @@ describe("run session shared", () => { agents: [], time: { created: 1 }, }, - ], - cursor: {}, - }, - error: undefined, - request: new Request("https://opencode.test"), - response: new Response(), + ], + cursor: {}, }), ) - spyOn(client.v2.session, "get").mockImplementation(() => + spyOn(client.session, "get").mockImplementation(() => Promise.resolve({ - data: { - data: { - id: "ses_1", - title: "Session", - version: "dev", - projectID: "proj_1", - location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, - time: { created: 1, updated: 1 }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - model: { providerID: "openai", id: "gpt-5", variant: "high" }, - }, - }, - error: undefined, - request: new Request("https://opencode.test"), - response: new Response(), + id: "ses_1", + title: "Session", + projectID: "proj_1", + location: { directory: "/tmp" }, + time: { created: 1, updated: 1 }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + model: { providerID: "openai", id: "gpt-5", variant: "high" }, }), ) const out = await resolveCurrentSession(client, "ses_1") + expect(out.model).toEqual({ providerID: "openai", modelID: "gpt-5" }) + expect(out.variant).toBe("high") expect(out.turns[0]?.prompt).toEqual({ text: "Review @note.ts", parts: [ diff --git a/packages/opencode/test/cli/run/stream-v2.transport.test.ts b/packages/opencode/test/cli/run/stream-v2.transport.test.ts index c09617701f..d191419e5c 100644 --- a/packages/opencode/test/cli/run/stream-v2.transport.test.ts +++ b/packages/opencode/test/cli/run/stream-v2.transport.test.ts @@ -2,12 +2,12 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import fs from "fs/promises" import path from "path" import { pathToFileURL } from "node:url" -import { OpencodeClient, type V2Event } from "@opencode-ai/sdk/v2" -import { createSessionTransport } from "@/cli/cmd/run/stream-v2.transport" -import type { FooterApi, FooterEvent, StreamCommit } from "@/cli/cmd/run/types" +import { OpenCode, type EventSubscribeOutput, type MessageListOutput, type OpenCodeClient } from "@opencode-ai/client/promise" +import { createSessionTransport } from "@opencode-ai/cli/mini/stream-v2.transport" +import type { FooterApi, FooterEvent, StreamCommit } from "@opencode-ai/cli/mini/types" import { tmpdir } from "../../fixture/fixture" -type RunV2Event = V2Event +type RunV2Event = EventSubscribeOutput function feed() { const values: RunV2Event[] = [] @@ -41,18 +41,17 @@ function feed() { } function ok(data: T) { - return Promise.resolve({ - data, - error: undefined, - request: new Request("https://opencode.test"), - response: new Response(), - }) + return Promise.resolve(data) } function connected(id = "evt_connected") { return { id, type: "server.connected", data: {} } satisfies RunV2Event } +function durable(sessionID: string, seq = 0, version = 1) { + return { aggregateID: sessionID, seq, version } +} + function footer() { const commits: StreamCommit[] = [] const events: FooterEvent[] = [] @@ -81,9 +80,7 @@ function footer() { return { api, commits, events } } -type SessionMessages = NonNullable< - Awaited>["data"] ->["data"][number][] +type SessionMessages = MessageListOutput["data"] function sdk(input: { streams: ReturnType[] @@ -91,12 +88,10 @@ function sdk(input: { messages?: Record sessions?: Array<{ id: string; parentID?: string; title?: string; agent?: string; time: { updated: number } }> }) { - const client = new OpencodeClient() + const client = OpenCode.make({ baseUrl: "https://opencode.test" }) let subscription = 0 - spyOn(client.v2.event, "subscribe").mockImplementation( - () => Promise.resolve({ stream: input.streams[subscription++]?.stream ?? feed().stream }) as ReturnType, - ) - spyOn(client.v2.session, "messages").mockImplementation((request) => + spyOn(client.event, "subscribe").mockImplementation(() => input.streams[subscription++]?.stream ?? feed().stream) + spyOn(client.message, "list").mockImplementation((request) => ok({ data: input.messages?.[request.sessionID] ?? [ { @@ -111,21 +106,28 @@ function sdk(input: { cursor: {}, }), ) - spyOn(client.v2.session.permission, "list").mockImplementation(() => ok({ data: [] })) - spyOn(client.v2.session.question, "list").mockImplementation(() => ok({ data: [] })) - spyOn(client.v2.session, "active").mockImplementation(() => ok({ data: input.active?.() ?? {}, watermarks: {} })) - spyOn(client.v2.session, "switchAgent").mockImplementation(() => ok(undefined)) - spyOn(client.v2.session, "switchModel").mockImplementation(() => ok(undefined)) + spyOn(client.permission, "list").mockImplementation(() => ok([])) + spyOn(client.question, "list").mockImplementation(() => ok([])) + spyOn(client.session, "active").mockImplementation(() => ok({ data: input.active?.() ?? {}, watermarks: {} })) + spyOn(client.session, "switchAgent").mockImplementation(() => ok(undefined)) + spyOn(client.session, "switchModel").mockImplementation(() => ok(undefined)) // The generated methods have conditional return types for throwOnError; the // minimal shapes below are enough for family discovery and model fallback. - spyOn(client.v2.session, "list").mockImplementation( - () => - ok({ - location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, - data: input.sessions ?? [], - }) as never, - ) - spyOn(client.v2.model, "default").mockImplementation( + spyOn(client.session, "list").mockImplementation((request) => { + const parentID = request?.parentID + return ok({ + location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, + data: + input.sessions?.filter((session) => + parentID === undefined + ? true + : parentID === null + ? session.parentID === undefined + : session.parentID === parentID, + ) ?? [], + }) as never + }) + spyOn(client.model, "default").mockImplementation( () => ok({ location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, @@ -156,9 +158,7 @@ describe("V2 mini transport", () => { expect(ui.commits.map((item) => item.text)).toEqual(["previous prompt"]) let admitted = false - // The generated method has conditional return types for throwOnError; this mock represents the successful branch. - // @ts-expect-error successful SDK response is valid for both modes at runtime - spyOn(client.v2.session, "prompt").mockImplementation((request) => { + spyOn(client.session, "prompt").mockImplementation((request) => { const messageID = request.id ?? "msg_prompt" const prompt = request.prompt ?? { text: "" } admitted = true @@ -171,7 +171,7 @@ describe("V2 mini transport", () => { delivery: "steer" as const, timeCreated: 2, }, - }) + }) as never }) const turn = transport.runPromptTurn({ @@ -184,32 +184,32 @@ describe("V2 mini transport", () => { }) while (!admitted) await Bun.sleep(0) events.push({ - id: "evt_prompted", - type: "session.next.prompted", - data: { - timestamp: 2, - sessionID: "ses_1", - messageID: "msg_prompt", - prompt: { text: "hello" }, - delivery: "steer", - }, - }) - events.push({ - id: "evt_text", - type: "session.next.text.delta", - data: { - timestamp: 3, - sessionID: "ses_1", - assistantMessageID: "msg_assistant", - textID: "txt_1", - delta: "answer", - }, - }) - events.push({ - id: "evt_settled", - type: "session.next.execution.settled", - data: { timestamp: 4, sessionID: "ses_1", outcome: "success" }, - }) + id: "evt_prompted", + created: 0, + type: "session.prompt.promoted", + durable: durable("ses_1"), + data: { + sessionID: "ses_1", + inputID: "msg_prompt", + }, + }) + events.push({ + id: "evt_text", + created: 0, + type: "session.text.delta", + data: { + sessionID: "ses_1", + assistantMessageID: "msg_assistant", + textID: "txt_1", + delta: "answer", + }, + }) + events.push({ + id: "evt_settled", + created: 0, + type: "session.execution.settled", + data: { sessionID: "ses_1", outcome: "success" }, + }) await turn expect(ui.commits.map((item) => item.text)).toEqual(["previous prompt", "answer"]) @@ -236,29 +236,25 @@ describe("V2 mini transport", () => { limits: () => ({}), footer: ui.api, }) - let request: - | Parameters[0] - | undefined - // The generated method has conditional return types for throwOnError; this mock represents the successful branch. - // @ts-expect-error successful SDK response is valid for both modes at runtime - spyOn(client.v2.session, "prompt").mockImplementation((input) => { + let request: Parameters[0] | undefined + spyOn(client.session, "prompt").mockImplementation((input) => { request = input queueMicrotask(() => { events.push({ id: "evt_prompted", - type: "session.next.prompted", + created: 0, + type: "session.prompt.promoted", + durable: durable("ses_1"), data: { - timestamp: 2, sessionID: "ses_1", - messageID: "msg_prompt", - prompt: { text: input.prompt?.text ?? "" }, - delivery: "steer", + inputID: "msg_prompt", }, }) events.push({ id: "evt_settled", - type: "session.next.execution.settled", - data: { timestamp: 3, sessionID: "ses_1", outcome: "success" }, + created: 0, + type: "session.execution.settled", + data: { sessionID: "ses_1", outcome: "success" }, }) }) return ok({ @@ -270,7 +266,7 @@ describe("V2 mini transport", () => { delivery: "steer" as const, timeCreated: 2, }, - }) + }) as never }) await transport.runPromptTurn({ @@ -332,29 +328,27 @@ describe("V2 mini transport", () => { limits: () => ({}), footer: ui.api, }) - let request: - | Parameters[0] - | undefined + let request: Parameters[0] | undefined // The generated method has conditional return types for throwOnError; this mock represents the successful branch. // @ts-expect-error successful SDK response is valid for both modes at runtime - spyOn(client.v2.session, "prompt").mockImplementation((input) => { + spyOn(client.session, "prompt").mockImplementation((input) => { request = input queueMicrotask(() => { events.push({ id: "evt_prompted", - type: "session.next.prompted", + created: 0, + type: "session.prompt.promoted", + durable: durable("ses_1"), data: { - timestamp: 2, sessionID: "ses_1", - messageID: "msg_prompt", - prompt: { text: input.prompt?.text ?? "" }, - delivery: "steer", + inputID: "msg_prompt", }, }) events.push({ id: "evt_settled", - type: "session.next.execution.settled", - data: { timestamp: 3, sessionID: "ses_1", outcome: "success" }, + created: 0, + type: "session.execution.settled", + data: { sessionID: "ses_1", outcome: "success" }, }) }) return ok({ @@ -431,29 +425,27 @@ describe("V2 mini transport", () => { limits: () => ({}), footer: ui.api, }) - let request: - | Parameters[0] - | undefined + let request: Parameters[0] | undefined // The generated method has conditional return types for throwOnError; this mock represents the successful branch. // @ts-expect-error successful SDK response is valid for both modes at runtime - spyOn(client.v2.session, "prompt").mockImplementation((input) => { + spyOn(client.session, "prompt").mockImplementation((input) => { request = input queueMicrotask(() => { events.push({ id: "evt_prompted", - type: "session.next.prompted", + created: 0, + type: "session.prompt.promoted", + durable: durable("ses_1"), data: { - timestamp: 2, sessionID: "ses_1", - messageID: "msg_prompt", - prompt: { text: input.prompt?.text ?? "" }, - delivery: "steer", + inputID: "msg_prompt", }, }) events.push({ id: "evt_settled", - type: "session.next.execution.settled", - data: { timestamp: 3, sessionID: "ses_1", outcome: "success" }, + created: 0, + type: "session.execution.settled", + data: { sessionID: "ses_1", outcome: "success" }, }) }) return ok({ @@ -514,6 +506,7 @@ describe("V2 mini transport", () => { }) events.push({ id: "evt_permission", + created: 0, type: "permission.v2.asked", data: { id: "per_1", sessionID: "ses_1", action: "read", resources: ["/tmp/file"] }, }) @@ -552,7 +545,7 @@ describe("V2 mini transport", () => { }, }) let projected = false - spyOn(client.v2.session, "messages").mockImplementation(() => + spyOn(client.message, "list").mockImplementation(() => ok({ data: projected ? [ @@ -580,11 +573,13 @@ describe("V2 mini transport", () => { let admitted = false // The generated method has conditional return types for throwOnError; this mock represents the successful branch. // @ts-expect-error successful SDK response is valid for both modes at runtime - spyOn(client.v2.session, "prompt").mockImplementation((request) => { + spyOn(client.session, "prompt").mockImplementation((request) => { const messageID = request.id ?? "msg_prompt" const prompt = request.prompt ?? { text: "" } admitted = true - return ok({ data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 } }) + return ok({ + data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 }, + }) }) const turn = transport.runPromptTurn({ @@ -621,7 +616,7 @@ describe("V2 mini transport", () => { return active }, }) - spyOn(client.v2.session, "messages").mockImplementation(() => + spyOn(client.message, "list").mockImplementation(() => ok({ data: projected ? [ @@ -650,11 +645,13 @@ describe("V2 mini transport", () => { let admitted = false // The generated method has conditional return types for throwOnError; this mock represents the successful branch. // @ts-expect-error successful SDK response is valid for both modes at runtime - spyOn(client.v2.session, "prompt").mockImplementation((request) => { + spyOn(client.session, "prompt").mockImplementation((request) => { const messageID = request.id ?? "msg_prompt" const prompt = request.prompt ?? { text: "" } admitted = true - return ok({ data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 } }) + return ok({ + data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 }, + }) }) const turn = transport.runPromptTurn({ @@ -688,7 +685,7 @@ describe("V2 mini transport", () => { limits: () => ({}), footer: ui.api, }) - spyOn(client.v2.session, "messages").mockImplementation(() => + spyOn(client.message, "list").mockImplementation(() => ok({ data: [ { @@ -710,9 +707,9 @@ describe("V2 mini transport", () => { const replay = transport.replayOnResize({ localRows: () => [], reset: () => resetting }) events.push({ id: "evt_text", - type: "session.next.text.delta", + created: 0, + type: "session.text.delta", data: { - timestamp: 3, sessionID: "ses_1", assistantMessageID: "msg_assistant", textID: "txt_1", @@ -732,7 +729,7 @@ describe("V2 mini transport", () => { const events = feed() events.push(connected()) const client = sdk({ streams: [events] }) - spyOn(client.v2.session, "messages").mockImplementation(() => + spyOn(client.message, "list").mockImplementation(() => ok({ data: [ { @@ -795,9 +792,10 @@ describe("V2 mini transport", () => { }) events.push({ id: "evt_reasoning", - type: "session.next.reasoning.ended", + created: 0, + type: "session.reasoning.ended", + durable: durable("ses_1"), data: { - timestamp: 3, sessionID: "ses_1", assistantMessageID: "msg_assistant", reasoningID: "reasoning_1", @@ -828,13 +826,15 @@ describe("V2 mini transport", () => { let admitted = false // The generated method has conditional return types for throwOnError; this mock represents the successful branch. // @ts-expect-error successful SDK response is valid for both modes at runtime - spyOn(client.v2.session, "prompt").mockImplementation((request) => { + spyOn(client.session, "prompt").mockImplementation((request) => { const messageID = request.id ?? "msg_prompt" const prompt = request.prompt ?? { text: "" } admitted = true - return ok({ data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 } }) + return ok({ + data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 }, + }) }) - const interrupted = spyOn(client.v2.session, "interrupt").mockImplementation(() => ok(undefined)) + const interrupted = spyOn(client.session, "interrupt").mockImplementation(() => ok(undefined)) const turn = transport.runPromptTurn({ agent: undefined, @@ -848,8 +848,9 @@ describe("V2 mini transport", () => { await transport.interruptActiveTurn() events.push({ id: "evt_settled", - type: "session.next.execution.settled", - data: { timestamp: 3, sessionID: "ses_1", outcome: "interrupted" }, + created: 0, + type: "session.execution.settled", + data: { sessionID: "ses_1", outcome: "success" }, }) await turn @@ -869,25 +870,25 @@ describe("V2 mini transport", () => { limits: () => ({}), footer: ui.api, }) - // The generated method has conditional return types for throwOnError; the test only needs the nested model field. - // @ts-expect-error minimal session shape is enough for this lookup - spyOn(client.v2.session, "get").mockImplementation(() => ok({ data: { model: undefined } })) - spyOn(client.v2.model, "default").mockImplementation( + spyOn(client.session, "get").mockImplementation(() => ok({ model: undefined }) as never) + spyOn(client.model, "default").mockImplementation( () => ok({ location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, data: { id: "gpt-5", providerID: "openai" }, }) as never, ) - const switched = spyOn(client.v2.session, "switchModel").mockImplementation(() => ok(undefined)) + const switched = spyOn(client.session, "switchModel").mockImplementation(() => ok(undefined)) let admitted = false // The generated method has conditional return types for throwOnError; this mock represents the successful branch. // @ts-expect-error successful SDK response is valid for both modes at runtime - spyOn(client.v2.session, "prompt").mockImplementation((request) => { + spyOn(client.session, "prompt").mockImplementation((request) => { const messageID = request.id ?? "msg_prompt" const prompt = request.prompt ?? { text: "" } admitted = true - return ok({ data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 } }) + return ok({ + data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 }, + }) }) const turn = transport.runPromptTurn({ @@ -901,25 +902,25 @@ describe("V2 mini transport", () => { while (!admitted) await Bun.sleep(0) events.push({ id: "evt_prompted", - type: "session.next.prompted", + created: 0, + type: "session.prompt.promoted", + durable: durable("ses_1"), data: { - timestamp: 2, sessionID: "ses_1", - messageID: "msg_prompt", - prompt: { text: "hello" }, - delivery: "steer", + inputID: "msg_prompt", }, }) events.push({ id: "evt_settled", - type: "session.next.execution.settled", - data: { timestamp: 3, sessionID: "ses_1", outcome: "success" }, + created: 0, + type: "session.execution.settled", + data: { sessionID: "ses_1", outcome: "success" }, }) await turn expect(switched).toHaveBeenCalledWith( { sessionID: "ses_1", model: { providerID: "openai", id: "gpt-5", variant: "high" } }, - expect.objectContaining({ throwOnError: true }), + { signal: undefined }, ) await transport.close() }) @@ -939,13 +940,15 @@ describe("V2 mini transport", () => { let admitted = false // The generated method has conditional return types for throwOnError; this mock represents the successful branch. // @ts-expect-error successful SDK response is valid for both modes at runtime - spyOn(client.v2.session, "prompt").mockImplementation((request) => { + spyOn(client.session, "prompt").mockImplementation((request) => { const messageID = request.id ?? "msg_prompt" const prompt = request.prompt ?? { text: "" } admitted = true - return ok({ data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 } }) + return ok({ + data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 }, + }) }) - const interrupted = spyOn(client.v2.session, "interrupt").mockImplementation(() => ok(undefined)) + const interrupted = spyOn(client.session, "interrupt").mockImplementation(() => ok(undefined)) const controller = new AbortController() const turn = transport.runPromptTurn({ agent: undefined, @@ -959,21 +962,21 @@ describe("V2 mini transport", () => { while (!admitted) await Bun.sleep(0) events.push({ id: "evt_prompted", - type: "session.next.prompted", + created: 0, + type: "session.prompt.promoted", + durable: durable("ses_1"), data: { - timestamp: 2, sessionID: "ses_1", - messageID: "msg_prompt", - prompt: { text: "hello" }, - delivery: "steer", + inputID: "msg_prompt", }, }) await Bun.sleep(0) controller.abort() events.push({ id: "evt_settled", - type: "session.next.execution.settled", - data: { timestamp: 3, sessionID: "ses_1", outcome: "interrupted" }, + created: 0, + type: "session.execution.settled", + data: { sessionID: "ses_1", outcome: "success" }, }) await turn @@ -981,6 +984,705 @@ describe("V2 mini transport", () => { await transport.close() }) + test("runs a shell turn through v2.session.shell and renders live output", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + let request: Parameters[0] | undefined + spyOn(client.session, "shell").mockImplementation((input) => { + request = input + queueMicrotask(() => { + events.push({ + id: input.id ?? "evt_missing", + created: 0, + type: "session.shell.started", + durable: durable("ses_1"), + data: { + sessionID: "ses_1", + shell: { + id: "sh_shell", + status: "running", + command: "ls", + cwd: "/tmp", + shell: "/bin/sh", + file: "/tmp/opencode-shell", + metadata: {}, + time: { started: 0 }, + }, + }, + }) + events.push({ + id: "evt_shell_end", + created: 0, + type: "session.shell.ended", + durable: durable("ses_1", 1), + data: { + sessionID: "ses_1", + shell: { + id: "sh_shell", + status: "exited", + command: "ls", + cwd: "/tmp", + shell: "/bin/sh", + file: "/tmp/opencode-shell", + exit: 0, + metadata: {}, + time: { started: 0, completed: 1 }, + }, + output: { output: "file.txt", cursor: 8, size: 8, truncated: false }, + }, + }) + }) + return ok(undefined) as never + }) + + await transport.runPromptTurn({ + agent: undefined, + model: undefined, + variant: undefined, + prompt: { text: "ls", parts: [], mode: "shell" }, + files: [], + includeFiles: true, + }) + + expect(request).toMatchObject({ sessionID: "ses_1", command: "ls", id: expect.stringMatching(/^evt_/) }) + expect(ui.commits.filter((item) => item.shell)).toMatchObject([ + { phase: "start", tool: "bash", toolState: "running", shell: { callID: "sh_shell", command: "ls" } }, + { phase: "progress", text: "file.txt", toolState: "completed", shell: { callID: "sh_shell", command: "ls" } }, + ]) + expect(ui.events).toContainEqual({ type: "stream.patch", patch: { phase: "running", status: "running shell" } }) + await transport.close() + }) + + test("aborts an active shell turn without interrupting the session", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + let started = false + let aborted = false + spyOn(client.session, "shell").mockImplementation( + (_input, options) => + new Promise((_, reject) => { + started = true + options?.signal?.addEventListener("abort", () => { + aborted = true + reject(new Error("aborted")) + }) + }) as never, + ) + const interrupted = spyOn(client.session, "interrupt").mockImplementation(() => ok(undefined)) + + const turn = transport.runPromptTurn({ + agent: undefined, + model: undefined, + variant: undefined, + prompt: { text: "sleep 100", parts: [], mode: "shell" }, + files: [], + includeFiles: true, + }) + while (!started) await Bun.sleep(0) + await transport.interruptActiveTurn() + await turn + + expect(aborted).toBe(true) + expect(interrupted).not.toHaveBeenCalled() + await transport.close() + }) + + test("does not resolve an owned shell output wait from an unrelated shell", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + let request: Parameters[0] | undefined + let complete!: () => void + spyOn(client.session, "shell").mockImplementation((input) => { + request = input + return new Promise((resolve) => { + complete = resolve + }) as never + }) + + let done = false + const turn = transport + .runPromptTurn({ + agent: undefined, + model: undefined, + variant: undefined, + prompt: { text: "pwd", parts: [], mode: "shell" }, + files: [], + includeFiles: true, + }) + .then(() => { + done = true + }) + while (!request) await Bun.sleep(0) + events.push({ + id: "evt_unrelated_shell", + created: 0, + type: "session.shell.started", + durable: durable("ses_1"), + data: { + sessionID: "ses_1", + shell: { + id: "sh_unrelated", + status: "running", + command: "other", + cwd: "/tmp", + shell: "/bin/sh", + file: "/tmp/unrelated", + metadata: {}, + time: { started: 0 }, + }, + }, + }) + events.push({ + id: "evt_unrelated_end", + created: 0, + type: "session.shell.ended", + durable: durable("ses_1", 1), + data: { + sessionID: "ses_1", + shell: { + id: "sh_unrelated", + status: "exited", + command: "other", + cwd: "/tmp", + shell: "/bin/sh", + file: "/tmp/unrelated", + exit: 0, + metadata: {}, + time: { started: 0, completed: 1 }, + }, + output: { output: "wrong", cursor: 5, size: 5, truncated: false }, + }, + }) + await Bun.sleep(0) + complete() + await Bun.sleep(0) + expect(done).toBe(false) + + events.push({ + id: request.id ?? "evt_missing", + created: 0, + type: "session.shell.started", + durable: durable("ses_1", 2), + data: { + sessionID: "ses_1", + shell: { + id: "sh_owned", + status: "running", + command: "pwd", + cwd: "/tmp", + shell: "/bin/sh", + file: "/tmp/owned", + metadata: {}, + time: { started: 0 }, + }, + }, + }) + events.push({ + id: "evt_owned_end", + created: 0, + type: "session.shell.ended", + durable: durable("ses_1", 3), + data: { + sessionID: "ses_1", + shell: { + id: "sh_owned", + status: "exited", + command: "pwd", + cwd: "/tmp", + shell: "/bin/sh", + file: "/tmp/owned", + exit: 0, + metadata: {}, + time: { started: 0, completed: 1 }, + }, + output: { output: "/tmp", cursor: 4, size: 4, truncated: false }, + }, + }) + await turn + + expect(request.id).toMatch(/^evt_/) + expect(ui.commits.some((item) => item.shell?.callID === "sh_owned" && item.text === "/tmp")).toBe(true) + await transport.close() + }) + + test("hydrates projected shell transcripts once and dedupes live redelivery", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ + streams: [events], + messages: { + ses_1: [ + { + id: "msg_shell", + type: "shell" as const, + shell: { + id: "sh_1", + status: "exited", + command: "ls", + cwd: "/tmp", + shell: "/bin/sh", + file: "/tmp/opencode-shell", + exit: 0, + metadata: {}, + time: { started: 0, completed: 1 }, + }, + output: { output: "file.txt", cursor: 8, size: 8, truncated: false }, + time: { created: 1, completed: 2 }, + }, + ], + }, + }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + replay: true, + limits: () => ({}), + footer: ui.api, + }) + events.push({ + id: "evt_shell_end", + created: 0, + type: "session.shell.ended", + durable: durable("ses_1", 1), + data: { + sessionID: "ses_1", + shell: { + id: "sh_1", + status: "exited", + command: "ls", + cwd: "/tmp", + shell: "/bin/sh", + file: "/tmp/opencode-shell", + exit: 0, + metadata: {}, + time: { started: 0, completed: 1 }, + }, + output: { output: "file.txt", cursor: 8, size: 8, truncated: false }, + }, + }) + await Bun.sleep(0) + await Bun.sleep(0) + + expect(ui.commits.filter((item) => item.shell)).toMatchObject([ + { phase: "start", shell: { callID: "sh_1", command: "ls" } }, + { phase: "progress", text: "file.txt", toolState: "completed" }, + ]) + await transport.close() + }) + + test("renders failed projected shells as errors and marks truncated live output", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ + streams: [events], + messages: { + ses_1: [ + { + id: "msg_failed_shell", + type: "shell" as const, + shell: { + id: "sh_failed", + status: "exited", + command: "false", + cwd: "/tmp", + shell: "/bin/sh", + file: "/tmp/failed", + exit: 7, + metadata: {}, + time: { started: 0, completed: 1 }, + }, + output: { output: "failure output", cursor: 14, size: 14, truncated: false }, + time: { created: 1, completed: 2 }, + }, + ], + }, + }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + replay: true, + limits: () => ({}), + footer: ui.api, + }) + events.push({ + id: "evt_truncated_start", + created: 0, + type: "session.shell.started", + durable: durable("ses_1"), + data: { + sessionID: "ses_1", + shell: { + id: "sh_truncated", + status: "running", + command: "long", + cwd: "/tmp", + shell: "/bin/sh", + file: "/tmp/truncated", + metadata: {}, + time: { started: 0 }, + }, + }, + }) + events.push({ + id: "evt_truncated_end", + created: 0, + type: "session.shell.ended", + durable: durable("ses_1", 1), + data: { + sessionID: "ses_1", + shell: { + id: "sh_truncated", + status: "exited", + command: "long", + cwd: "/tmp", + shell: "/bin/sh", + file: "/tmp/truncated", + exit: 0, + metadata: {}, + time: { started: 0, completed: 1 }, + }, + output: { output: "partial", cursor: 7, size: 20, truncated: false }, + }, + }) + await Bun.sleep(0) + + expect(ui.commits).toContainEqual( + expect.objectContaining({ toolState: "error", toolError: "Shell exited with code 7" }), + ) + expect(ui.commits).toContainEqual(expect.objectContaining({ text: "partial\n[output truncated]" })) + await transport.close() + }) + + test("routes command prompts through v2.session.command", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + let request: Parameters[0] | undefined + spyOn(client.session, "command").mockImplementation((input) => { + request = input + queueMicrotask(() => { + events.push({ + id: "evt_prompted", + created: 0, + type: "session.prompt.promoted", + durable: durable("ses_1"), + data: { + sessionID: "ses_1", + inputID: "msg_cmd", + }, + }) + events.push({ + id: "evt_settled", + created: 0, + type: "session.execution.settled", + data: { sessionID: "ses_1", outcome: "success" }, + }) + }) + return ok({ + admittedSeq: 1, + id: input.id ?? "msg_cmd", + sessionID: "ses_1", + prompt: { text: "evaluated template" }, + delivery: "steer" as const, + timeCreated: 2, + }) + }) + + await transport.runPromptTurn({ + agent: "build", + model: { providerID: "test", modelID: "model" }, + variant: undefined, + prompt: { + messageID: "msg_cmd", + text: "/deploy prod", + parts: [], + command: { name: "deploy", arguments: "prod" }, + }, + files: [], + includeFiles: true, + }) + + expect(request).toMatchObject({ + sessionID: "ses_1", + id: "msg_cmd", + command: "deploy", + arguments: "prod", + agent: "build", + model: { providerID: "test", id: "model" }, + delivery: "steer", + }) + // Selection rides the command payload; no separate client-side switch. + expect(client.session.switchAgent).not.toHaveBeenCalled() + expect(client.session.switchModel).not.toHaveBeenCalled() + await transport.close() + }) + + test("routes skill prompts through v2.session.skill and settles without promotion", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + let request: Parameters[0] | undefined + const command = spyOn(client.session, "command") + const prompt = spyOn(client.session, "prompt") + spyOn(client.session, "skill").mockImplementation((input) => { + request = input + queueMicrotask(() => { + events.push({ + id: "evt_skill", + created: 0, + type: "session.skill.activated", + durable: durable("ses_1"), + data: { + sessionID: "ses_1", + name: input.skill ?? "tigerstyle", + text: "skill instructions", + }, + }) + events.push({ + id: "evt_settled", + created: 0, + type: "session.execution.settled", + data: { sessionID: "ses_1", outcome: "success" }, + }) + }) + return ok(undefined) as never + }) + + await transport.runPromptTurn({ + agent: undefined, + model: undefined, + variant: undefined, + prompt: { + messageID: "msg_skill", + text: "/tigerstyle", + parts: [], + command: { name: "tigerstyle", arguments: "", source: "skill" }, + }, + files: [], + includeFiles: true, + }) + + expect(request).toMatchObject({ sessionID: "ses_1", id: "msg_skill", skill: "tigerstyle" }) + expect(command).not.toHaveBeenCalled() + expect(prompt).not.toHaveBeenCalled() + expect(ui.commits).toContainEqual( + expect.objectContaining({ kind: "system", text: '→ Skill "tigerstyle"', messageID: "msg_skill" }), + ) + await transport.close() + }) + + test("does not resolve a skill turn before the matching activation is observed", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + let sent = false + spyOn(client.session, "skill").mockImplementation(() => { + sent = true + return ok(undefined) as never + }) + + let done = false + const turn = transport + .runPromptTurn({ + agent: undefined, + model: undefined, + variant: undefined, + prompt: { + messageID: "msg_skill", + text: "/tigerstyle", + parts: [], + command: { name: "tigerstyle", arguments: "", source: "skill" }, + }, + files: [], + includeFiles: true, + }) + .then(() => { + done = true + }) + while (!sent) await Bun.sleep(0) + events.push({ + id: "evt_other", + created: 0, + type: "session.skill.activated", + durable: durable("ses_1"), + data: { + sessionID: "ses_1", + name: "other", + text: "other instructions", + }, + }) + events.push({ + id: "evt_unrelated_settled", + created: 0, + type: "session.execution.settled", + data: { sessionID: "ses_1", outcome: "success" }, + }) + await Bun.sleep(0) + await Bun.sleep(0) + expect(done).toBe(false) + + events.push({ + id: "evt_skill", + created: 0, + type: "session.skill.activated", + durable: durable("ses_1"), + data: { + sessionID: "ses_1", + name: "tigerstyle", + text: "skill instructions", + }, + }) + events.push({ + id: "evt_skill_settled", + created: 0, + type: "session.execution.settled", + data: { sessionID: "ses_1", outcome: "success" }, + }) + await turn + + expect(done).toBe(true) + await transport.close() + }) + + test("refreshes catalogs on connection and location-scoped invalidations", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + let refreshes = 0 + const transport = await createSessionTransport({ + sdk: client, + directory: "/project", + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + onCatalogRefresh: () => refreshes++, + }) + expect(refreshes).toBe(1) + + for (const type of [ + "catalog.updated", + "integration.updated", + "agent.updated", + "command.updated", + "skill.updated", + "reference.updated", + ] as const) + events.push({ id: `evt_${type}`, created: 0, type, location: { directory: "/project" }, data: {} }) + events.push({ + id: "evt_foreign_catalog", + created: 0, + type: "catalog.updated", + location: { directory: "/other" }, + data: {}, + }) + while (refreshes < 7) await Bun.sleep(0) + await Bun.sleep(0) + + expect(refreshes).toBe(7) + await transport.close() + }) + + test("hydrates skill activation messages once and dedupes live redelivery", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ + streams: [events], + messages: { + ses_1: [ + { + id: "msg_skill", + type: "skill" as const, + name: "tigerstyle", + text: "skill instructions", + time: { created: 2 }, + }, + ], + }, + }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + replay: true, + limits: () => ({}), + footer: ui.api, + }) + events.push({ + id: "evt_skill", + created: 0, + type: "session.skill.activated", + durable: durable("ses_1"), + data: { + sessionID: "ses_1", + name: "tigerstyle", + text: "skill instructions", + }, + }) + await Bun.sleep(0) + await Bun.sleep(0) + + expect(ui.commits.filter((item) => item.text === '→ Skill "tigerstyle"')).toHaveLength(1) + await transport.close() + }) + test("discovers a live child session and tracks its tab and selected detail", async () => { const events = feed() events.push(connected()) @@ -999,20 +1701,18 @@ describe("V2 mini transport", () => { ], }, }) - spyOn(client.v2.session, "get").mockImplementation(() => + spyOn(client.session, "get").mockImplementation(() => ok({ - data: { - id: "ses_child", - parentID: "ses_1", - projectID: "proj_1", - agent: "explore", - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 1, updated: 1 }, - title: "Find files", - location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, - }, - }), + id: "ses_child", + parentID: "ses_1", + projectID: "proj_1", + agent: "explore", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + title: "Find files", + location: { directory: "/tmp" }, + }) as never, ) const ui = footer() const transport = await createSessionTransport({ @@ -1022,15 +1722,15 @@ describe("V2 mini transport", () => { limits: () => ({}), footer: ui.api, }) - const states = () => - ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : [])) + const states = () => ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : [])) transport.selectSubagent("ses_child") events.push({ id: "evt_child_step", - type: "session.next.step.started", + created: 0, + type: "session.step.started", + durable: durable("ses_child"), data: { - timestamp: 2, sessionID: "ses_child", assistantMessageID: "msg_child_a", agent: "explore", @@ -1045,9 +1745,9 @@ describe("V2 mini transport", () => { events.push({ id: "evt_child_text", - type: "session.next.text.delta", + created: 0, + type: "session.text.delta", data: { - timestamp: 3, sessionID: "ses_child", assistantMessageID: "msg_child_a", textID: "txt_child", @@ -1059,13 +1759,336 @@ describe("V2 mini transport", () => { events.push({ id: "evt_child_settled", - type: "session.next.execution.settled", - data: { timestamp: 4, sessionID: "ses_child", outcome: "success" }, + created: 0, + type: "session.execution.settled", + data: { sessionID: "ses_child", outcome: "success" }, }) while (!states().some((state) => state.tabs.some((tab) => tab.status === "completed"))) await Bun.sleep(0) await transport.close() }) + test("reveals an admitted child prompt only when it is promoted after hydration", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ + streams: [events], + messages: { ses_child: [] }, + sessions: [{ id: "ses_child", parentID: "ses_1", time: { updated: 1 } }], + }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + const states = () => ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : [])) + transport.selectSubagent("ses_child") + while (!states().some((state) => state.details.ses_child)) await Bun.sleep(0) + + events.push({ + id: "evt_child_admitted", + created: 1, + type: "session.prompt.admitted", + durable: durable("ses_child"), + data: { + sessionID: "ses_child", + inputID: "msg_child_prompt", + prompt: { text: "actual child prompt" }, + delivery: "steer", + }, + }) + await Bun.sleep(0) + expect( + states().at(-1)?.details.ses_child?.commits.some((item) => item.messageID === "msg_child_prompt"), + ).toBe(false) + + events.push({ + id: "evt_child_promoted", + created: 2, + type: "session.prompt.promoted", + durable: durable("ses_child", 1), + data: { sessionID: "ses_child", inputID: "msg_child_prompt" }, + }) + while ( + !states() + .at(-1) + ?.details.ses_child?.commits.some( + (item) => item.messageID === "msg_child_prompt" && item.text === "actual child prompt", + ) + ) + await Bun.sleep(0) + + await transport.close() + }) + + test("preserves a pre-hydration admission promoted during stale hydration", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ + streams: [events], + sessions: [{ id: "ses_child", parentID: "ses_1", time: { updated: 1 } }], + }) + let childHydrating = false + let releaseHydration!: () => void + const hydration = new Promise((resolve) => { + releaseHydration = resolve + }) + spyOn(client.message, "list").mockImplementation(async (request) => { + if (request.sessionID === "ses_child") { + childHydrating = true + await hydration + } + return ok({ data: [], cursor: {} }) + }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + const states = () => ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : [])) + events.push({ + id: "evt_child_admitted_race", + created: 1, + type: "session.prompt.admitted", + durable: durable("ses_child"), + data: { + sessionID: "ses_child", + inputID: "msg_child_race", + prompt: { text: "prompt admitted before hydration" }, + delivery: "steer", + }, + }) + await Bun.sleep(0) + transport.selectSubagent("ses_child") + while (!childHydrating) await Bun.sleep(0) + events.push({ + id: "evt_child_promoted_race", + created: 2, + type: "session.prompt.promoted", + durable: durable("ses_child", 1), + data: { sessionID: "ses_child", inputID: "msg_child_race" }, + }) + await Bun.sleep(0) + releaseHydration() + await Bun.sleep(0) + await Bun.sleep(0) + while ( + !states() + .at(-1) + ?.details.ses_child?.commits.some( + (item) => item.messageID === "msg_child_race" && item.text === "prompt admitted before hydration", + ) + ) + await Bun.sleep(0) + + await transport.close() + }) + + test("retries child hydration after a bounded live-event overflow", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ + streams: [events], + sessions: [{ id: "ses_child", parentID: "ses_1", time: { updated: 1 } }], + }) + let childRequests = 0 + let releaseStale!: () => void + let releaseRetry!: () => void + const stale = new Promise((resolve) => { + releaseStale = resolve + }) + const retry = new Promise((resolve) => { + releaseRetry = resolve + }) + spyOn(client.message, "list").mockImplementation(async (request) => { + if (request.sessionID !== "ses_child") return ok({ data: [], cursor: {} }) + childRequests++ + if (childRequests === 1) { + await stale + return ok({ data: [], cursor: {} }) + } + await retry + return ok({ + data: [ + { + id: "msg_overflow_assistant", + type: "assistant" as const, + agent: "explore", + model: { providerID: "test", id: "model" }, + content: [{ type: "text" as const, id: "txt_overflow_64", text: "live 64" }], + time: { created: 2, completed: 3 }, + }, + { + id: "msg_overflow_baseline", + type: "user" as const, + text: "baseline history", + files: [], + agents: [], + time: { created: 1 }, + }, + ], + cursor: {}, + }) + }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + const states = () => ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : [])) + transport.selectSubagent("ses_child") + while (childRequests < 1) await Bun.sleep(0) + + for (let index = 0; index < 65; index++) + events.push({ + id: `evt_overflow_${index}`, + created: index, + type: "session.text.delta", + data: { + sessionID: "ses_child", + assistantMessageID: "msg_overflow_assistant", + textID: `txt_overflow_${index}`, + delta: `live ${index}`, + }, + }) + while (!states().at(-1)?.details.ses_child?.commits.some((item) => item.text === "live 64")) await Bun.sleep(0) + releaseStale() + while (childRequests < 2) await Bun.sleep(0) + expect(states().at(-1)?.details.ses_child?.commits.some((item) => item.text === "live 64")).toBe(true) + + releaseRetry() + while (!states().at(-1)?.details.ses_child?.commits.some((item) => item.text === "baseline history")) + await Bun.sleep(0) + expect(states().at(-1)?.details.ses_child?.commits.some((item) => item.text === "live 64")).toBe(true) + expect(childRequests).toBe(2) + await transport.close() + }) + + test("reconciles pre-hydration tool metadata without downgrading projected completion", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ + streams: [events], + sessions: [{ id: "ses_child", parentID: "ses_1", time: { updated: 1 } }], + }) + let childHydrating = false + let releaseHydration!: () => void + const hydration = new Promise((resolve) => { + releaseHydration = resolve + }) + spyOn(client.message, "list").mockImplementation(async (request) => { + if (request.sessionID !== "ses_child") return ok({ data: [], cursor: {} }) + childHydrating = true + await hydration + return ok({ + data: [ + { + id: "msg_tool_projected", + type: "assistant" as const, + agent: "explore", + model: { providerID: "test", id: "model" }, + content: [ + { + type: "tool" as const, + id: "call_overlap", + name: "bash", + state: { + status: "completed" as const, + input: { command: "projected" }, + content: [{ type: "text" as const, text: "projected result" }], + structured: {}, + }, + time: { created: 1, ran: 1, completed: 2 }, + }, + ], + time: { created: 1, completed: 2 }, + }, + ], + cursor: {}, + }) + }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + const states = () => ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : [])) + const inputStarted = (callID: string, name: string, seq: number) => + events.push({ + id: `evt_started_${callID}`, + created: seq, + type: "session.tool.input.started", + durable: durable("ses_child", seq), + data: { sessionID: "ses_child", assistantMessageID: "msg_tool_projected", callID, name }, + }) + const called = (callID: string, tool: string, input: Record, seq: number) => + events.push({ + id: `evt_called_${callID}`, + created: seq, + type: "session.tool.called", + durable: durable("ses_child", seq), + data: { + sessionID: "ses_child", + assistantMessageID: "msg_tool_projected", + callID, + tool, + input, + provider: { executed: true }, + }, + }) + + inputStarted("call_terminal", "grep", 0) + called("call_terminal", "grep", { pattern: "needle" }, 1) + await Bun.sleep(0) + transport.selectSubagent("ses_child") + while (!childHydrating) await Bun.sleep(0) + events.push({ + id: "evt_success_terminal", + created: 2, + type: "session.tool.success", + durable: durable("ses_child", 2), + data: { + sessionID: "ses_child", + assistantMessageID: "msg_tool_projected", + callID: "call_terminal", + structured: {}, + content: [{ type: "text", text: "found" }], + provider: { executed: true }, + }, + }) + inputStarted("call_overlap", "bash", 3) + called("call_overlap", "bash", { command: "stale" }, 4) + await Bun.sleep(0) + const beforeHydration = states().length + releaseHydration() + while (states().length === beforeHydration) await Bun.sleep(0) + await Bun.sleep(0) + + const commits = states().at(-1)?.details.ses_child?.commits ?? [] + expect(commits.find((item) => item.partID === "prt_call_terminal")).toMatchObject({ + tool: "grep", + toolState: "completed", + part: { state: { input: { pattern: "needle" } } }, + }) + expect(commits.find((item) => item.partID === "prt_call_overlap")).toMatchObject({ + tool: "bash", + toolState: "completed", + part: { state: { input: { command: "projected" } } }, + }) + await transport.close() + }) + test("keeps child terminal state observed during discovery", async () => { const events = feed() events.push(connected()) @@ -1074,21 +2097,19 @@ describe("V2 mini transport", () => { const gate = new Promise((resolve) => { resolveGet = resolve }) - spyOn(client.v2.session, "get").mockImplementation(async () => { + spyOn(client.session, "get").mockImplementation(async () => { await gate return ok({ - data: { - id: "ses_child", - parentID: "ses_1", - projectID: "proj_1", - agent: "explore", - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 1, updated: 1 }, - title: "Find files", - location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, - }, - }) + id: "ses_child", + parentID: "ses_1", + projectID: "proj_1", + agent: "explore", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + title: "Find files", + location: { directory: "/tmp" }, + }) as never }) const ui = footer() const transport = await createSessionTransport({ @@ -1098,15 +2119,15 @@ describe("V2 mini transport", () => { limits: () => ({}), footer: ui.api, }) - const states = () => - ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : [])) + const states = () => ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : [])) // Both events arrive while session.get is still in flight. events.push({ id: "evt_child_step", - type: "session.next.step.started", + created: 0, + type: "session.step.started", + durable: durable("ses_child"), data: { - timestamp: 2, sessionID: "ses_child", assistantMessageID: "msg_child_a", agent: "explore", @@ -1115,8 +2136,9 @@ describe("V2 mini transport", () => { }) events.push({ id: "evt_child_settled", - type: "session.next.execution.settled", - data: { timestamp: 3, sessionID: "ses_child", outcome: "interrupted" }, + created: 0, + type: "session.execution.settled", + data: { sessionID: "ses_child", outcome: "interrupted" }, }) await Bun.sleep(0) resolveGet?.() @@ -1132,21 +2154,19 @@ describe("V2 mini transport", () => { const gate = new Promise((resolve) => { resolveGet = resolve }) - spyOn(client.v2.session, "get").mockImplementation(async () => { + spyOn(client.session, "get").mockImplementation(async () => { await gate return ok({ - data: { - id: "ses_child", - parentID: "ses_1", - projectID: "proj_1", - agent: "explore", - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 1, updated: 1 }, - title: "Find files", - location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, - }, - }) + id: "ses_child", + parentID: "ses_1", + projectID: "proj_1", + agent: "explore", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + title: "Find files", + location: { directory: "/tmp" }, + }) as never }) const ui = footer() const transport = await createSessionTransport({ @@ -1156,15 +2176,15 @@ describe("V2 mini transport", () => { limits: () => ({}), footer: ui.api, }) - const states = () => - ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : [])) + const states = () => ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : [])) // Child event arrives first and gets buffered behind the gated session.get. events.push({ id: "evt_child_step", - type: "session.next.step.started", + created: 0, + type: "session.step.started", + durable: durable("ses_child"), data: { - timestamp: 2, sessionID: "ses_child", assistantMessageID: "msg_child_a", agent: "explore", @@ -1174,9 +2194,10 @@ describe("V2 mini transport", () => { // Parent's background subagent tool.success adopts the child mid-discovery. events.push({ id: "evt_parent_call", - type: "session.next.tool.called", + created: 0, + type: "session.tool.called", + durable: durable("ses_1"), data: { - timestamp: 3, sessionID: "ses_1", assistantMessageID: "msg_parent_a", callID: "call_sub", @@ -1187,9 +2208,10 @@ describe("V2 mini transport", () => { }) events.push({ id: "evt_parent_success", - type: "session.next.tool.success", + created: 0, + type: "session.tool.success", + durable: durable("ses_1", 1), data: { - timestamp: 4, sessionID: "ses_1", assistantMessageID: "msg_parent_a", callID: "call_sub", @@ -1201,8 +2223,9 @@ describe("V2 mini transport", () => { // The settled event arrives after adoption, so it applies directly. events.push({ id: "evt_child_settled", - type: "session.next.execution.settled", - data: { timestamp: 5, sessionID: "ses_child", outcome: "interrupted" }, + created: 0, + type: "session.execution.settled", + data: { sessionID: "ses_child", outcome: "interrupted" }, }) while (!states().some((state) => state.tabs.some((tab) => tab.status === "cancelled"))) await Bun.sleep(0) @@ -1237,6 +2260,7 @@ describe("V2 mini transport", () => { footer: ui.api, }) const states = ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : [])) + expect(client.session.list).toHaveBeenCalledWith({ parentID: "ses_1", limit: 100, order: "desc" }) expect(states.at(-1)?.tabs).toMatchObject([ { sessionID: "ses_child_old", diff --git a/packages/opencode/test/cli/run/stream.test.ts b/packages/opencode/test/cli/run/stream.test.ts index e6b40dd925..8e02b7cf48 100644 --- a/packages/opencode/test/cli/run/stream.test.ts +++ b/packages/opencode/test/cli/run/stream.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" -import { writeSessionOutput } from "@/cli/cmd/run/stream" -import type { FooterApi, FooterEvent, StreamCommit } from "@/cli/cmd/run/types" +import { writeSessionOutput } from "@opencode-ai/cli/mini/stream" +import type { FooterApi, FooterEvent, StreamCommit } from "@opencode-ai/cli/mini/types" function footer() { const events: FooterEvent[] = [] diff --git a/packages/opencode/test/cli/run/theme.test.ts b/packages/opencode/test/cli/run/theme.test.ts index 4102dea1c9..52b8fc6439 100644 --- a/packages/opencode/test/cli/run/theme.test.ts +++ b/packages/opencode/test/cli/run/theme.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test" import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core" -import { RUN_THEME_FALLBACK, generateSystem, resolveRunTheme, resolveTheme } from "@/cli/cmd/run/theme" +import { RUN_THEME_FALLBACK, generateSystem, resolveRunTheme, resolveTheme } from "@opencode-ai/cli/mini/theme" const palette = ["#15161e", "#f7768e", "#9ece6a", "#e0af68", "#7aa2f7", "#bb9af7", "#7dcfff", "#c0caf5"] as const diff --git a/packages/opencode/test/cli/run/variant.shared.test.ts b/packages/opencode/test/cli/run/variant.shared.test.ts index e05c9888bb..70e565670f 100644 --- a/packages/opencode/test/cli/run/variant.shared.test.ts +++ b/packages/opencode/test/cli/run/variant.shared.test.ts @@ -10,9 +10,9 @@ import { formatModelLabel, pickVariant, resolveVariant, -} from "@/cli/cmd/run/variant.shared" -import type { SessionMessages } from "@/cli/cmd/run/session.shared" -import type { RunProvider } from "@/cli/cmd/run/types" +} from "@opencode-ai/cli/mini/variant.shared" +import type { SessionMessages } from "@opencode-ai/cli/mini/session.shared" +import type { RunProvider } from "@opencode-ai/cli/mini/types" import { testEffect } from "../../lib/effect" const model = { diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index a358eddb31..6d35900640 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -4,7 +4,6 @@ import fs from "fs/promises" import path from "path" import yargs from "yargs" import { tmpdir } from "../../fixture/fixture" -import { MiniLocalCommand } from "../../../src/cli/cmd/mini" import { TuiThreadCommand, resolveThreadDirectory } from "../../../src/cli/cmd/tui" import { cliIt } from "../../lib/cli-process" @@ -38,7 +37,7 @@ describe("tui thread", () => { await check(".") }) - test("resolves a relative mini project from PWD when cwd differs", async () => { + test("resolves a relative project from PWD when cwd differs", async () => { await using pwd = await tmpdir({ git: true }) await using cwd = await tmpdir({ git: true }) @@ -46,18 +45,6 @@ describe("tui thread", () => { expect(resolveThreadDirectory(undefined, pwd.path, cwd.path)).toBe(cwd.path) }) - test("parses supported mini --no-replay forms", async () => { - for (const option of ["--no-replay", "--no-replay=true", "--noReplay"]) { - const args = await yargs([]) - .command({ ...MiniLocalCommand, handler: () => {} }) - .exitProcess(false) - .parse([option, "--replay-limit", "10"]) - - expect(args.replay === false || args.noReplay === true).toBe(true) - expect(args.replayLimit).toBe(10) - } - }) - test("preserves boolean negation for existing options", async () => { const args = await yargs([]) .command({ ...TuiThreadCommand, handler: () => {} }) @@ -85,24 +72,6 @@ describe("tui thread", () => { }), ) - cliIt.live("routes local sessions through mini", ({ opencode }) => - Effect.gen(function* () { - const result = yield* opencode.spawn(["mini"]) - - opencode.expectExit(result, 1) - expect(result.stderr).toContain("opencode mini requires a TTY stdout") - }), - ) - - cliIt.live("routes attached sessions through mini attach", ({ opencode }) => - Effect.gen(function* () { - const result = yield* opencode.spawn(["mini", "attach", "http://127.0.0.1:1"]) - - opencode.expectExit(result, 1) - expect(result.stderr).toContain("opencode mini requires a TTY stdout") - }), - ) - cliIt.live("rejects removed attach mini alias", ({ opencode }) => Effect.gen(function* () { const result = yield* opencode.spawn(["attach", "http://127.0.0.1:1", "--mini"]) diff --git a/packages/opencode/test/lib/llm-server.ts b/packages/opencode/test/lib/llm-server.ts index 245acc7280..aa16fdb9a6 100644 --- a/packages/opencode/test/lib/llm-server.ts +++ b/packages/opencode/test/lib/llm-server.ts @@ -606,7 +606,8 @@ function hit(url: string, body: unknown) { function isTitleRequest(body: unknown): boolean { if (!body || typeof body !== "object") return false - return JSON.stringify(body).includes("Generate a title for this conversation") + const value = JSON.stringify(body) + return value.includes("Generate a title for this conversation") || value.includes("You are a title generator") } namespace TestLLMServer { diff --git a/packages/opencode/test/mcp/catalog.test.ts b/packages/opencode/test/mcp/catalog.test.ts index 55cabaef76..7b0d6403bb 100644 --- a/packages/opencode/test/mcp/catalog.test.ts +++ b/packages/opencode/test/mcp/catalog.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "bun:test" -import type { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { McpCatalog } from "@/mcp/catalog" +import { Effect } from "effect" const options = { toolCallId: "call_mcp", abortSignal: new AbortController().signal } as any @@ -45,3 +49,59 @@ describe("McpCatalog.convertTool", () => { }) }) }) + +test("preserves output schema validation across paginated tool discovery", async () => { + const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } }) + server.setRequestHandler(ListToolsRequestSchema, ({ params }) => + Promise.resolve( + params?.cursor === "page-2" + ? { + tools: [ + { + name: "second", + inputSchema: { type: "object" }, + outputSchema: { + type: "object", + properties: { value: { type: "number" } }, + required: ["value"], + }, + }, + ], + } + : { + tools: [ + { + name: "first", + inputSchema: { type: "object" }, + outputSchema: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + }, + }, + ], + nextCursor: "page-2", + }, + ), + ) + server.setRequestHandler(CallToolRequestSchema, ({ params }) => + Promise.resolve({ + content: [], + structuredContent: { value: params.name === "first" ? 42 : 1 }, + }), + ) + + const client = new Client({ name: "pagination-test", version: "1.0.0" }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]) + + try { + const tools = await Effect.runPromise(McpCatalog.defs(client)) + expect(tools?.map((tool) => tool.name)).toEqual(["first", "second"]) + await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow( + "Structured content does not match the tool's output schema", + ) + } finally { + await Promise.all([client.close(), server.close()]) + } +}) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 804b92b08e..5e425e7f73 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -76,6 +76,7 @@ function projectV2FailureLayer() { return Layer.succeed( ProjectV2.Service, ProjectV2.Service.of({ + list: () => Effect.succeed([]), directories: () => Effect.succeed([]), resolve: (input) => Effect.succeed({ diff --git a/packages/opencode/test/server/httpapi-pty.test.ts b/packages/opencode/test/server/httpapi-pty.test.ts index 3eec0c9682..ac18a8a2cf 100644 --- a/packages/opencode/test/server/httpapi-pty.test.ts +++ b/packages/opencode/test/server/httpapi-pty.test.ts @@ -99,10 +99,10 @@ describe("pty HttpApi bridge", () => { const updated = await app().request(PtyPaths.update.replace(":ptyID", info.id), { method: "PUT", headers: { ...headers, "content-type": "application/json" }, - body: JSON.stringify({ title: "renamed", size: { cols: 80, rows: 24 } }), + body: JSON.stringify({ title: "session.renamed", size: { cols: 80, rows: 24 } }), }) expect(updated.status).toBe(200) - expect(await updated.json()).toMatchObject({ id: info.id, title: "renamed" }) + expect(await updated.json()).toMatchObject({ id: info.id, title: "session.renamed" }) } finally { await app().request(PtyPaths.remove.replace(":ptyID", info.id), { method: "DELETE", headers }) } diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index c81b3b771b..0af92ae382 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -573,7 +573,7 @@ describe("HttpApi SDK", () => { const child = yield* capture(() => sdk.session.create({ title: "child", parentID })) const childID = String(record(child.data).id) const get = yield* capture(() => sdk.session.get({ sessionID: parentID })) - const update = yield* capture(() => sdk.session.update({ sessionID: parentID, title: "renamed" })) + const update = yield* capture(() => sdk.session.update({ sessionID: parentID, title: "session.renamed" })) const roots = yield* capture(() => sdk.session.list({ roots: true, limit: 10 })) const all = yield* capture(() => sdk.session.list({ roots: false, limit: 10 })) const children = yield* capture(() => sdk.session.children({ sessionID: parentID })) diff --git a/packages/opencode/test/server/httpapi-v2-location.test.ts b/packages/opencode/test/server/httpapi-v2-location.test.ts index cf009812b8..1de6007b79 100644 --- a/packages/opencode/test/server/httpapi-v2-location.test.ts +++ b/packages/opencode/test/server/httpapi-v2-location.test.ts @@ -82,7 +82,7 @@ describe("v2 location HttpApi", () => { expect( Schema.decodeUnknownSync(Event)({ id: "evt_test", - type: "file.watcher.updated", + type: "filesystem.changed", location: { directory: "/tmp/project" }, data: {}, }), diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index e394d8084f..57febfa06e 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -271,7 +271,7 @@ describe("tool.apply_patch freeform", () => { yield* execute({ patchText }, ctx) - const moved = path.join(test.directory, "renamed", "dir", "name.txt") + const moved = path.join(test.directory, "session.renamed", "dir", "name.txt") yield* expectReadFailure(original) expect(yield* readText(moved)).toBe("new content\n") }), @@ -282,7 +282,7 @@ describe("tool.apply_patch freeform", () => { const test = yield* TestInstance const { ctx } = makeCtx() const original = path.join(test.directory, "old", "name.txt") - const destination = path.join(test.directory, "renamed", "dir", "name.txt") + const destination = path.join(test.directory, "session.renamed", "dir", "name.txt") yield* makeDir(path.dirname(original)) yield* makeDir(path.dirname(destination)) yield* writeText(original, "from\n") diff --git a/packages/opencode/test/tool/fixtures/models-api.json b/packages/opencode/test/tool/fixtures/models-api.json index 6302a951dd..9432ee6635 100644 --- a/packages/opencode/test/tool/fixtures/models-api.json +++ b/packages/opencode/test/tool/fixtures/models-api.json @@ -79593,8 +79593,8 @@ } } }, - "synthetic": { - "id": "synthetic", + "session.synthetic": { + "id": "session.synthetic", "env": ["SYNTHETIC_API_KEY"], "npm": "@ai-sdk/openai-compatible", "api": "https://api.synthetic.new/openai/v1", diff --git a/packages/opencode/test/v2/session-message-updater.test.ts b/packages/opencode/test/v2/session-message-updater.test.ts index 668a353f67..1fc3dda0df 100644 --- a/packages/opencode/test/v2/session-message-updater.test.ts +++ b/packages/opencode/test/v2/session-message-updater.test.ts @@ -9,6 +9,10 @@ import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater" import { SessionMessage } from "@opencode-ai/core/session/message" +function durable(sessionID: SessionID, seq = 0, version = 1) { + return { aggregateID: sessionID, seq: EventV2.Seq.make(seq), version: EventV2.Version.make(version) } +} + test.skip("step snapshots carry over to assistant messages", () => { const state: SessionMessageUpdater.MemoryState = { messages: [] } const sessionID = SessionID.make("session") @@ -17,11 +21,12 @@ test.skip("step snapshots carry over to assistant messages", () => { Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), - type: "session.next.step.started", + created: DateTime.makeUnsafe(0), + type: "session.step.started", + durable: durable(sessionID), data: { sessionID, assistantMessageID, - timestamp: DateTime.makeUnsafe(1), agent: "build", model: { id: ModelV2.ID.make("model"), @@ -38,11 +43,12 @@ test.skip("step snapshots carry over to assistant messages", () => { Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), - type: "session.next.step.ended", + created: DateTime.makeUnsafe(0), + type: "session.step.ended", + durable: durable(sessionID, 1, 2), data: { sessionID, assistantMessageID, - timestamp: DateTime.makeUnsafe(2), finish: "stop", cost: 0, tokens: { @@ -70,11 +76,12 @@ test.skip("text ended populates assistant text content", () => { Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), - type: "session.next.step.started", + created: DateTime.makeUnsafe(0), + type: "session.step.started", + durable: durable(sessionID), data: { sessionID, assistantMessageID, - timestamp: DateTime.makeUnsafe(1), agent: "build", model: { id: ModelV2.ID.make("model"), @@ -88,11 +95,12 @@ test.skip("text ended populates assistant text content", () => { Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), - type: "session.next.text.started", + created: DateTime.makeUnsafe(0), + type: "session.text.started", + durable: durable(sessionID, 1), data: { sessionID, assistantMessageID, - timestamp: DateTime.makeUnsafe(2), textID: "text-1", }, } satisfies SessionEvent.Event), @@ -101,11 +109,12 @@ test.skip("text ended populates assistant text content", () => { Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), - type: "session.next.text.ended", + created: DateTime.makeUnsafe(0), + type: "session.text.ended", + durable: durable(sessionID, 2), data: { sessionID, assistantMessageID, - timestamp: DateTime.makeUnsafe(3), textID: "text-1", text: "hello assistant", }, @@ -126,11 +135,12 @@ test.skip("tool completion stores completed timestamp", () => { Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), - type: "session.next.step.started", + created: DateTime.makeUnsafe(0), + type: "session.step.started", + durable: durable(sessionID), data: { sessionID, assistantMessageID, - timestamp: DateTime.makeUnsafe(1), agent: "build", model: { id: ModelV2.ID.make("model"), @@ -144,11 +154,12 @@ test.skip("tool completion stores completed timestamp", () => { Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), - type: "session.next.tool.input.started", + created: DateTime.makeUnsafe(0), + type: "session.tool.input.started", + durable: durable(sessionID, 1), data: { sessionID, assistantMessageID, - timestamp: DateTime.makeUnsafe(2), callID, name: "bash", }, @@ -158,11 +169,12 @@ test.skip("tool completion stores completed timestamp", () => { Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), - type: "session.next.tool.called", + created: DateTime.makeUnsafe(0), + type: "session.tool.called", + durable: durable(sessionID, 2), data: { sessionID, assistantMessageID, - timestamp: DateTime.makeUnsafe(3), callID, tool: "bash", input: { command: "pwd" }, @@ -174,11 +186,12 @@ test.skip("tool completion stores completed timestamp", () => { Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), - type: "session.next.tool.success", + created: DateTime.makeUnsafe(0), + type: "session.tool.success", + durable: durable(sessionID, 3), data: { sessionID, assistantMessageID, - timestamp: DateTime.makeUnsafe(4), callID, structured: {}, content: [{ type: "text", text: "/tmp" }], @@ -199,16 +212,16 @@ test("compaction events reduce to compaction message only when completed", () => const state: SessionMessageUpdater.MemoryState = { messages: [] } const sessionID = SessionID.make("session") const id = EventV2.ID.create() - const compactionID = SessionMessage.ID.create() + const endedID = EventV2.ID.create() Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id, - type: "session.next.compaction.started", + created: DateTime.makeUnsafe(0), + type: "session.compaction.started", + durable: durable(sessionID), data: { sessionID, - messageID: compactionID, - timestamp: DateTime.makeUnsafe(1), reason: "auto", }, } satisfies SessionEvent.Event), @@ -219,11 +232,10 @@ test("compaction events reduce to compaction message only when completed", () => Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), - type: "session.next.compaction.delta", + created: DateTime.makeUnsafe(0), + type: "session.compaction.delta", data: { sessionID, - messageID: compactionID, - timestamp: DateTime.makeUnsafe(2), text: "hello ", }, } satisfies SessionEvent.Event), @@ -232,11 +244,10 @@ test("compaction events reduce to compaction message only when completed", () => Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { id: EventV2.ID.create(), - type: "session.next.compaction.delta", + created: DateTime.makeUnsafe(0), + type: "session.compaction.delta", data: { sessionID, - messageID: compactionID, - timestamp: DateTime.makeUnsafe(3), text: "summary", }, } satisfies SessionEvent.Event), @@ -244,12 +255,12 @@ test("compaction events reduce to compaction message only when completed", () => Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { - id: EventV2.ID.create(), - type: "session.next.compaction.ended", + id: endedID, + created: DateTime.makeUnsafe(0), + type: "session.compaction.ended", + durable: durable(sessionID, 1), data: { sessionID, - messageID: compactionID, - timestamp: DateTime.makeUnsafe(4), reason: "auto", text: "final summary", recent: "recent context", @@ -259,11 +270,11 @@ test("compaction events reduce to compaction message only when completed", () => expect(state.messages).toHaveLength(1) expect(state.messages[0]).toMatchObject({ - id: compactionID, + id: SessionMessage.ID.fromEvent(endedID), type: "compaction", reason: "auto", summary: "final summary", recent: "recent context", - time: { created: DateTime.makeUnsafe(4) }, + time: { created: DateTime.makeUnsafe(0) }, }) }) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index e5f9c43dfc..d97f655253 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -23,6 +23,7 @@ ], "dependencies": { "@ai-sdk/provider": "3.0.8", + "@opencode-ai/client": "workspace:*", "@opencode-ai/llm": "workspace:*", "@opencode-ai/protocol": "workspace:*", "@opencode-ai/schema": "workspace:*", diff --git a/packages/plugin/src/v2/effect/agent.ts b/packages/plugin/src/v2/effect/agent.ts index 3c9e6d4210..ce56cbe777 100644 --- a/packages/plugin/src/v2/effect/agent.ts +++ b/packages/plugin/src/v2/effect/agent.ts @@ -1,6 +1,7 @@ import type { AgentV2Info } from "@opencode-ai/sdk/v2/types" -import type { AgentApi } from "./generated/api.js" -import type { Hooks } from "./registration.js" +import type { AgentApi } from "@opencode-ai/client/effect/api" +import type { Effect } from "effect" +import type { TransformHook } from "./registration.js" export interface AgentDraft { list(): readonly AgentV2Info[] @@ -10,9 +11,7 @@ export interface AgentDraft { remove(id: string): void } -export type AgentHooks = Hooks<{ - transform: AgentDraft -}> - -export type AgentPluginApi = AgentHooks -export type AgentDomain = AgentApi & AgentPluginApi +export interface AgentHooks extends AgentApi { + readonly transform: TransformHook + readonly reload: () => Effect.Effect +} diff --git a/packages/plugin/src/v2/effect/catalog.ts b/packages/plugin/src/v2/effect/catalog.ts index 704ef16bf6..4cc5e55227 100644 --- a/packages/plugin/src/v2/effect/catalog.ts +++ b/packages/plugin/src/v2/effect/catalog.ts @@ -1,5 +1,7 @@ import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types" -import type { Hooks } from "./registration.js" +import type { CatalogApi } from "@opencode-ai/client/effect/api" +import type { Effect } from "effect" +import type { TransformHook } from "./registration.js" export interface CatalogProviderRecord { readonly provider: ProviderV2Info @@ -24,6 +26,7 @@ export interface CatalogDraft { } } -export type CatalogHooks = Hooks<{ - transform: CatalogDraft -}> +export interface CatalogHooks extends CatalogApi { + readonly transform: TransformHook + readonly reload: () => Effect.Effect +} diff --git a/packages/plugin/src/v2/effect/command.ts b/packages/plugin/src/v2/effect/command.ts index 0afd4bafe0..2faaa91fa9 100644 --- a/packages/plugin/src/v2/effect/command.ts +++ b/packages/plugin/src/v2/effect/command.ts @@ -1,5 +1,7 @@ import type { CommandV2Info } from "@opencode-ai/sdk/v2/types" -import type { Hooks } from "./registration.js" +import type { CommandApi } from "@opencode-ai/client/effect/api" +import type { Effect } from "effect" +import type { TransformHook } from "./registration.js" export interface CommandDraft { list(): readonly CommandV2Info[] @@ -8,6 +10,7 @@ export interface CommandDraft { remove(name: string): void } -export type CommandHooks = Hooks<{ - transform: CommandDraft -}> +export interface CommandHooks extends CommandApi { + readonly transform: TransformHook + readonly reload: () => Effect.Effect +} diff --git a/packages/plugin/src/v2/effect/context.ts b/packages/plugin/src/v2/effect/context.ts index 7ab5c3c4fe..3026c0b930 100644 --- a/packages/plugin/src/v2/effect/context.ts +++ b/packages/plugin/src/v2/effect/context.ts @@ -1,28 +1,29 @@ import type { PluginOptions } from "../options.js" -import type { AgentDomain } from "./agent.js" +import type { AgentHooks } from "./agent.js" import type { AISDKHooks } from "./aisdk.js" import type { CatalogHooks } from "./catalog.js" import type { CommandHooks } from "./command.js" +import type { EventHooks } from "./event.js" import type { IntegrationHooks } from "./integration.js" import type { PluginDomain } from "./plugin.js" import type { ReferenceHooks } from "./reference.js" import type { SkillHooks } from "./skill.js" -import type { Reload } from "./registration.js" import type { ToolDomain } from "./tool.js" -import type { SessionDomain } from "./runtime.js" +import type { SessionHooks } from "./runtime.js" import type { VcsDomain } from "./vcs.js" export interface PluginContext { readonly options: PluginOptions - readonly agent: AgentDomain & Reload + readonly agent: AgentHooks readonly aisdk: AISDKHooks - readonly catalog: CatalogHooks & Reload - readonly command: CommandHooks & Reload - readonly integration: IntegrationHooks & Reload + readonly catalog: CatalogHooks + readonly command: CommandHooks + readonly event: EventHooks + readonly integration: IntegrationHooks readonly plugin: PluginDomain - readonly reference: ReferenceHooks & Reload - readonly skill: SkillHooks & Reload + readonly reference: ReferenceHooks + readonly skill: SkillHooks readonly tool: ToolDomain - readonly session: SessionDomain + readonly session: SessionHooks readonly vcs: VcsDomain } diff --git a/packages/plugin/src/v2/effect/event.ts b/packages/plugin/src/v2/effect/event.ts index e6ea7cf0ce..49ad375d67 100644 --- a/packages/plugin/src/v2/effect/event.ts +++ b/packages/plugin/src/v2/effect/event.ts @@ -1,10 +1,3 @@ -import type { Event as SDKEvent } from "@opencode-ai/sdk/v2/types" -import type { Stream } from "effect" +import type { EventApi } from "@opencode-ai/client/effect/api" -export type EventMap = { - [Item in SDKEvent as Item["type"]]: Item -} - -export interface Event { - subscribe(type: Type): Stream.Stream -} +export interface EventHooks extends Pick, "subscribe"> {} diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts index affa0d0741..9fbaf14053 100644 --- a/packages/plugin/src/v2/effect/index.ts +++ b/packages/plugin/src/v2/effect/index.ts @@ -1,8 +1,16 @@ export type { PluginContext } from "./context.js" export { define } from "./plugin.js" -export type { Plugin } from "./plugin.js" +export type { Plugin, PluginDomain } from "./plugin.js" +export type { AgentDraft, AgentHooks } from "./agent.js" +export type { AISDKHooks } from "./aisdk.js" +export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js" +export type { CommandDraft, CommandHooks } from "./command.js" +export type { EventHooks } from "./event.js" +export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js" +export type { ReferenceDraft, ReferenceHooks } from "./reference.js" +export type { SkillDraft, SkillHooks } from "./skill.js" export * as Tool from "./tool.js" export type { ToolDomain, ToolExecuteBeforeEvent, ToolExecuteAfterEvent } from "./tool.js" -export type { SessionDomain } from "./runtime.js" +export type { SessionHooks } from "./runtime.js" export { Vcs } from "./vcs.js" export type { VcsDomain } from "./vcs.js" diff --git a/packages/plugin/src/v2/effect/integration.ts b/packages/plugin/src/v2/effect/integration.ts index 64052b1506..1433786634 100644 --- a/packages/plugin/src/v2/effect/integration.ts +++ b/packages/plugin/src/v2/effect/integration.ts @@ -9,8 +9,9 @@ import type { IntegrationOAuthMethod, IntegrationRef, } from "@opencode-ai/sdk/v2/types" +import type { IntegrationApi } from "@opencode-ai/client/effect/api" import type { Effect, Scope } from "effect" -import type { Hooks } from "./registration.js" +import type { TransformHook } from "./registration.js" export type IntegrationOAuthAuthorization = { readonly url: string @@ -55,7 +56,9 @@ export interface IntegrationDraft { } } -export interface IntegrationHooks extends Hooks<{ transform: IntegrationDraft }> { +export interface IntegrationHooks extends IntegrationApi { + readonly transform: TransformHook + readonly reload: () => Effect.Effect readonly connection: { readonly active: (integrationID: string) => Effect.Effect readonly resolve: (connection: ConnectionInfo) => Effect.Effect diff --git a/packages/plugin/src/v2/effect/plugin.ts b/packages/plugin/src/v2/effect/plugin.ts index 54224fa831..66f57caf60 100644 --- a/packages/plugin/src/v2/effect/plugin.ts +++ b/packages/plugin/src/v2/effect/plugin.ts @@ -1,3 +1,4 @@ +import type { PluginApi } from "@opencode-ai/client/effect/api" import type { Effect, Scope } from "effect" import type { PluginContext } from "./context.js" @@ -10,7 +11,4 @@ export function define(plugin: Plugin) { return plugin } -export interface PluginDomain { - readonly add: (plugin: Plugin) => Effect.Effect - readonly remove: (id: string) => Effect.Effect -} +export interface PluginDomain extends PluginApi {} diff --git a/packages/plugin/src/v2/effect/reference.ts b/packages/plugin/src/v2/effect/reference.ts index c1e2630476..1216d3c0b8 100644 --- a/packages/plugin/src/v2/effect/reference.ts +++ b/packages/plugin/src/v2/effect/reference.ts @@ -1,5 +1,7 @@ import type { ReferenceGitSource, ReferenceLocalSource } from "@opencode-ai/sdk/v2/types" -import type { Hooks } from "./registration.js" +import type { ReferenceApi } from "@opencode-ai/client/effect/api" +import type { Effect } from "effect" +import type { TransformHook } from "./registration.js" export interface ReferenceDraft { add(name: string, source: ReferenceLocalSource | ReferenceGitSource): void @@ -7,6 +9,7 @@ export interface ReferenceDraft { list(): readonly (readonly [string, ReferenceLocalSource | ReferenceGitSource])[] } -export type ReferenceHooks = Hooks<{ - transform: ReferenceDraft -}> +export interface ReferenceHooks extends ReferenceApi { + readonly transform: TransformHook + readonly reload: () => Effect.Effect +} diff --git a/packages/plugin/src/v2/effect/registration.ts b/packages/plugin/src/v2/effect/registration.ts index dfe5626397..1ddb9e3489 100644 --- a/packages/plugin/src/v2/effect/registration.ts +++ b/packages/plugin/src/v2/effect/registration.ts @@ -4,12 +4,10 @@ export interface Registration { readonly dispose: Effect.Effect } -export interface Reload { - readonly reload: () => Effect.Effect -} - export type Hooks = { readonly [Name in keyof Spec]: ( callback: (input: Spec[Name]) => Effect.Effect | void, ) => Effect.Effect } + +export type TransformHook = (callback: (input: Input) => void) => Effect.Effect diff --git a/packages/plugin/src/v2/effect/runtime.ts b/packages/plugin/src/v2/effect/runtime.ts index ca2074a977..0358330210 100644 --- a/packages/plugin/src/v2/effect/runtime.ts +++ b/packages/plugin/src/v2/effect/runtime.ts @@ -1,3 +1,4 @@ -import type { SessionApi } from "./generated/api.js" +import type { SessionApi } from "@opencode-ai/client/effect/api" -export type SessionDomain = Pick, "create" | "get" | "prompt" | "command" | "interrupt"> +export interface SessionHooks + extends Pick, "create" | "get" | "prompt" | "command" | "interrupt"> {} diff --git a/packages/plugin/src/v2/effect/skill.ts b/packages/plugin/src/v2/effect/skill.ts index cac658a083..b01b81bc3b 100644 --- a/packages/plugin/src/v2/effect/skill.ts +++ b/packages/plugin/src/v2/effect/skill.ts @@ -1,11 +1,14 @@ import type { SkillV2Source } from "@opencode-ai/sdk/v2/types" -import type { Hooks } from "./registration.js" +import type { SkillApi } from "@opencode-ai/client/effect/api" +import type { Effect } from "effect" +import type { TransformHook } from "./registration.js" export interface SkillDraft { source(source: SkillV2Source): void list(): readonly SkillV2Source[] } -export type SkillHooks = Hooks<{ - transform: SkillDraft -}> +export interface SkillHooks extends SkillApi { + readonly transform: TransformHook + readonly reload: () => Effect.Effect +} diff --git a/packages/plugin/src/v2/effect/tool.ts b/packages/plugin/src/v2/effect/tool.ts index c25f79c58b..98efaa3ce2 100644 --- a/packages/plugin/src/v2/effect/tool.ts +++ b/packages/plugin/src/v2/effect/tool.ts @@ -186,8 +186,17 @@ export const validateName = (name: string) => ? Effect.void : Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` })) -export const registrationEntries = (tools: Readonly>) => - Object.entries(tools).map(([name, tool]) => [name.replace(/[^a-zA-Z0-9_-]/g, "_"), tool] as const) +export const registrationEntries = (tools: Readonly>, group?: string) => + Object.entries(tools).map(([name, tool]) => { + const normalized = name.replace(/[^a-zA-Z0-9_-]/g, "_") + const parent = group?.replace(/[^a-zA-Z0-9_-]/g, "_") + return { + key: parent === undefined ? normalized : `${parent}_${normalized}`, + name: normalized, + group: parent, + tool, + } + }) export const withPermission = , Output extends SchemaType>( tool: Definition, @@ -235,7 +244,15 @@ export interface ToolExecuteAfterEvent { outputPaths?: ReadonlyArray } +export interface RegisterOptions { + readonly group?: string + readonly deferred?: boolean +} + export interface ToolDomain { - readonly register: (tools: Readonly>) => Effect.Effect + readonly register: ( + tools: Readonly>, + options?: RegisterOptions, + ) => Effect.Effect readonly execute: Hooks<{ before: ToolExecuteBeforeEvent; after: ToolExecuteAfterEvent }> } diff --git a/packages/plugin/src/v2/promise/agent.ts b/packages/plugin/src/v2/promise/agent.ts index bec589146a..d9313838cb 100644 --- a/packages/plugin/src/v2/promise/agent.ts +++ b/packages/plugin/src/v2/promise/agent.ts @@ -1,8 +1,10 @@ +import type { AgentApi } from "@opencode-ai/client/promise/api" import type { AgentDraft } from "../effect/agent.js" -import type { Hooks } from "./registration.js" +import type { TransformHook } from "./registration.js" export type { AgentDraft } -export type AgentHooks = Hooks<{ - transform: AgentDraft -}> +export interface AgentHooks extends AgentApi { + readonly transform: TransformHook + readonly reload: () => Promise +} diff --git a/packages/plugin/src/v2/promise/catalog.ts b/packages/plugin/src/v2/promise/catalog.ts index 70842e94b7..f6b0f649f8 100644 --- a/packages/plugin/src/v2/promise/catalog.ts +++ b/packages/plugin/src/v2/promise/catalog.ts @@ -1,8 +1,10 @@ +import type { CatalogApi } from "@opencode-ai/client/promise/api" import type { CatalogDraft, CatalogProviderRecord } from "../effect/catalog.js" -import type { Hooks } from "./registration.js" +import type { TransformHook } from "./registration.js" export type { CatalogDraft, CatalogProviderRecord } -export type CatalogHooks = Hooks<{ - transform: CatalogDraft -}> +export interface CatalogHooks extends CatalogApi { + readonly transform: TransformHook + readonly reload: () => Promise +} diff --git a/packages/plugin/src/v2/promise/command.ts b/packages/plugin/src/v2/promise/command.ts index cdc5f8268a..d042259c47 100644 --- a/packages/plugin/src/v2/promise/command.ts +++ b/packages/plugin/src/v2/promise/command.ts @@ -1,8 +1,10 @@ +import type { CommandApi } from "@opencode-ai/client/promise/api" import type { CommandDraft } from "../effect/command.js" -import type { Hooks } from "./registration.js" +import type { TransformHook } from "./registration.js" export type { CommandDraft } -export type CommandHooks = Hooks<{ - transform: CommandDraft -}> +export interface CommandHooks extends CommandApi { + readonly transform: TransformHook + readonly reload: () => Promise +} diff --git a/packages/plugin/src/v2/promise/context.ts b/packages/plugin/src/v2/promise/context.ts index 9089334ee3..5e67e44961 100644 --- a/packages/plugin/src/v2/promise/context.ts +++ b/packages/plugin/src/v2/promise/context.ts @@ -3,20 +3,23 @@ import type { AgentHooks } from "./agent.js" import type { AISDKHooks } from "./aisdk.js" import type { CatalogHooks } from "./catalog.js" import type { CommandHooks } from "./command.js" +import type { EventHooks } from "./event.js" import type { IntegrationHooks } from "./integration.js" import type { PluginDomain } from "./plugin.js" import type { ReferenceHooks } from "./reference.js" +import type { SessionHooks } from "./runtime.js" import type { SkillHooks } from "./skill.js" -import type { Reload } from "./registration.js" export interface PluginContext { readonly options: PluginOptions - readonly agent: AgentHooks & Reload + readonly agent: AgentHooks readonly aisdk: AISDKHooks - readonly catalog: CatalogHooks & Reload - readonly command: CommandHooks & Reload - readonly integration: IntegrationHooks & Reload + readonly catalog: CatalogHooks + readonly command: CommandHooks + readonly event: EventHooks + readonly integration: IntegrationHooks readonly plugin: PluginDomain - readonly reference: ReferenceHooks & Reload - readonly skill: SkillHooks & Reload + readonly reference: ReferenceHooks + readonly session: SessionHooks + readonly skill: SkillHooks } diff --git a/packages/plugin/src/v2/promise/event.ts b/packages/plugin/src/v2/promise/event.ts new file mode 100644 index 0000000000..5330f70c7c --- /dev/null +++ b/packages/plugin/src/v2/promise/event.ts @@ -0,0 +1,3 @@ +import type { EventApi } from "@opencode-ai/client/promise/api" + +export interface EventHooks extends Pick {} diff --git a/packages/plugin/src/v2/promise/index.ts b/packages/plugin/src/v2/promise/index.ts index 8287955657..594ff7da3c 100644 --- a/packages/plugin/src/v2/promise/index.ts +++ b/packages/plugin/src/v2/promise/index.ts @@ -2,11 +2,12 @@ export type { PluginContext } from "./context.js" export type { PluginOptions } from "../options.js" export { define } from "./plugin.js" export type { Plugin, PluginDomain } from "./plugin.js" -export type { Registration, Reload } from "./registration.js" export type { AgentDraft, AgentHooks } from "./agent.js" export type { AISDKHooks } from "./aisdk.js" export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js" export type { CommandDraft, CommandHooks } from "./command.js" +export type { EventHooks } from "./event.js" export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js" export type { ReferenceDraft, ReferenceHooks } from "./reference.js" +export type { SessionHooks } from "./runtime.js" export type { SkillDraft, SkillHooks } from "./skill.js" diff --git a/packages/plugin/src/v2/promise/integration.ts b/packages/plugin/src/v2/promise/integration.ts index 6a798a0291..bd133e889e 100644 --- a/packages/plugin/src/v2/promise/integration.ts +++ b/packages/plugin/src/v2/promise/integration.ts @@ -1,10 +1,13 @@ +import type { IntegrationApi } from "@opencode-ai/client/promise/api" import type { IntegrationDraft, IntegrationMethodRegistration } from "../effect/integration.js" import type { CredentialValue } from "@opencode-ai/sdk/v2/types" -import type { Hooks } from "./registration.js" +import type { TransformHook } from "./registration.js" export type { IntegrationDraft, IntegrationMethodRegistration } -export interface IntegrationHooks extends Hooks<{ transform: IntegrationDraft }> { +export interface IntegrationHooks extends IntegrationApi { + readonly transform: TransformHook + readonly reload: () => Promise readonly connection: { readonly active: (integrationID: string) => Promise readonly resolve: ( diff --git a/packages/plugin/src/v2/promise/plugin.ts b/packages/plugin/src/v2/promise/plugin.ts index ab59fb95fc..eb91b9df53 100644 --- a/packages/plugin/src/v2/promise/plugin.ts +++ b/packages/plugin/src/v2/promise/plugin.ts @@ -1,3 +1,4 @@ +import type { PluginApi } from "@opencode-ai/client/promise/api" import type { PluginContext } from "./context.js" export interface Plugin { @@ -9,7 +10,4 @@ export function define(plugin: Plugin) { return plugin } -export interface PluginDomain { - readonly add: (plugin: Plugin) => Promise - readonly remove: (id: string) => Promise -} +export interface PluginDomain extends PluginApi {} diff --git a/packages/plugin/src/v2/promise/reference.ts b/packages/plugin/src/v2/promise/reference.ts index f4b7f8b839..66bd5b4874 100644 --- a/packages/plugin/src/v2/promise/reference.ts +++ b/packages/plugin/src/v2/promise/reference.ts @@ -1,8 +1,10 @@ +import type { ReferenceApi } from "@opencode-ai/client/promise/api" import type { ReferenceDraft } from "../effect/reference.js" -import type { Hooks } from "./registration.js" +import type { TransformHook } from "./registration.js" export type { ReferenceDraft } -export type ReferenceHooks = Hooks<{ - transform: ReferenceDraft -}> +export interface ReferenceHooks extends ReferenceApi { + readonly transform: TransformHook + readonly reload: () => Promise +} diff --git a/packages/plugin/src/v2/promise/registration.ts b/packages/plugin/src/v2/promise/registration.ts index 5e0ae7f480..76be0982b8 100644 --- a/packages/plugin/src/v2/promise/registration.ts +++ b/packages/plugin/src/v2/promise/registration.ts @@ -2,10 +2,8 @@ export interface Registration { readonly dispose: () => Promise } -export interface Reload { - readonly reload: () => Promise -} - export type Hooks = { readonly [Name in keyof Spec]: (callback: (input: Spec[Name]) => Promise | void) => Promise } + +export type TransformHook = (callback: (input: Input) => void) => Promise diff --git a/packages/plugin/src/v2/promise/runtime.ts b/packages/plugin/src/v2/promise/runtime.ts new file mode 100644 index 0000000000..b89b6e8abf --- /dev/null +++ b/packages/plugin/src/v2/promise/runtime.ts @@ -0,0 +1,3 @@ +import type { SessionApi } from "@opencode-ai/client/promise/api" + +export interface SessionHooks extends Pick {} diff --git a/packages/plugin/src/v2/promise/skill.ts b/packages/plugin/src/v2/promise/skill.ts index 2efc35f818..a1e62fc544 100644 --- a/packages/plugin/src/v2/promise/skill.ts +++ b/packages/plugin/src/v2/promise/skill.ts @@ -1,8 +1,10 @@ +import type { SkillApi } from "@opencode-ai/client/promise/api" import type { SkillDraft } from "../effect/skill.js" -import type { Hooks } from "./registration.js" +import type { TransformHook } from "./registration.js" export type { SkillDraft } -export type SkillHooks = Hooks<{ - transform: SkillDraft -}> +export interface SkillHooks extends SkillApi { + readonly transform: TransformHook + readonly reload: () => Promise +} diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts index 04d60888d7..2f91fae332 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -16,6 +16,7 @@ import type { Definition } from "@opencode-ai/schema/event" import { AgentGroup } from "./groups/agent.js" import { PluginGroup } from "./groups/plugin.js" import { HealthGroup } from "./groups/health.js" +import { DebugGroup } from "./groups/debug.js" import { PtyGroup } from "./groups/pty.js" import { ShellGroup } from "./groups/shell.js" import { makeQuestionGroup } from "./groups/question.js" @@ -81,6 +82,7 @@ type ApiGroups< Event extends HttpApiGroup.Any, > = | typeof HealthGroup + | typeof DebugGroup | LocationGroups | FormGroups | SessionGroups @@ -165,6 +167,7 @@ const makeApiFromGroup = < .add(ReferenceGroup.middleware(locationMiddleware)) .add(ProjectCopyGroup.middleware(locationMiddleware)) .add(VcsGroup.middleware(locationMiddleware)) + .add(DebugGroup) .annotateMerge( OpenApi.annotations({ title: "opencode HttpApi", diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index 3976289ed9..3d2993a02b 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -34,6 +34,7 @@ export const ClientApi: ClientApiShape = makeDefaultApi({ export const groupNames = { "server.health": "health", + "server.debug": "debug", "server.location": "location", "server.agent": "agent", "server.plugin": "plugin", diff --git a/packages/protocol/src/groups/agent.ts b/packages/protocol/src/groups/agent.ts index 1a9e958552..cfae58533a 100644 --- a/packages/protocol/src/groups/agent.ts +++ b/packages/protocol/src/groups/agent.ts @@ -4,17 +4,19 @@ import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { LocationQuery, locationQueryOpenApi } from "./location.js" -export const AgentGroup = HttpApiGroup.make("server.agent").add( - HttpApiEndpoint.get("agent.list", "/api/agent", { - query: LocationQuery, - success: Location.response(Schema.Array(Agent.Info)), - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.agent.list", - summary: "List agents", - description: "Retrieve currently registered agents.", - }), - ), -) +export const AgentGroup = HttpApiGroup.make("server.agent") + .add( + HttpApiEndpoint.get("agent.list", "/api/agent", { + query: LocationQuery, + success: Location.response(Schema.Array(Agent.Info)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.agent.list", + summary: "List agents", + description: "Retrieve currently registered agents.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "agent" })) diff --git a/packages/protocol/src/groups/command.ts b/packages/protocol/src/groups/command.ts index 6d463ae371..cc27e0f12e 100644 --- a/packages/protocol/src/groups/command.ts +++ b/packages/protocol/src/groups/command.ts @@ -21,7 +21,7 @@ export const CommandGroup = HttpApiGroup.make("server.command") ) .annotateMerge( OpenApi.annotations({ - title: "commands", + title: "command", description: "Experimental command routes.", }), ) diff --git a/packages/protocol/src/groups/credential.ts b/packages/protocol/src/groups/credential.ts index 7ef7e2e7de..414fc49c0d 100644 --- a/packages/protocol/src/groups/credential.ts +++ b/packages/protocol/src/groups/credential.ts @@ -20,6 +20,7 @@ export const CredentialGroup = HttpApiGroup.make("server.credential") }), ), ) + .annotateMerge(OpenApi.annotations({ title: "credential" })) .add( HttpApiEndpoint.delete("credential.remove", "/api/credential/:credentialID", { params: { credentialID: Credential.ID }, diff --git a/packages/protocol/src/groups/debug.ts b/packages/protocol/src/groups/debug.ts new file mode 100644 index 0000000000..b43e5bc639 --- /dev/null +++ b/packages/protocol/src/groups/debug.ts @@ -0,0 +1,17 @@ +import { Location } from "@opencode-ai/schema/location" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +export const DebugGroup = HttpApiGroup.make("server.debug") + .add( + HttpApiEndpoint.get("debug.location", "/api/debug/location", { + success: Schema.Array(Location.Ref), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.debug.location", + summary: "List loaded locations", + description: "List locations currently loaded by the server.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "debug" })) diff --git a/packages/protocol/src/groups/event.ts b/packages/protocol/src/groups/event.ts index f5a0c0c7bb..e555abe3f9 100644 --- a/packages/protocol/src/groups/event.ts +++ b/packages/protocol/src/groups/event.ts @@ -9,7 +9,6 @@ import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/un const fields = { id: Event.ID, metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), - durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Event.Seq, version: Event.Version })), location: Schema.optional(Location.Ref), } @@ -56,7 +55,7 @@ const make = >(definitions: }), ), ) - .annotateMerge(OpenApi.annotations({ title: "events", description: "Experimental event stream routes." })), + .annotateMerge(OpenApi.annotations({ title: "event", description: "Experimental event stream routes." })), } } @@ -68,3 +67,5 @@ export const EventGroup = event.group export const OpenCodeEvent = event.schema export type OpenCodeEvent = typeof OpenCodeEvent.Type export type OpenCodeEventEncoded = typeof OpenCodeEvent.Encoded +export const isOpenCodeEvent = (event: { readonly type: string }): event is OpenCodeEvent => + event.type === "server.connected" || EventManifest.isServer(event) diff --git a/packages/protocol/src/groups/form.ts b/packages/protocol/src/groups/form.ts index 145f1da0b8..b3fe2f12e8 100644 --- a/packages/protocol/src/groups/form.ts +++ b/packages/protocol/src/groups/form.ts @@ -144,4 +144,4 @@ export const makeFormGroup = < }), ), ) - .annotateMerge(OpenApi.annotations({ title: "forms", description: "Session form routes." })) + .annotateMerge(OpenApi.annotations({ title: "form", description: "Session form routes." })) diff --git a/packages/protocol/src/groups/health.ts b/packages/protocol/src/groups/health.ts index 18618164f0..625cce3919 100644 --- a/packages/protocol/src/groups/health.ts +++ b/packages/protocol/src/groups/health.ts @@ -1,14 +1,20 @@ import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -export const HealthGroup = HttpApiGroup.make("server.health").add( - HttpApiEndpoint.get("health.get", "/api/health", { - success: Schema.Struct({ healthy: Schema.Literal(true) }), - }).annotateMerge( - OpenApi.annotations({ - identifier: "v2.health.get", - summary: "Check server health", - description: "Check whether the API server is ready to accept requests.", - }), - ), -) +export const HealthGroup = HttpApiGroup.make("server.health") + .add( + HttpApiEndpoint.get("health.get", "/api/health", { + success: Schema.Struct({ + healthy: Schema.Literal(true), + version: Schema.String, + pid: Schema.Int.check(Schema.isGreaterThan(0)), + }), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.health.get", + summary: "Check server health", + description: "Check whether the API server is ready to accept requests.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "health" })) diff --git a/packages/protocol/src/groups/integration.ts b/packages/protocol/src/groups/integration.ts index 6c1b08f316..a6ec4d5282 100644 --- a/packages/protocol/src/groups/integration.ts +++ b/packages/protocol/src/groups/integration.ts @@ -126,5 +126,5 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration") ), ) .annotateMerge( - OpenApi.annotations({ title: "integrations", description: "Integration discovery and authentication routes." }), + OpenApi.annotations({ title: "integration", description: "Integration discovery and authentication routes." }), ) diff --git a/packages/protocol/src/groups/location.ts b/packages/protocol/src/groups/location.ts index 1752bae9be..4723b8a46a 100644 --- a/packages/protocol/src/groups/location.ts +++ b/packages/protocol/src/groups/location.ts @@ -26,17 +26,19 @@ export const locationQueryOpenApi = OpenApi.annotations({ }, }) -export const LocationGroup = HttpApiGroup.make("server.location").add( - HttpApiEndpoint.get("location.get", "/api/location", { - query: LocationQuery, - success: Location.Info, - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.location.get", - summary: "Get location", - description: "Resolve the requested location or the server default location.", - }), - ), -) +export const LocationGroup = HttpApiGroup.make("server.location") + .add( + HttpApiEndpoint.get("location.get", "/api/location", { + query: LocationQuery, + success: Location.Info, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.location.get", + summary: "Get location", + description: "Resolve the requested location or the server default location.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "location" })) diff --git a/packages/protocol/src/groups/message.ts b/packages/protocol/src/groups/message.ts index e6d9a48377..b2a360cd10 100644 --- a/packages/protocol/src/groups/message.ts +++ b/packages/protocol/src/groups/message.ts @@ -51,7 +51,7 @@ export const MessageGroup = HttpApiGroup.make("server.message") ) .annotateMerge( OpenApi.annotations({ - title: "messages", + title: "session", description: "Experimental message routes.", }), ) diff --git a/packages/protocol/src/groups/model.ts b/packages/protocol/src/groups/model.ts index 071a1b4735..a5970192bb 100644 --- a/packages/protocol/src/groups/model.ts +++ b/packages/protocol/src/groups/model.ts @@ -38,7 +38,7 @@ export const ModelGroup = HttpApiGroup.make("server.model") ) .annotateMerge( OpenApi.annotations({ - title: "models", + title: "model", description: "Experimental model routes.", }), ) diff --git a/packages/protocol/src/groups/permission.ts b/packages/protocol/src/groups/permission.ts index cfd26c2c50..d8f722193f 100644 --- a/packages/protocol/src/groups/permission.ts +++ b/packages/protocol/src/groups/permission.ts @@ -134,4 +134,4 @@ export const makePermissionGroup = < }), ), ) - .annotateMerge(OpenApi.annotations({ title: "permissions", description: "Experimental permission routes." })) + .annotateMerge(OpenApi.annotations({ title: "permission", description: "Experimental permission routes." })) diff --git a/packages/protocol/src/groups/plugin.ts b/packages/protocol/src/groups/plugin.ts index ee6d1ad7af..daf17c1778 100644 --- a/packages/protocol/src/groups/plugin.ts +++ b/packages/protocol/src/groups/plugin.ts @@ -21,7 +21,7 @@ export const PluginGroup = HttpApiGroup.make("server.plugin") ) .annotateMerge( OpenApi.annotations({ - title: "plugins", + title: "plugin", description: "Experimental plugin routes.", }), ) diff --git a/packages/protocol/src/groups/project.ts b/packages/protocol/src/groups/project.ts index 53862fede4..a62f1d072f 100644 --- a/packages/protocol/src/groups/project.ts +++ b/packages/protocol/src/groups/project.ts @@ -1,10 +1,22 @@ import { Project } from "@opencode-ai/schema/project" +import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { LocationQuery, locationQueryOpenApi } from "./location.js" const root = "/api/project" export const ProjectGroup = HttpApiGroup.make("server.project") + .add( + HttpApiEndpoint.get("project.list", root, { + success: Schema.Array(Project.Info), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.project.list", + summary: "List projects", + description: "List known projects.", + }), + ), + ) .add( HttpApiEndpoint.get("project.current", `${root}/current`, { query: LocationQuery, @@ -36,7 +48,7 @@ export const ProjectGroup = HttpApiGroup.make("server.project") ) .annotateMerge( OpenApi.annotations({ - title: "projects", + title: "project", description: "Location-scoped project routes.", }), ) diff --git a/packages/protocol/src/groups/provider.ts b/packages/protocol/src/groups/provider.ts index 179d87092a..25854aab6b 100644 --- a/packages/protocol/src/groups/provider.ts +++ b/packages/protocol/src/groups/provider.ts @@ -39,7 +39,7 @@ export const ProviderGroup = HttpApiGroup.make("server.provider") ) .annotateMerge( OpenApi.annotations({ - title: "providers", + title: "provider", description: "Experimental provider routes.", }), ) diff --git a/packages/protocol/src/groups/pty.ts b/packages/protocol/src/groups/pty.ts index 4ac3c5519f..309868189f 100644 --- a/packages/protocol/src/groups/pty.ts +++ b/packages/protocol/src/groups/pty.ts @@ -127,6 +127,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty") description: "Establish a WebSocket connection streaming PTY output and accepting terminal input.", transform: (operation) => ({ ...operation, + "x-websocket": true, parameters: [ ...(operation.parameters ?? []), ...["location[directory]", "location[workspace]", "cursor", PTY_CONNECT_TICKET_QUERY].map((name) => ({ diff --git a/packages/protocol/src/groups/question.ts b/packages/protocol/src/groups/question.ts index 288d8724f3..58545a390d 100644 --- a/packages/protocol/src/groups/question.ts +++ b/packages/protocol/src/groups/question.ts @@ -30,7 +30,7 @@ export const makeQuestionGroup = < }), ), ) - .annotateMerge(OpenApi.annotations({ title: "questions", description: "Experimental question routes." })) + .annotateMerge(OpenApi.annotations({ title: "question", description: "Experimental question routes." })) // Effect applies group middleware only to endpoints already added; session endpoints use session placement below. .middleware(locationMiddleware) .add( @@ -80,5 +80,5 @@ export const makeQuestionGroup = < ), ) .annotateMerge( - OpenApi.annotations({ title: "session questions", description: "Experimental session question routes." }), + OpenApi.annotations({ title: "question", description: "Experimental session question routes." }), ) diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index d2114ddee9..d89918f515 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -349,6 +349,26 @@ export const makeSessionGroup = (sessionLo }), ), ) + .add( + HttpApiEndpoint.post("session.shell", "/api/session/:sessionID/shell", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ + id: Event.ID.pipe(Schema.optional), + command: Schema.String, + }), + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.shell", + summary: "Run shell command", + description: + "Execute one shell command in the session's working directory. Emits a shell.started event before execution and a shell.ended event with the merged output after.", + }), + ), + ) .add( HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", { params: { sessionID: Session.ID }, @@ -547,7 +567,7 @@ export const makeSessionGroup = (sessionLo ) .annotateMerge( OpenApi.annotations({ - title: "sessions", + title: "session", description: "Experimental session routes.", }), ) diff --git a/packages/protocol/src/groups/skill.ts b/packages/protocol/src/groups/skill.ts index 805d0e441a..9d4e372633 100644 --- a/packages/protocol/src/groups/skill.ts +++ b/packages/protocol/src/groups/skill.ts @@ -21,7 +21,7 @@ export const SkillGroup = HttpApiGroup.make("server.skill") ) .annotateMerge( OpenApi.annotations({ - title: "skills", + title: "skill", description: "Experimental skill routes.", }), ) diff --git a/packages/protocol/test/event.test.ts b/packages/protocol/test/event.test.ts new file mode 100644 index 0000000000..33ece777c4 --- /dev/null +++ b/packages/protocol/test/event.test.ts @@ -0,0 +1,8 @@ +import { expect, test } from "bun:test" +import { isOpenCodeEvent } from "../src/groups/event.js" + +test("classifies public events by type", () => { + expect(isOpenCodeEvent({ type: "server.connected" })).toBe(true) + expect(isOpenCodeEvent({ type: "mcp.status.changed" })).toBe(true) + expect(isOpenCodeEvent({ type: "mcp.tools.changed" })).toBe(false) +}) diff --git a/packages/schema/src/agent.ts b/packages/schema/src/agent.ts index 3d8068b89a..eb84cf500b 100644 --- a/packages/schema/src/agent.ts +++ b/packages/schema/src/agent.ts @@ -1,14 +1,14 @@ export * as Agent from "./agent.js" import { Schema } from "effect" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" import { optional } from "./schema.js" import { Model } from "./model.js" import { Permission } from "./permission.js" import { Provider } from "./provider.js" import { PositiveInt, statics } from "./schema.js" -const Updated = define({ type: "agent.updated", schema: {} }) +const Updated = ephemeral({ type: "agent.updated", schema: {} }) export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID")) export type ID = typeof ID.Type diff --git a/packages/schema/src/catalog.ts b/packages/schema/src/catalog.ts index 19e2c26b9f..58545cd86b 100644 --- a/packages/schema/src/catalog.ts +++ b/packages/schema/src/catalog.ts @@ -1,6 +1,6 @@ export * as Catalog from "./catalog.js" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" -const Updated = define({ type: "catalog.updated", schema: {} }) +const Updated = ephemeral({ type: "catalog.updated", schema: {} }) export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/src/command.ts b/packages/schema/src/command.ts index fb88087dbd..81e37157c6 100644 --- a/packages/schema/src/command.ts +++ b/packages/schema/src/command.ts @@ -1,11 +1,11 @@ export * as Command from "./command.js" import { Schema } from "effect" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" import { optional } from "./schema.js" import { Model } from "./model.js" -const Updated = define({ type: "command.updated", schema: {} }) +const Updated = ephemeral({ type: "command.updated", schema: {} }) export interface Info extends Schema.Schema.Type {} export const Info = Schema.Struct({ diff --git a/packages/schema/src/config.ts b/packages/schema/src/config.ts new file mode 100644 index 0000000000..92b07b0e39 --- /dev/null +++ b/packages/schema/src/config.ts @@ -0,0 +1,10 @@ +export * as Config from "./config.js" + +import { ephemeral, inventory } from "./event.js" + +const Updated = ephemeral({ + type: "config.updated", + schema: {}, +}) + +export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/src/durable-event-manifest.ts b/packages/schema/src/durable-event-manifest.ts index 6b481dcfb9..8f8870f581 100644 --- a/packages/schema/src/durable-event-manifest.ts +++ b/packages/schema/src/durable-event-manifest.ts @@ -5,11 +5,8 @@ import { SessionEvent } from "./session-event.js" import { SessionV1 } from "./session-v1.js" export const SessionDurable = { - definitions: Event.durable(SessionEvent.DurableDefinitions), + definitions: Event.durableMap(SessionEvent.Definitions), schema: SessionEvent.Durable, } as const -export const Durable = Event.durable([ - ...SessionV1.Event.Definitions.filter((definition) => definition.durable !== undefined), - ...SessionEvent.DurableDefinitions, -]) +export const Durable = Event.durableMap([...SessionV1.Event.Definitions, ...SessionEvent.Definitions]) diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts index 19fca5e79d..23ed3fd044 100644 --- a/packages/schema/src/event-manifest.ts +++ b/packages/schema/src/event-manifest.ts @@ -1,12 +1,14 @@ export * as EventManifest from "./event-manifest.js" +import { Schema } from "effect" import { Agent } from "./agent.js" import { Catalog } from "./catalog.js" import { Command } from "./command.js" +import { Config } from "./config.js" import { Durable } from "./durable-event-manifest.js" import { Event } from "./event.js" import { FileSystem } from "./filesystem.js" -import { FileSystemWatcher } from "./filesystem-watcher.js" +import { FileSystemV1 } from "./filesystem-v1.js" import { Form } from "./form.js" import { InstallationEvent } from "./installation-event.js" import { Integration } from "./integration.js" @@ -36,8 +38,12 @@ import { VcsEvent } from "./vcs-event.js" import { WorkspaceEvent } from "./workspace-event.js" import { WorktreeEvent } from "./worktree-event.js" -const sessionV1DurableDefinitions = SessionV1.Event.Definitions.filter((definition) => definition.durable !== undefined) -const sessionV1LiveDefinitions = SessionV1.Event.Definitions.filter((definition) => definition.durable === undefined) +const sessionV1DurableDefinitions = SessionV1.Event.Definitions.filter( + (definition) => definition.durability === "durable", +) +const sessionV1LiveDefinitions = SessionV1.Event.Definitions.filter( + (definition) => definition.durability === "ephemeral", +) const coreDefinitions = Event.inventory(...sessionV1DurableDefinitions, ...SessionEvent.Definitions) @@ -56,8 +62,8 @@ const featureDefinitions = Event.inventory( ...Plugin.Event.Definitions, ...ProjectDirectories.Event.Definitions, ...Command.Event.Definitions, + ...Config.Event.Definitions, ...Skill.Event.Definitions, - ...FileSystemWatcher.Event.Definitions, ...Pty.Event.Definitions, ...Shell.Event.Definitions, ...Question.Event.Definitions, @@ -81,6 +87,9 @@ export const ServerDefinitions = Event.inventory( ...QuestionV1.Event.Definitions, SessionV1.Error, ) +export const Server = Event.latest(ServerDefinitions) +export type ServerEvent = Schema.Schema.Type<(typeof ServerDefinitions)[number]> +export const isServer = (event: { readonly type: string }): event is ServerEvent => Server.has(event.type) export const Definitions = Event.inventory( ...foundationDefinitions, @@ -93,6 +102,7 @@ export const Definitions = Event.inventory( ...TuiEvent.Definitions, ...McpEvent.Definitions, ...LegacyEvent.Definitions, + ...FileSystemV1.Event.Definitions, ...Project.Event.Definitions, ...SessionStatusEvent.Definitions, ...QuestionV1.Event.Definitions, diff --git a/packages/schema/src/event.ts b/packages/schema/src/event.ts index 1157a2bc44..9f7c347b03 100644 --- a/packages/schema/src/event.ts +++ b/packages/schema/src/event.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { optional } from "./schema.js" import { ascending } from "./identifier.js" import { Location } from "./location.js" -import { statics } from "./schema.js" +import { DateTimeUtcFromMillis, statics } from "./schema.js" export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe( Schema.brand("Event.ID"), @@ -24,50 +24,72 @@ export type Seq = typeof Seq.Type export const Version = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).pipe(Schema.brand("Event.Version")) export type Version = typeof Version.Type -export type Definition< +const DurableEnvelope = Schema.Struct({ aggregateID: Schema.String, seq: Seq, version: Version }) +export type DurableEnvelope = typeof DurableEnvelope.Type + +export type DurableDefinition< Type extends string = string, DataSchema extends Schema.Codec = Schema.Codec, > = Schema.Top & { readonly type: Type - readonly durable?: { + readonly durability: "durable" + readonly durable: { readonly version: number readonly aggregate: string } readonly data: DataSchema } +export type EphemeralDefinition< + Type extends string = string, + DataSchema extends Schema.Codec = Schema.Codec, +> = Schema.Top & { + readonly type: Type + readonly durability: "ephemeral" + readonly durable?: never + readonly data: DataSchema +} + +export type Definition< + Type extends string = string, + DataSchema extends Schema.Codec = Schema.Codec, +> = DurableDefinition | EphemeralDefinition + export type Data = Schema.Schema.Type -export type Payload = { +type PayloadBase = { readonly id: ID readonly type: D["type"] + readonly created: typeof DateTimeUtcFromMillis.Type readonly data: Data - readonly durable?: { - readonly aggregateID: string - readonly seq: Seq - readonly version: Version - } readonly location?: Location.Ref readonly metadata?: Record } -export function define< - const Type extends string, - const Fields extends Readonly>>, ->(input: { +export type Payload = D extends DurableDefinition + ? PayloadBase & { readonly durable: DurableEnvelope } + : PayloadBase & { readonly durable?: never } + +type Input>>> = { readonly type: Type readonly durable?: { readonly version: number readonly aggregate: string } readonly schema: Fields -}) { +} + +export function durable< + const Type extends string, + const Fields extends Readonly>>, +>(input: Input & { readonly durable: NonNullable["durable"]> }) { const data = Schema.Struct(input.schema) return Schema.Struct({ id: ID, + created: DateTimeUtcFromMillis, metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), type: Schema.Literal(input.type), - durable: optional(Schema.Struct({ aggregateID: Schema.String, seq: Seq, version: Version })), + durable: DurableEnvelope, location: optional(Location.Ref), data, }) @@ -75,10 +97,35 @@ export function define< .pipe( statics(() => ({ type: input.type, - ...(input.durable === undefined ? {} : { durable: input.durable }), + durability: "durable" as const, + durable: input.durable, data, })), - ) satisfies Definition + ) satisfies DurableDefinition +} + +export function ephemeral< + const Type extends string, + const Fields extends Readonly>>, +>(input: Omit, "durable">) { + const data = Schema.Struct(input.schema) + return Schema.Struct({ + id: ID, + created: DateTimeUtcFromMillis, + metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), + type: Schema.Literal(input.type), + location: optional(Location.Ref), + data, + }) + .annotate({ identifier: input.type }) + .pipe( + statics(() => ({ + type: input.type, + durability: "ephemeral" as const, + durable: undefined, + data, + })), + ) satisfies EphemeralDefinition } export function inventory>(...definitions: Definitions) { @@ -107,15 +154,15 @@ export function versionedType(type: string, version: number) { return `${type}.${version}` } -export function durable>(definitions: Definitions) { +export function durableMap>(definitions: Definitions) { return readonlyMap( definitions.reduce((result, definition) => { - if (!definition.durable) return result + if (definition.durability !== "durable") return result const key = versionedType(definition.type, definition.durable.version) if (result.has(key)) throw new Error(`Duplicate durable event definition for ${key}`) result.set(key, definition) return result - }, new Map()), + }, new Map()), ) } diff --git a/packages/schema/src/filesystem-v1.ts b/packages/schema/src/filesystem-v1.ts new file mode 100644 index 0000000000..60e1a91853 --- /dev/null +++ b/packages/schema/src/filesystem-v1.ts @@ -0,0 +1 @@ +export * from "./v1/filesystem.js" diff --git a/packages/schema/src/filesystem-watcher.ts b/packages/schema/src/filesystem-watcher.ts deleted file mode 100644 index e1e1d557d6..0000000000 --- a/packages/schema/src/filesystem-watcher.ts +++ /dev/null @@ -1,13 +0,0 @@ -export * as FileSystemWatcher from "./filesystem-watcher.js" - -import { Schema } from "effect" -import { define, inventory } from "./event.js" - -const Updated = define({ - type: "file.watcher.updated", - schema: { - file: Schema.String, - event: Schema.Literals(["add", "change", "unlink"]), - }, -}) -export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/src/filesystem.ts b/packages/schema/src/filesystem.ts index 882c3f0abf..3f95e97a5b 100644 --- a/packages/schema/src/filesystem.ts +++ b/packages/schema/src/filesystem.ts @@ -2,14 +2,17 @@ export * as FileSystem from "./filesystem.js" import { Schema } from "effect" import { optional } from "./schema.js" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js" -const Edited = define({ - type: "file.edited", - schema: { file: Schema.String }, +const Changed = ephemeral({ + type: "filesystem.changed", + schema: { + file: Schema.String, + event: Schema.Literals(["add", "change", "unlink"]), + }, }) -export const Event = { Edited, Definitions: inventory(Edited) } +export const Event = { Changed, Definitions: inventory(Changed) } export interface Entry extends Schema.Schema.Type {} export const Entry = Schema.Struct({ diff --git a/packages/schema/src/form.ts b/packages/schema/src/form.ts index 820192d06f..df9d455f13 100644 --- a/packages/schema/src/form.ts +++ b/packages/schema/src/form.ts @@ -1,7 +1,7 @@ export * as Form from "./form.js" import { Schema } from "effect" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" import { ascending } from "./identifier.js" import { NonNegativeInt, optional, statics } from "./schema.js" @@ -127,9 +127,11 @@ export interface UrlInfo extends Schema.Schema.Type {} export const Info = Schema.Union([FormInfo, UrlInfo]).pipe(Schema.toTaggedUnion("mode")) export type Info = FormInfo | UrlInfo -export const Value = Schema.Union([Schema.String, Schema.Number, Schema.Boolean, Schema.Array(Schema.String)]).annotate({ - identifier: "Form.Value", -}) +export const Value = Schema.Union([Schema.String, Schema.Number, Schema.Boolean, Schema.Array(Schema.String)]).annotate( + { + identifier: "Form.Value", + }, +) export type Value = typeof Value.Type export const Answer = Schema.Record(Schema.String, Value).annotate({ identifier: "Form.Answer" }) @@ -149,8 +151,8 @@ export const Reply = Schema.Struct({ }).annotate({ identifier: "Form.Reply" }) export interface Reply extends Schema.Schema.Type {} -const Created = define({ type: "form.created", schema: { form: Info } }) -const Replied = define({ type: "form.replied", schema: { id: ID, sessionID: Schema.String, answer: Answer } }) -const Cancelled = define({ type: "form.cancelled", schema: { id: ID, sessionID: Schema.String } }) +const Created = ephemeral({ type: "form.created", schema: { form: Info } }) +const Replied = ephemeral({ type: "form.replied", schema: { id: ID, sessionID: Schema.String, answer: Answer } }) +const Cancelled = ephemeral({ type: "form.cancelled", schema: { id: ID, sessionID: Schema.String } }) export const Event = { Created, Replied, Cancelled, Definitions: inventory(Created, Replied, Cancelled) } diff --git a/packages/schema/src/ide-event.ts b/packages/schema/src/ide-event.ts index ec01d9fa36..dd88975b38 100644 --- a/packages/schema/src/ide-event.ts +++ b/packages/schema/src/ide-event.ts @@ -3,7 +3,7 @@ export * as IdeEvent from "./ide-event.js" import { Schema } from "effect" import { Event } from "./event.js" -export const Installed = Event.define({ +export const Installed = Event.ephemeral({ type: "ide.installed", schema: { ide: Schema.String, diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index fb2ef17b96..1454fab1cf 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -1,5 +1,6 @@ export { Agent } from "./agent.js" export { Command } from "./command.js" +export { Config } from "./config.js" export { Connection } from "./connection.js" export { Credential } from "./credential.js" export { Event } from "./event.js" diff --git a/packages/schema/src/installation-event.ts b/packages/schema/src/installation-event.ts index dde53f7b72..f36a2dfc14 100644 --- a/packages/schema/src/installation-event.ts +++ b/packages/schema/src/installation-event.ts @@ -3,14 +3,14 @@ export * as InstallationEvent from "./installation-event.js" import { Schema } from "effect" import { Event } from "./event.js" -export const Updated = Event.define({ +export const Updated = Event.ephemeral({ type: "installation.updated", schema: { version: Schema.String, }, }) -export const UpdateAvailable = Event.define({ +export const UpdateAvailable = Event.ephemeral({ type: "installation.update-available", schema: { version: Schema.String, diff --git a/packages/schema/src/integration.ts b/packages/schema/src/integration.ts index f1d9c63784..329bf8207f 100644 --- a/packages/schema/src/integration.ts +++ b/packages/schema/src/integration.ts @@ -2,7 +2,7 @@ export * as Integration from "./integration.js" import { Schema } from "effect" import { optional } from "./schema.js" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" import { Connection } from "./connection.js" import { ascending } from "./identifier.js" import { statics } from "./schema.js" @@ -76,11 +76,11 @@ export type Method = typeof Method.Type export const Inputs = Schema.Record(Schema.String, Schema.String).annotate({ identifier: "Integration.Inputs" }) export type Inputs = typeof Inputs.Type -const Updated = define({ +const Updated = ephemeral({ type: "integration.updated", schema: {}, }) -const ConnectionUpdated = define({ +const ConnectionUpdated = ephemeral({ type: "integration.connection.updated", schema: { integrationID: ID }, }) diff --git a/packages/schema/src/lsp-event.ts b/packages/schema/src/lsp-event.ts index 68f31fbc6c..7928fa00a0 100644 --- a/packages/schema/src/lsp-event.ts +++ b/packages/schema/src/lsp-event.ts @@ -2,6 +2,6 @@ export * as LspEvent from "./lsp-event.js" import { Event } from "./event.js" -export const Updated = Event.define({ type: "lsp.updated", schema: {} }) +export const Updated = Event.ephemeral({ type: "lsp.updated", schema: {} }) export const Definitions = Event.inventory(Updated) diff --git a/packages/schema/src/mcp-event.ts b/packages/schema/src/mcp-event.ts index 70be7a91d8..ae1e82656d 100644 --- a/packages/schema/src/mcp-event.ts +++ b/packages/schema/src/mcp-event.ts @@ -3,14 +3,14 @@ export * as McpEvent from "./mcp-event.js" import { Schema } from "effect" import { Event } from "./event.js" -export const ToolsChanged = Event.define({ +export const ToolsChanged = Event.ephemeral({ type: "mcp.tools.changed", schema: { server: Schema.String, }, }) -export const BrowserOpenFailed = Event.define({ +export const BrowserOpenFailed = Event.ephemeral({ type: "mcp.browser.open.failed", schema: { mcpName: Schema.String, @@ -20,11 +20,11 @@ export const BrowserOpenFailed = Event.define({ // Emitted whenever a server's connection status settles (connected, failed, needs_auth, closed) so // observers can refresh status without polling. -export const StatusChanged = Event.define({ +export const StatusChanged = Event.ephemeral({ type: "mcp.status.changed", schema: { server: Schema.String, }, }) -export const Definitions = Event.inventory(ToolsChanged, BrowserOpenFailed, StatusChanged) +export const Definitions = Event.inventory(ToolsChanged, StatusChanged) diff --git a/packages/schema/src/mcp.ts b/packages/schema/src/mcp.ts index 421b5aee95..4a7ce15618 100644 --- a/packages/schema/src/mcp.ts +++ b/packages/schema/src/mcp.ts @@ -7,8 +7,8 @@ import { IntegrationID } from "./integration-id.js" const Connected = Schema.Struct({ status: Schema.Literal("connected") }).annotate({ identifier: "Mcp.Status.Connected", }) -const Disconnected = Schema.Struct({ status: Schema.Literal("disconnected") }).annotate({ - identifier: "Mcp.Status.Disconnected", +const Pending = Schema.Struct({ status: Schema.Literal("pending") }).annotate({ + identifier: "Mcp.Status.Pending", }) const Disabled = Schema.Struct({ status: Schema.Literal("disabled") }).annotate({ identifier: "Mcp.Status.Disabled", @@ -27,7 +27,7 @@ const NeedsClientRegistration = Schema.Struct({ export type Status = typeof Status.Type export const Status = Schema.Union([ Connected, - Disconnected, + Pending, Disabled, Failed, NeedsAuth, diff --git a/packages/schema/src/models-dev.ts b/packages/schema/src/models-dev.ts index 60d630ecfa..925639aef3 100644 --- a/packages/schema/src/models-dev.ts +++ b/packages/schema/src/models-dev.ts @@ -1,8 +1,8 @@ export * as ModelsDev from "./models-dev.js" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" -const Refreshed = define({ +const Refreshed = ephemeral({ type: "models-dev.refreshed", schema: {}, }) diff --git a/packages/schema/src/permission.ts b/packages/schema/src/permission.ts index de86fd1b1e..ca79081eda 100644 --- a/packages/schema/src/permission.ts +++ b/packages/schema/src/permission.ts @@ -2,7 +2,7 @@ export * as Permission from "./permission.js" import { Schema } from "effect" import { optional } from "./schema.js" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" import { ascending } from "./identifier.js" import { SessionID } from "./session-id.js" import { statics } from "./schema.js" @@ -40,8 +40,8 @@ export interface Request extends Schema.Schema.Type {} export const Reply = Schema.Literals(["once", "always", "reject"]).annotate({ identifier: "PermissionV2.Reply" }) export type Reply = typeof Reply.Type -const Asked = define({ type: "permission.v2.asked", schema: Request.fields }) -const Replied = define({ +const Asked = ephemeral({ type: "permission.v2.asked", schema: Request.fields }) +const Replied = ephemeral({ type: "permission.v2.replied", schema: { sessionID: SessionID, diff --git a/packages/schema/src/plugin.ts b/packages/schema/src/plugin.ts index 3a9b8b55b1..4a003f1199 100644 --- a/packages/schema/src/plugin.ts +++ b/packages/schema/src/plugin.ts @@ -1,7 +1,7 @@ export * as Plugin from "./plugin.js" import { Schema } from "effect" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" export const ID = Schema.String.pipe(Schema.brand("Plugin.ID")) export type ID = typeof ID.Type @@ -11,8 +11,12 @@ export const Info = Schema.Struct({ id: ID, }).annotate({ identifier: "Plugin.Info" }) -const Added = define({ +const Added = ephemeral({ type: "plugin.added", schema: { id: ID }, }) -export const Event = { Added, Definitions: inventory(Added) } +const Updated = ephemeral({ + type: "plugin.updated", + schema: {}, +}) +export const Event = { Added, Updated, Definitions: inventory(Added, Updated) } diff --git a/packages/schema/src/project-directories.ts b/packages/schema/src/project-directories.ts index d80322287c..aa5d51f0cf 100644 --- a/packages/schema/src/project-directories.ts +++ b/packages/schema/src/project-directories.ts @@ -1,9 +1,9 @@ export * as ProjectDirectories from "./project-directories.js" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" import { Project } from "./project.js" -const Updated = define({ +const Updated = ephemeral({ type: "project.directories.updated", schema: { projectID: Project.ID }, }) diff --git a/packages/schema/src/project.ts b/packages/schema/src/project.ts index 71d6908105..30024dd38c 100644 --- a/packages/schema/src/project.ts +++ b/packages/schema/src/project.ts @@ -1,7 +1,7 @@ export * as Project from "./project.js" import { Schema } from "effect" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" import { AbsolutePath, NonNegativeInt, optional } from "./schema.js" import { ProjectID } from "./project-id.js" @@ -56,5 +56,5 @@ export const Info = Schema.Struct({ }).annotate({ identifier: "Project" }) export interface Info extends Schema.Schema.Type {} -const Updated = define({ type: "project.updated", schema: Info.fields }) +const Updated = ephemeral({ type: "project.updated", schema: Info.fields }) export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/src/pty.ts b/packages/schema/src/pty.ts index 58dc9cb833..b2da9093f0 100644 --- a/packages/schema/src/pty.ts +++ b/packages/schema/src/pty.ts @@ -2,7 +2,7 @@ export * as Pty from "./pty.js" import { Schema } from "effect" import { optional } from "./schema.js" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" import { ascending } from "./identifier.js" import { NonNegativeInt, PositiveInt, statics } from "./schema.js" @@ -31,10 +31,10 @@ export const Info = Schema.Struct({ }).annotate({ identifier: "Pty" }) export interface Info extends Schema.Schema.Type {} -const Created = define({ type: "pty.created", schema: { info: Info } }) -const Updated = define({ type: "pty.updated", schema: { info: Info } }) -const Exited = define({ type: "pty.exited", schema: { id: ID, exitCode: NonNegativeInt } }) -const Deleted = define({ type: "pty.deleted", schema: { id: ID } }) +const Created = ephemeral({ type: "pty.created", schema: { info: Info } }) +const Updated = ephemeral({ type: "pty.updated", schema: { info: Info } }) +const Exited = ephemeral({ type: "pty.exited", schema: { id: ID, exitCode: NonNegativeInt } }) +const Deleted = ephemeral({ type: "pty.deleted", schema: { id: ID } }) export const Event = { Created, Updated, Exited, Deleted, Definitions: inventory(Created, Updated, Exited, Deleted) } export const CreateInput = Schema.Struct({ diff --git a/packages/schema/src/question.ts b/packages/schema/src/question.ts index 56fa7a277b..617ad2a895 100644 --- a/packages/schema/src/question.ts +++ b/packages/schema/src/question.ts @@ -2,7 +2,7 @@ export * as Question from "./question.js" import { Schema } from "effect" import { optional } from "./schema.js" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" import { ascending } from "./identifier.js" import { SessionID } from "./session-id.js" import { statics } from "./schema.js" @@ -67,8 +67,8 @@ export const Reply = Schema.Struct({ }).annotate({ identifier: "QuestionV2.Reply" }) export interface Reply extends Schema.Schema.Type {} -const Asked = define({ type: "question.v2.asked", schema: Request.fields }) -const Replied = define({ +const Asked = ephemeral({ type: "question.v2.asked", schema: Request.fields }) +const Replied = ephemeral({ type: "question.v2.replied", schema: { sessionID: SessionID, @@ -76,7 +76,7 @@ const Replied = define({ answers: Schema.Array(Answer), }, }) -const Rejected = define({ +const Rejected = ephemeral({ type: "question.v2.rejected", schema: { sessionID: SessionID, diff --git a/packages/schema/src/reference.ts b/packages/schema/src/reference.ts index 5d63bea9df..9dd277f62b 100644 --- a/packages/schema/src/reference.ts +++ b/packages/schema/src/reference.ts @@ -2,10 +2,10 @@ export * as Reference from "./reference.js" import { Schema } from "effect" import { optional } from "./schema.js" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" import { AbsolutePath } from "./schema.js" -const Updated = define({ type: "reference.updated", schema: {} }) +const Updated = ephemeral({ type: "reference.updated", schema: {} }) export const Event = { Updated, Definitions: inventory(Updated) } export interface LocalSource extends Schema.Schema.Type {} diff --git a/packages/schema/src/server-event.ts b/packages/schema/src/server-event.ts index ec8599ad32..4f4963cfbf 100644 --- a/packages/schema/src/server-event.ts +++ b/packages/schema/src/server-event.ts @@ -2,7 +2,7 @@ export * as ServerEvent from "./server-event.js" import { Event } from "./event.js" -export const Connected = Event.define({ type: "server.connected", schema: {} }) -export const Disposed = Event.define({ type: "global.disposed", schema: {} }) +export const Connected = Event.ephemeral({ type: "server.connected", schema: {} }) +export const Disposed = Event.ephemeral({ type: "global.disposed", schema: {} }) export const Definitions = Event.inventory(Connected, Disposed) diff --git a/packages/schema/src/session-compaction-event.ts b/packages/schema/src/session-compaction-event.ts index 56782fd5d5..a9c0144c98 100644 --- a/packages/schema/src/session-compaction-event.ts +++ b/packages/schema/src/session-compaction-event.ts @@ -3,7 +3,7 @@ export * as SessionCompactionEvent from "./session-compaction-event.js" import { Event } from "./event.js" import { SessionID } from "./session-id.js" -export const Compacted = Event.define({ +export const Compacted = Event.ephemeral({ type: "session.compacted", schema: { sessionID: SessionID, diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index df78fa1493..824b1690db 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -6,12 +6,13 @@ import { Event } from "./event.js" import { ProviderMetadata, ToolContent } from "./llm.js" import { Delivery } from "./session-delivery.js" import { Model } from "./model.js" -import { DateTimeUtcFromMillis, NonNegativeInt, RelativePath } from "./schema.js" +import { NonNegativeInt, RelativePath } from "./schema.js" import { FileAttachment, Prompt } from "./prompt.js" import { SessionID } from "./session-id.js" import { Location } from "./location.js" import { SessionMessage } from "./session-message.js" import { Revert } from "./revert.js" +import { Shell as ShellSchema } from "./shell.js" export { FileAttachment } @@ -20,17 +21,16 @@ export const Source = Schema.Struct({ end: NonNegativeInt, text: Schema.String, }).annotate({ - identifier: "session.next.event.source", + identifier: "session.event.source", }) export interface Source extends Schema.Schema.Type {} const Base = { - timestamp: DateTimeUtcFromMillis, sessionID: SessionID, } const PromptFields = { ...Base, - messageID: SessionMessage.ID, + inputID: SessionMessage.ID, prompt: Prompt, delivery: Delivery, } @@ -44,48 +44,46 @@ const options = { const stepSettlementOptions = { durable: { aggregate: "sessionID", - version: 2, + version: 1, }, } as const export const UnknownError = SessionMessage.UnknownError export type UnknownError = SessionMessage.UnknownError -export const AgentSwitched = Event.define({ - type: "session.next.agent.switched", +export const AgentSelected = Event.durable({ + type: "session.agent.selected", ...options, schema: { ...Base, - messageID: SessionMessage.ID, agent: Schema.String, }, }) -export type AgentSwitched = typeof AgentSwitched.Type +export type AgentSelected = typeof AgentSelected.Type -export const ModelSwitched = Event.define({ - type: "session.next.model.switched", +export const ModelSelected = Event.durable({ + type: "session.model.selected", ...options, schema: { ...Base, - messageID: SessionMessage.ID, model: Model.Ref, }, }) -export type ModelSwitched = typeof ModelSwitched.Type +export type ModelSelected = typeof ModelSelected.Type -export const Moved = Event.define({ - type: "session.next.moved", +export const Moved = Event.durable({ + type: "session.moved", ...options, schema: { ...Base, location: Location.Ref, - subdirectory: RelativePath.pipe(optional), + subpath: RelativePath.pipe(optional), }, }) export type Moved = typeof Moved.Type -export const Renamed = Event.define({ - type: "session.next.renamed", +export const Renamed = Event.durable({ + type: "session.renamed", ...options, schema: { ...Base, @@ -94,33 +92,36 @@ export const Renamed = Event.define({ }) export type Renamed = typeof Renamed.Type -export const Forked = Event.define({ - type: "session.next.forked", +export const Forked = Event.durable({ + type: "session.forked", ...options, schema: { ...Base, parentID: SessionID, - messageID: SessionMessage.ID.pipe(optional), + from: SessionMessage.ID.pipe(optional), }, }) export type Forked = typeof Forked.Type -export const Prompted = Event.define({ - type: "session.next.prompted", +export const PromptPromoted = Event.durable({ + type: "session.prompt.promoted", ...options, - schema: PromptFields, + schema: { + sessionID: SessionID, + inputID: SessionMessage.ID, + }, }) -export type Prompted = typeof Prompted.Type +export type PromptPromoted = typeof PromptPromoted.Type -export const PromptAdmitted = Event.define({ - type: "session.next.prompt.admitted", +export const PromptAdmitted = Event.durable({ + type: "session.prompt.admitted", ...options, schema: PromptFields, }) export type PromptAdmitted = typeof PromptAdmitted.Type -export const ExecutionSettled = Event.define({ - type: "session.next.execution.settled", +export const ExecutionSettled = Event.ephemeral({ + type: "session.execution.settled", schema: { ...Base, outcome: Schema.Literals(["success", "failure", "interrupted"]), @@ -129,23 +130,21 @@ export const ExecutionSettled = Event.define({ }) export type ExecutionSettled = typeof ExecutionSettled.Type -export const ContextUpdated = Event.define({ - type: "session.next.context.updated", +export const ContextUpdated = Event.durable({ + type: "session.context.updated", ...options, schema: { ...Base, - messageID: SessionMessage.ID, text: Schema.String, }, }) export type ContextUpdated = typeof ContextUpdated.Type -export const Synthetic = Event.define({ - type: "session.next.synthetic", +export const Synthetic = Event.durable({ + type: "session.synthetic", ...options, schema: { ...Base, - messageID: SessionMessage.ID, text: Schema.String, description: Schema.String.pipe(optional), metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional), @@ -154,12 +153,11 @@ export const Synthetic = Event.define({ export type Synthetic = typeof Synthetic.Type export namespace Skill { - export const Activated = Event.define({ - type: "session.next.skill.activated", + export const Activated = Event.durable({ + type: "session.skill.activated", ...options, schema: { ...Base, - messageID: SessionMessage.ID, name: Schema.String, text: Schema.String, }, @@ -168,33 +166,31 @@ export namespace Skill { } export namespace Shell { - export const Started = Event.define({ - type: "session.next.shell.started", + export const Started = Event.durable({ + type: "session.shell.started", ...options, schema: { ...Base, - messageID: SessionMessage.ID, - callID: Schema.String, - command: Schema.String, + shell: ShellSchema.Info, }, }) export type Started = typeof Started.Type - export const Ended = Event.define({ - type: "session.next.shell.ended", + export const Ended = Event.durable({ + type: "session.shell.ended", ...options, schema: { ...Base, - callID: Schema.String, - output: Schema.String, + shell: ShellSchema.Info, + output: ShellSchema.Output, }, }) export type Ended = typeof Ended.Type } export namespace Step { - export const Started = Event.define({ - type: "session.next.step.started", + export const Started = Event.durable({ + type: "session.step.started", ...options, schema: { ...Base, @@ -206,8 +202,8 @@ export namespace Step { }) export type Started = typeof Started.Type - export const Ended = Event.define({ - type: "session.next.step.ended", + export const Ended = Event.durable({ + type: "session.step.ended", ...stepSettlementOptions, schema: { ...Base, @@ -229,8 +225,8 @@ export namespace Step { }) export type Ended = typeof Ended.Type - export const Failed = Event.define({ - type: "session.next.step.failed", + export const Failed = Event.durable({ + type: "session.step.failed", ...stepSettlementOptions, schema: { ...Base, @@ -242,8 +238,8 @@ export namespace Step { } export namespace Text { - export const Started = Event.define({ - type: "session.next.text.started", + export const Started = Event.durable({ + type: "session.text.started", ...options, schema: { ...Base, @@ -254,8 +250,8 @@ export namespace Text { export type Started = typeof Started.Type // Stream fragments are live-only; Text.Ended is the replayable full-value boundary. - export const Delta = Event.define({ - type: "session.next.text.delta", + export const Delta = Event.ephemeral({ + type: "session.text.delta", schema: { ...Base, assistantMessageID: SessionMessage.ID, @@ -265,8 +261,8 @@ export namespace Text { }) export type Delta = typeof Delta.Type - export const Ended = Event.define({ - type: "session.next.text.ended", + export const Ended = Event.durable({ + type: "session.text.ended", ...options, schema: { ...Base, @@ -279,8 +275,8 @@ export namespace Text { } export namespace Reasoning { - export const Started = Event.define({ - type: "session.next.reasoning.started", + export const Started = Event.durable({ + type: "session.reasoning.started", ...options, schema: { ...Base, @@ -292,8 +288,8 @@ export namespace Reasoning { export type Started = typeof Started.Type // Stream fragments are live-only; Reasoning.Ended is the replayable full-value boundary. - export const Delta = Event.define({ - type: "session.next.reasoning.delta", + export const Delta = Event.ephemeral({ + type: "session.reasoning.delta", schema: { ...Base, assistantMessageID: SessionMessage.ID, @@ -303,8 +299,8 @@ export namespace Reasoning { }) export type Delta = typeof Delta.Type - export const Ended = Event.define({ - type: "session.next.reasoning.ended", + export const Ended = Event.durable({ + type: "session.reasoning.ended", ...options, schema: { ...Base, @@ -325,8 +321,8 @@ export namespace Tool { } export namespace Input { - export const Started = Event.define({ - type: "session.next.tool.input.started", + export const Started = Event.durable({ + type: "session.tool.input.started", ...options, schema: { ...ToolBase, @@ -336,8 +332,8 @@ export namespace Tool { export type Started = typeof Started.Type // Stream fragments are live-only; Input.Ended is the replayable raw-input boundary. - export const Delta = Event.define({ - type: "session.next.tool.input.delta", + export const Delta = Event.ephemeral({ + type: "session.tool.input.delta", schema: { ...ToolBase, delta: Schema.String, @@ -345,8 +341,8 @@ export namespace Tool { }) export type Delta = typeof Delta.Type - export const Ended = Event.define({ - type: "session.next.tool.input.ended", + export const Ended = Event.durable({ + type: "session.tool.input.ended", ...options, schema: { ...ToolBase, @@ -356,8 +352,8 @@ export namespace Tool { export type Ended = typeof Ended.Type } - export const Called = Event.define({ - type: "session.next.tool.called", + export const Called = Event.durable({ + type: "session.tool.called", ...options, schema: { ...ToolBase, @@ -375,8 +371,8 @@ export namespace Tool { * Replayable bounded running-tool state. Tools should checkpoint semantic * transitions or at a bounded cadence, not persist every stdout/stderr chunk. */ - export const Progress = Event.define({ - type: "session.next.tool.progress", + export const Progress = Event.durable({ + type: "session.tool.progress", ...options, schema: { ...ToolBase, @@ -386,8 +382,8 @@ export namespace Tool { }) export type Progress = typeof Progress.Type - export const Success = Event.define({ - type: "session.next.tool.success", + export const Success = Event.durable({ + type: "session.tool.success", ...options, schema: { ...ToolBase, @@ -403,8 +399,8 @@ export namespace Tool { }) export type Success = typeof Success.Type - export const Failed = Event.define({ - type: "session.next.tool.failed", + export const Failed = Event.durable({ + type: "session.tool.failed", ...options, schema: { ...ToolBase, @@ -427,12 +423,12 @@ export const RetryError = Schema.Struct({ responseBody: Schema.String.pipe(optional), metadata: Schema.Record(Schema.String, Schema.String).pipe(optional), }).annotate({ - identifier: "session.next.retry_error", + identifier: "session.retry.error", }) export interface RetryError extends Schema.Schema.Type {} -export const Retried = Event.define({ - type: "session.next.retried", +export const Retried = Event.durable({ + type: "session.retried", ...options, schema: { ...Base, @@ -443,33 +439,30 @@ export const Retried = Event.define({ export type Retried = typeof Retried.Type export namespace Compaction { - export const Started = Event.define({ - type: "session.next.compaction.started", + export const Started = Event.durable({ + type: "session.compaction.started", ...options, schema: { ...Base, - messageID: SessionMessage.ID, reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]), }, }) export type Started = typeof Started.Type - export const Delta = Event.define({ - type: "session.next.compaction.delta", + export const Delta = Event.ephemeral({ + type: "session.compaction.delta", schema: { ...Base, - messageID: SessionMessage.ID, text: Schema.String, }, }) export type Delta = typeof Delta.Type - export const Ended = Event.define({ - type: "session.next.compaction.ended", + export const Ended = Event.durable({ + type: "session.compaction.ended", ...options, schema: { ...Base, - messageID: SessionMessage.ID, reason: Started.data.fields.reason, text: Schema.String, recent: Schema.String, @@ -479,60 +472,26 @@ export namespace Compaction { } export namespace RevertEvent { - export const Staged = Event.define({ - type: "session.next.revert.staged", + export const Staged = Event.durable({ + type: "session.revert.staged", ...options, schema: { ...Base, revert: Revert.State }, }) - export const Cleared = Event.define({ type: "session.next.revert.cleared", ...options, schema: Base }) - export const Committed = Event.define({ - type: "session.next.revert.committed", + export const Cleared = Event.durable({ type: "session.revert.cleared", ...options, schema: Base }) + export const Committed = Event.durable({ + type: "session.revert.committed", ...options, schema: { ...Base, messageID: SessionMessage.ID }, }) } -export const DurableDefinitions = Event.inventory( - AgentSwitched, - ModelSwitched, - Moved, - Renamed, - Forked, - Prompted, - PromptAdmitted, - ContextUpdated, - Synthetic, - Skill.Activated, - Shell.Started, - Shell.Ended, - Step.Started, - Step.Ended, - Step.Failed, - Text.Started, - Text.Ended, - Tool.Input.Started, - Tool.Input.Ended, - Tool.Called, - Tool.Progress, - Tool.Success, - Tool.Failed, - Reasoning.Started, - Reasoning.Ended, - Retried, - Compaction.Started, - Compaction.Ended, - RevertEvent.Staged, - RevertEvent.Cleared, - RevertEvent.Committed, -) - export const Definitions = Event.inventory( - AgentSwitched, - ModelSwitched, + AgentSelected, + ModelSelected, Moved, Renamed, Forked, - Prompted, + PromptPromoted, PromptAdmitted, ExecutionSettled, ContextUpdated, @@ -565,6 +524,10 @@ export const Definitions = Event.inventory( RevertEvent.Committed, ) +export const DurableDefinitions = Event.inventory( + ...Definitions.filter((definition) => definition.durability === "durable"), +) + export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }) .pipe(Schema.toTaggedUnion("type")) .annotate({ identifier: "SessionDurableEvent" }) diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index 457cf87206..c9f39193a5 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -8,10 +8,15 @@ import { FileAttachment, Prompt } from "./prompt.js" import { DateTimeUtcFromMillis, RelativePath, statics } from "./schema.js" import { SessionID } from "./session-id.js" import { ascending } from "./identifier.js" +import { Event } from "./event.js" +import { Shell as ShellSchema } from "./shell.js" export const ID = Schema.String.check(Schema.isStartsWith("msg_")).pipe( Schema.brand("Session.Message.ID"), - statics((schema) => ({ create: () => schema.make("msg_" + ascending()) })), + statics((schema) => ({ + create: () => schema.make("msg_" + ascending()), + fromEvent: (eventID: Event.ID) => schema.make(eventID.replace(/^evt_/, "msg_")), + })), ) export type ID = typeof ID.Type @@ -27,19 +32,20 @@ const Base = { time: Schema.Struct({ created: DateTimeUtcFromMillis }), } -export interface AgentSwitched extends Schema.Schema.Type {} -export const AgentSwitched = Schema.Struct({ +export interface AgentSelected extends Schema.Schema.Type {} +export const AgentSelected = Schema.Struct({ ...Base, type: Schema.Literal("agent-switched"), agent: Schema.String, -}).annotate({ identifier: "Session.Message.AgentSwitched" }) +}).annotate({ identifier: "Session.Message.AgentSelected" }) -export interface ModelSwitched extends Schema.Schema.Type {} -export const ModelSwitched = Schema.Struct({ +export interface ModelSelected extends Schema.Schema.Type {} +export const ModelSelected = Schema.Struct({ ...Base, type: Schema.Literal("model-switched"), model: Model.Ref, -}).annotate({ identifier: "Session.Message.ModelSwitched" }) + previous: Model.Ref.pipe(optional), +}).annotate({ identifier: "Session.Message.ModelSelected" }) export interface User extends Schema.Schema.Type {} export const User = Schema.Struct({ @@ -78,9 +84,8 @@ export interface Shell extends Schema.Schema.Type {} export const Shell = Schema.Struct({ ...Base, type: Schema.Literal("shell"), - callID: Schema.String, - command: Schema.String, - output: Schema.String, + shell: ShellSchema.Info, + output: ShellSchema.Output.pipe(optional), time: Schema.Struct({ created: DateTimeUtcFromMillis, completed: DateTimeUtcFromMillis.pipe(optional), @@ -207,8 +212,8 @@ export const Compaction = Schema.Struct({ }).annotate({ identifier: "Session.Message.Compaction" }) export const Message = Schema.Union([ - AgentSwitched, - ModelSwitched, + AgentSelected, + ModelSelected, User, Synthetic, System, @@ -219,5 +224,5 @@ export const Message = Schema.Union([ ]) .pipe(Schema.toTaggedUnion("type")) .annotate({ identifier: "Session.Message" }) -export type Message = AgentSwitched | ModelSwitched | User | Synthetic | System | Skill | Shell | Assistant | Compaction +export type Message = AgentSelected | ModelSelected | User | Synthetic | System | Skill | Shell | Assistant | Compaction export type Type = Message["type"] diff --git a/packages/schema/src/session-status-event.ts b/packages/schema/src/session-status-event.ts index 331c58ba1c..03e6940215 100644 --- a/packages/schema/src/session-status-event.ts +++ b/packages/schema/src/session-status-event.ts @@ -32,7 +32,7 @@ export const Info = Schema.Union([ ]).annotate({ identifier: "SessionStatus" }) export type Info = Schema.Schema.Type -export const Status = Event.define({ +export const Status = Event.ephemeral({ type: "session.status", schema: { sessionID: SessionID, @@ -41,7 +41,7 @@ export const Status = Event.define({ }) // deprecated -export const Idle = Event.define({ +export const Idle = Event.ephemeral({ type: "session.idle", schema: { sessionID: SessionID, diff --git a/packages/schema/src/session-todo.ts b/packages/schema/src/session-todo.ts index f72abcd17e..f4d68268ff 100644 --- a/packages/schema/src/session-todo.ts +++ b/packages/schema/src/session-todo.ts @@ -1,7 +1,7 @@ export * as SessionTodo from "./session-todo.js" import { Schema } from "effect" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" import { SessionID } from "./session-id.js" export const Info = Schema.Struct({ @@ -15,7 +15,7 @@ export const Info = Schema.Struct({ }).annotate({ identifier: "Todo" }) export interface Info extends Schema.Schema.Type {} -const Updated = define({ +const Updated = ephemeral({ type: "todo.updated", schema: { sessionID: SessionID, diff --git a/packages/schema/src/shell.ts b/packages/schema/src/shell.ts index a627a15950..fbe877c974 100644 --- a/packages/schema/src/shell.ts +++ b/packages/schema/src/shell.ts @@ -2,7 +2,7 @@ export * as Shell from "./shell.js" import { Schema } from "effect" import { optional } from "./schema.js" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" import { ascending } from "./identifier.js" import { NonNegativeInt, statics } from "./schema.js" @@ -49,9 +49,9 @@ export const Info = Schema.Struct({ }).annotate({ identifier: "Shell" }) export interface Info extends Schema.Schema.Type {} -const Created = define({ type: "shell.created", schema: { info: Info } }) -const Exited = define({ type: "shell.exited", schema: { id: ID, exit: optional(Schema.Number), status: Status } }) -const Deleted = define({ type: "shell.deleted", schema: { id: ID } }) +const Created = ephemeral({ type: "shell.created", schema: { info: Info } }) +const Exited = ephemeral({ type: "shell.exited", schema: { id: ID, exit: optional(Schema.Number), status: Status } }) +const Deleted = ephemeral({ type: "shell.deleted", schema: { id: ID } }) export const Event = { Created, Exited, Deleted, Definitions: inventory(Created, Exited, Deleted) } export const CreateInput = Schema.Struct({ diff --git a/packages/schema/src/skill.ts b/packages/schema/src/skill.ts index bf0dd7aa2b..184266f2ad 100644 --- a/packages/schema/src/skill.ts +++ b/packages/schema/src/skill.ts @@ -3,7 +3,7 @@ export * as Skill from "./skill.js" import { Schema } from "effect" import { optional } from "./schema.js" import { AbsolutePath } from "./schema.js" -import { define, inventory } from "./event.js" +import { ephemeral, inventory } from "./event.js" export interface DirectorySource extends Schema.Schema.Type {} export const DirectorySource = Schema.Struct({ @@ -27,7 +27,7 @@ export const Info = Schema.Struct({ content: Schema.String, }).annotate({ identifier: "SkillV2.Info" }) -const Updated = define({ type: "skill.updated", schema: {} }) +const Updated = ephemeral({ type: "skill.updated", schema: {} }) export const Event = { Updated, Definitions: inventory(Updated) } export interface EmbeddedSource extends Schema.Schema.Type {} diff --git a/packages/schema/src/tui-event.ts b/packages/schema/src/tui-event.ts index 0de2b6e369..ba9fd29ef8 100644 --- a/packages/schema/src/tui-event.ts +++ b/packages/schema/src/tui-event.ts @@ -8,9 +8,9 @@ import { SessionID } from "./session-id.js" const DEFAULT_TOAST_DURATION = 5000 -export const PromptAppend = Event.define({ type: "tui.prompt.append", schema: { text: Schema.String } }) +export const PromptAppend = Event.ephemeral({ type: "tui.prompt.append", schema: { text: Schema.String } }) -export const CommandExecute = Event.define({ +export const CommandExecute = Event.ephemeral({ type: "tui.command.execute", schema: { command: Schema.Union([ @@ -38,7 +38,7 @@ export const CommandExecute = Event.define({ }, }) -export const ToastShow = Event.define({ +export const ToastShow = Event.ephemeral({ type: "tui.toast.show", schema: { title: optional(Schema.String), @@ -50,7 +50,7 @@ export const ToastShow = Event.define({ }, }) -export const SessionSelect = Event.define({ +export const SessionSelect = Event.ephemeral({ type: "tui.session.select", schema: { sessionID: SessionID.annotate({ description: "Session ID to navigate to" }), diff --git a/packages/schema/src/v1/filesystem.ts b/packages/schema/src/v1/filesystem.ts new file mode 100644 index 0000000000..a1756fefd3 --- /dev/null +++ b/packages/schema/src/v1/filesystem.ts @@ -0,0 +1,11 @@ +export * as FileSystemV1 from "./filesystem.js" + +import { Schema } from "effect" +import { ephemeral, inventory } from "../event.js" + +const Edited = ephemeral({ + type: "file.edited", + schema: { file: Schema.String }, +}) + +export const Event = { Edited, Definitions: inventory(Edited) } diff --git a/packages/schema/src/v1/legacy-event.ts b/packages/schema/src/v1/legacy-event.ts index 12037e104f..ac22fff5e3 100644 --- a/packages/schema/src/v1/legacy-event.ts +++ b/packages/schema/src/v1/legacy-event.ts @@ -1,11 +1,11 @@ export * as LegacyEvent from "./legacy-event.js" import { Schema } from "effect" -import { define, inventory } from "../event.js" +import { ephemeral, inventory } from "../event.js" import { SessionID } from "../session-id.js" import { SessionV1 } from "./session.js" -export const CommandExecuted = define({ +export const CommandExecuted = ephemeral({ type: "command.executed", schema: { name: Schema.String, diff --git a/packages/schema/src/v1/permission.ts b/packages/schema/src/v1/permission.ts index af1d0098b8..1faa46a508 100644 --- a/packages/schema/src/v1/permission.ts +++ b/packages/schema/src/v1/permission.ts @@ -1,7 +1,7 @@ export * as PermissionV1 from "./permission.js" import { Schema } from "effect" -import { define, inventory } from "../event.js" +import { ephemeral, inventory } from "../event.js" import { ascending } from "../identifier.js" import { Project } from "../project.js" import { statics } from "../schema.js" @@ -58,8 +58,8 @@ export const ReplyInput = Schema.Struct({ requestID: ID, ...ReplyBody.fields }). }) export type ReplyInput = typeof ReplyInput.Type -const Asked = define({ type: "permission.asked", schema: Request.fields }) -const Replied = define({ +const Asked = ephemeral({ type: "permission.asked", schema: Request.fields }) +const Replied = ephemeral({ type: "permission.replied", schema: { sessionID: SessionID, requestID: ID, reply: Reply }, }) diff --git a/packages/schema/src/v1/question.ts b/packages/schema/src/v1/question.ts index 557646c44e..da47f37ffd 100644 --- a/packages/schema/src/v1/question.ts +++ b/packages/schema/src/v1/question.ts @@ -1,7 +1,7 @@ export * as QuestionV1 from "./question.js" import { Schema } from "effect" -import { define, inventory } from "../event.js" +import { ephemeral, inventory } from "../event.js" import { ascending } from "../identifier.js" import { statics } from "../schema.js" import { SessionID } from "../session-id.js" @@ -55,9 +55,9 @@ export const Rejected = Schema.Struct({ sessionID: SessionID, requestID: ID }).a identifier: "QuestionRejected", }) -const Asked = define({ type: "question.asked", schema: Request.fields }) -const RepliedEvent = define({ type: "question.replied", schema: Replied.fields }) -const RejectedEvent = define({ type: "question.rejected", schema: Rejected.fields }) +const Asked = ephemeral({ type: "question.asked", schema: Request.fields }) +const RepliedEvent = ephemeral({ type: "question.replied", schema: Replied.fields }) +const RejectedEvent = ephemeral({ type: "question.rejected", schema: Rejected.fields }) export const Event = { Asked, Replied: RepliedEvent, diff --git a/packages/schema/src/v1/session.ts b/packages/schema/src/v1/session.ts index 452ba3a186..1c2827f0d8 100644 --- a/packages/schema/src/v1/session.ts +++ b/packages/schema/src/v1/session.ts @@ -1,7 +1,7 @@ export * as SessionV1 from "./session.js" import { Effect, Schema, Types } from "effect" -import { define, inventory } from "../event.js" +import { durable, ephemeral, inventory } from "../event.js" import { FileDiff } from "../file-diff.js" import { Project } from "../project.js" import { Provider } from "../provider.js" @@ -569,7 +569,7 @@ export const SessionInfo = Schema.Struct({ export type SessionInfo = typeof SessionInfo.Type const events = { - Created: define({ + Created: durable({ type: "session.created", ...options, schema: { @@ -577,7 +577,7 @@ const events = { info: SessionInfo, }, }), - Updated: define({ + Updated: durable({ type: "session.updated", ...options, schema: { @@ -585,7 +585,7 @@ const events = { info: SessionInfo, }, }), - Deleted: define({ + Deleted: durable({ type: "session.deleted", ...options, schema: { @@ -593,7 +593,7 @@ const events = { info: SessionInfo, }, }), - MessageUpdated: define({ + MessageUpdated: durable({ type: "message.updated", ...options, schema: { @@ -601,7 +601,7 @@ const events = { info: Info, }, }), - MessageRemoved: define({ + MessageRemoved: durable({ type: "message.removed", ...options, schema: { @@ -609,7 +609,7 @@ const events = { messageID: MessageID, }, }), - PartUpdated: define({ + PartUpdated: durable({ type: "message.part.updated", ...options, schema: { @@ -618,7 +618,7 @@ const events = { time: Schema.Finite, }, }), - PartRemoved: define({ + PartRemoved: durable({ type: "message.part.removed", ...options, schema: { @@ -629,7 +629,7 @@ const events = { }), } -export const PartDelta = define({ +export const PartDelta = ephemeral({ type: "message.part.delta", schema: { sessionID: SessionID, @@ -640,7 +640,7 @@ export const PartDelta = define({ }, }) -export const Diff = define({ +export const Diff = ephemeral({ type: "session.diff", schema: { sessionID: SessionID, @@ -648,7 +648,7 @@ export const Diff = define({ }, }) -export const Error = define({ +export const Error = ephemeral({ type: "session.error", schema: { sessionID: Schema.optional(SessionID), diff --git a/packages/schema/src/vcs-event.ts b/packages/schema/src/vcs-event.ts index 2428b432b4..5ece3cd844 100644 --- a/packages/schema/src/vcs-event.ts +++ b/packages/schema/src/vcs-event.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { optional } from "./schema.js" import { Event } from "./event.js" -export const BranchUpdated = Event.define({ +export const BranchUpdated = Event.ephemeral({ type: "vcs.branch.updated", schema: { branch: optional(Schema.String), diff --git a/packages/schema/src/workspace-event.ts b/packages/schema/src/workspace-event.ts index 6468a4410c..2b6a752ef5 100644 --- a/packages/schema/src/workspace-event.ts +++ b/packages/schema/src/workspace-event.ts @@ -10,21 +10,21 @@ export const ConnectionStatus = Schema.Struct({ }).annotate({ identifier: "WorkspaceEvent.ConnectionStatus" }) export interface ConnectionStatus extends Schema.Schema.Type {} -export const Ready = Event.define({ +export const Ready = Event.ephemeral({ type: "workspace.ready", schema: { name: Schema.String, }, }) -export const Failed = Event.define({ +export const Failed = Event.ephemeral({ type: "workspace.failed", schema: { message: Schema.String, }, }) -export const Status = Event.define({ +export const Status = Event.ephemeral({ type: "workspace.status", schema: ConnectionStatus.fields, }) diff --git a/packages/schema/src/worktree-event.ts b/packages/schema/src/worktree-event.ts index 2acb72fe7e..db80d794e5 100644 --- a/packages/schema/src/worktree-event.ts +++ b/packages/schema/src/worktree-event.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { optional } from "./schema.js" import { Event } from "./event.js" -export const Ready = Event.define({ +export const Ready = Event.ephemeral({ type: "worktree.ready", schema: { name: Schema.String, @@ -12,7 +12,7 @@ export const Ready = Event.define({ }, }) -export const Failed = Event.define({ +export const Failed = Event.ephemeral({ type: "worktree.failed", schema: { message: Schema.String, diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 452b0ac149..da344ba528 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -1,7 +1,21 @@ import { describe, expect, test } from "bun:test" -import { Agent, FileSystem, Form, Integration, Permission, Project, Reference, Session, Workspace } from "../src/index.js" +import { + Agent, + Config, + FileSystem, + Form, + Integration, + Permission, + Project, + Reference, + Session, + Workspace, +} from "../src/index.js" import { EventManifest } from "../src/event-manifest.js" +import { FileSystemV1 } from "../src/filesystem-v1.js" import { IdeEvent } from "../src/ide-event.js" +import { McpEvent } from "../src/mcp-event.js" +import { Plugin } from "../src/plugin.js" import { SessionEvent } from "../src/session-event.js" import { SessionTodo } from "../src/session-todo.js" import { SessionV1 } from "../src/session-v1.js" @@ -9,11 +23,11 @@ import { WorkspaceEvent } from "../src/workspace-event.js" describe("public event manifest", () => { test("owns the complete public event surface", () => { - expect(EventManifest.ServerDefinitions.filter((definition) => definition.type !== "agent.updated").length).toBe(86) + expect(EventManifest.ServerDefinitions).toContain(Agent.Event.Updated) expect(EventManifest.ServerDefinitions.filter((definition) => definition.type === "agent.updated")).toEqual([ Agent.Event.Updated, ]) - expect(EventManifest.Definitions.filter((definition) => definition.type !== "agent.updated").length).toBe(101) + expect(EventManifest.Definitions).toContain(Agent.Event.Updated) expect(EventManifest.Definitions.filter((definition) => definition.type === "agent.updated")).toEqual([ Agent.Event.Updated, ]) @@ -29,8 +43,13 @@ describe("public event manifest", () => { SessionV1.Event.Diff, SessionV1.Event.Error, ]) - expect(Array.from(EventManifest.Latest.keys()).filter((type) => type !== "agent.updated").length).toBe(101) + expect(Array.from(EventManifest.Latest.keys())).toEqual( + EventManifest.Definitions.map((definition) => definition.type), + ) expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated) + expect(EventManifest.Latest.get("plugin.updated")).toBe(Plugin.Event.Updated) + expect(EventManifest.Server.get("mcp.status.changed")).toBe(McpEvent.StatusChanged) + expect(EventManifest.Server.has("mcp.tools.changed")).toBe(false) expect(Agent.Event.Updated.durable).toBeUndefined() expect(EventManifest.Durable.has("agent.updated")).toBe(false) }) @@ -40,17 +59,22 @@ describe("public event manifest", () => { expect(Session.Event.Definitions).toBe(SessionEvent.Definitions) expect(Workspace.Event).toBe(WorkspaceEvent) expect(Workspace.Event.Definitions).toBe(WorkspaceEvent.Definitions) - expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended) + expect(EventManifest.Latest.get("session.step.ended")).toBe(SessionEvent.Step.Ended) expect(EventManifest.Latest.get("todo.updated")).toBe(SessionTodo.Event.Updated) expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated) expect(EventManifest.Latest.get("project.updated")).toBe(Project.Event.Updated) expect(Agent.Event.Definitions).toEqual([Agent.Event.Updated]) expect(Project.Event.Definitions).toEqual([Project.Event.Updated]) - expect(FileSystem.Event.Definitions).toEqual([FileSystem.Event.Edited]) + expect(Config.Event.Definitions).toEqual([Config.Event.Updated]) + expect(FileSystem.Event.Definitions).toEqual([FileSystem.Event.Changed]) + expect(FileSystemV1.Event.Definitions).toEqual([FileSystemV1.Event.Edited]) expect(Integration.Event.Definitions).toEqual([Integration.Event.Updated, Integration.Event.ConnectionUpdated]) expect(Permission.Event.Definitions).toEqual([Permission.Event.Asked, Permission.Event.Replied]) expect(Form.Event.Definitions).toEqual([Form.Event.Created, Form.Event.Replied, Form.Event.Cancelled]) expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated]) + expect(Plugin.Event.Definitions).toEqual([Plugin.Event.Added, Plugin.Event.Updated]) + expect(McpEvent.Definitions).toEqual([McpEvent.ToolsChanged, McpEvent.StatusChanged]) + expect(EventManifest.Latest.has("mcp.browser.open.failed")).toBe(false) expect(EventManifest.Latest.has("ide.installed")).toBe(false) expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed]) const sessionV1TailStart = EventManifest.Definitions.indexOf(SessionV1.Event.PartDelta) @@ -59,7 +83,56 @@ describe("public event manifest", () => { SessionV1.Event.Diff, SessionV1.Event.Error, ]) - expect(EventManifest.Durable.has("session.next.step.ended.1")).toBe(false) - expect(EventManifest.Durable.get("session.next.step.ended.2")).toBe(SessionEvent.Step.Ended) + expect(EventManifest.Durable.get("session.step.ended.1")).toBe(SessionEvent.Step.Ended) + expect(EventManifest.Durable.has("session.step.ended.2")).toBe(false) + }) + + test("derives durable definitions from explicit definition durability", () => { + expect(Array.from(EventManifest.Durable.keys()).toSorted()).toEqual( + [ + "session.created.1", + "session.updated.1", + "session.deleted.1", + "message.updated.1", + "message.removed.1", + "message.part.updated.1", + "message.part.removed.1", + "session.agent.selected.1", + "session.model.selected.1", + "session.moved.1", + "session.renamed.1", + "session.forked.1", + "session.prompt.promoted.1", + "session.prompt.admitted.1", + "session.context.updated.1", + "session.synthetic.1", + "session.skill.activated.1", + "session.shell.started.1", + "session.shell.ended.1", + "session.step.started.1", + "session.step.ended.1", + "session.step.failed.1", + "session.text.started.1", + "session.text.ended.1", + "session.tool.input.started.1", + "session.tool.input.ended.1", + "session.tool.called.1", + "session.tool.progress.1", + "session.tool.success.1", + "session.tool.failed.1", + "session.reasoning.started.1", + "session.reasoning.ended.1", + "session.retried.1", + "session.compaction.started.1", + "session.compaction.ended.1", + "session.revert.staged.1", + "session.revert.cleared.1", + "session.revert.committed.1", + ].toSorted(), + ) + expect(SessionEvent.DurableDefinitions).toEqual( + SessionEvent.Definitions.filter((definition) => definition.durability === "durable"), + ) + expect(EventManifest.Definitions.every((definition) => definition.durability !== undefined)).toBe(true) }) }) diff --git a/packages/schema/test/event.test.ts b/packages/schema/test/event.test.ts index a05dca0c6e..404b49aa74 100644 --- a/packages/schema/test/event.test.ts +++ b/packages/schema/test/event.test.ts @@ -6,17 +6,17 @@ import { EventLog } from "../src/event-log.js" describe("public event schemas", () => { test("definition is pure", () => { const definitions = Event.inventory() - Event.define({ type: "test.pure", schema: { value: Schema.String } }) + Event.ephemeral({ type: "test.pure", schema: { value: Schema.String } }) expect(definitions).toEqual([]) }) test("latest selection is independent of declaration order", () => { - const historical = Event.define({ + const historical = Event.durable({ type: "test.versioned", durable: { aggregate: "id", version: 1 }, schema: { id: Schema.String }, }) - const current = Event.define({ + const current = Event.durable({ type: "test.versioned", durable: { aggregate: "id", version: 2 }, schema: { id: Schema.String, value: Schema.String }, @@ -27,13 +27,13 @@ describe("public event schemas", () => { }) test("durable definitions are indexed by type and version", () => { - const definition = Event.define({ + const definition = Event.durable({ type: "test.durable", durable: { aggregate: "id", version: 1 }, schema: { id: Schema.String }, }) - expect(Event.durable([definition]).get("test.durable.1")).toBe(definition) + expect(Event.durableMap([definition]).get("test.durable.1")).toBe(definition) }) test("synced marker encodes the captured watermark", () => { diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts index 4737852ff4..087f689366 100644 --- a/packages/sdk-next/src/opencode.ts +++ b/packages/sdk-next/src/opencode.ts @@ -3,6 +3,7 @@ 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 { SdkPlugins } from "@opencode-ai/core/plugin/sdk" +import { Project } from "@opencode-ai/core/project" import { createEmbeddedRoutes } from "@opencode-ai/server/routes" import { Context, Effect, Layer, Scope } from "effect" import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http" @@ -12,7 +13,7 @@ export const create = Effect.fn("OpenCode.create")(function* () { const memoMap = yield* Layer.makeMemoMap const sdkPlugins = SdkPlugins.makeStore() const context = yield* Layer.buildWithMemoMap( - AppNodeBuilder.build(LayerNode.group([PermissionSaved.node, SdkPlugins.node]), [ + AppNodeBuilder.build(LayerNode.group([PermissionSaved.node, Project.node, SdkPlugins.node]), [ [SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)], ]), memoMap, @@ -20,11 +21,13 @@ export const create = Effect.fn("OpenCode.create")(function* () { ) const plugins = Context.get(context, SdkPlugins.Service) const permissions = Context.get(context, PermissionSaved.Service) + const project = Context.get(context, Project.Service) const web = yield* Effect.acquireRelease( Effect.sync(() => HttpRouter.toWebHandler( createEmbeddedRoutes(sdkPlugins).pipe( HttpRouter.provideRequest(Layer.succeed(PermissionSaved.Service, permissions)), + HttpRouter.provideRequest(Layer.succeed(Project.Service, project)), Layer.provide(HttpServer.layerServices), ), { disableLogger: true, memoMap }, diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index 572cdd9665..4ac31b70ab 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -78,7 +78,7 @@ it.live( prompt: fixture.sdk.Prompt.make({ text: "Promote this input" }), }) const prompted = yield* opencode.sessions.log({ sessionID: id, follow: true }).pipe( - Stream.filter((event) => event.type === "session.next.prompted" && event.data.messageID === wake.id), + Stream.filter((event) => event.type === "session.prompt.promoted" && event.data.inputID === wake.id), Stream.runHead, Effect.timeout("10 seconds"), Effect.map(Option.getOrThrow), @@ -119,7 +119,7 @@ it.live( expect(page.data.some((session) => session.id === id)).toBe(true) expect(active).toEqual({ data: {}, watermarks: {} }) expect(admitted.sessionID).toBe(id) - expect(prompted.type).toBe("session.next.prompted") + expect(prompted.type).toBe("session.prompt.promoted") expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" })) expect(contextEntries).toEqual([ { key: "deploy-target", value: "production" }, @@ -127,7 +127,7 @@ it.live( ]) expect(remainingContextEntries).toEqual([{ key: "deploy-target", value: "production" }]) expect(context.some((message) => message.type === "model-switched")).toBe(true) - expect(event).toMatchObject({ type: "session.next.model.switched", durable: { seq: 1 } }) + expect(event).toMatchObject({ type: "session.model.selected", durable: { seq: 1 } }) expect(message).toEqual(modelMessage) expect(missing.map((error) => error._tag)).toEqual([ "SessionNotFoundError", @@ -149,13 +149,13 @@ it.live( const opencode = yield* fixture.sdk.OpenCode.create() const id = sessionID(fixture) const connected = yield* Latch.make(false) - const prompted = yield* Deferred.make() + const prompted = yield* Deferred.make>() yield* opencode.events.subscribe().pipe( Stream.runForEach((event) => event.type === "server.connected" ? connected.open - : event.type === "session.next.prompted" && event.data.sessionID === id + : event.type === "session.prompt.promoted" && event.data.sessionID === id ? Deferred.succeed(prompted, event).pipe(Effect.asVoid) : Effect.void, ), @@ -191,7 +191,7 @@ it.live( Stream.runForEach((notification: OpenCodeEvent) => notification.type === "server.connected" ? ready.open - : notification.type === "session.next.agent.switched" && notification.data.sessionID === id + : notification.type === "session.agent.selected" && notification.data.sessionID === id ? event.open : Effect.void, ) diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index 8fc0f363e7..220bf43da3 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -59,7 +59,13 @@ if (schemas) { } visit({ ...document, components: { ...document.components, schemas: undefined } }) for (const name of Object.keys(schemas)) { - if (/^SessionNext\w+1$/.test(name) && !reachable.has(name)) delete schemas[name] + if ( + /^(SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionSettled|SessionContextUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionReasoningStarted|SessionReasoningDelta|SessionReasoningEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetried|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)1$/.test( + name, + ) && + !reachable.has(name) + ) + delete schemas[name] } await Bun.write("./openapi.json", JSON.stringify(document)) } @@ -93,7 +99,11 @@ await createClient({ const generatedTypesPath = "./src/v2/gen/types.gen.ts" const generatedTypes = await Bun.file(generatedTypesPath).text() -if (/export type SessionNext\w+1 =/.test(generatedTypes)) { +if ( + /export type (SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionSettled|SessionContextUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionReasoningStarted|SessionReasoningDelta|SessionReasoningEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetried|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)1 =/.test( + generatedTypes, + ) +) { throw new Error("Session history generated duplicate Session event variants") } const logTypesPatched = generatedTypes.replace( diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 1c896d80a3..3e47d81125 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -276,6 +276,8 @@ import type { V2CredentialRemoveResponses, V2CredentialUpdateErrors, V2CredentialUpdateResponses, + V2DebugLocationErrors, + V2DebugLocationResponses, V2EventChangesErrors, V2EventChangesResponses, V2EventSubscribeErrors, @@ -332,6 +334,8 @@ import type { V2ProjectCurrentResponses, V2ProjectDirectoriesErrors, V2ProjectDirectoriesResponses, + V2ProjectListErrors, + V2ProjectListResponses, V2ProviderGetErrors, V2ProviderGetResponses, V2ProviderListErrors, @@ -422,6 +426,8 @@ import type { V2SessionRevertCommitResponses, V2SessionRevertStageErrors, V2SessionRevertStageResponses, + V2SessionShellErrors, + V2SessionShellResponses, V2SessionSkillErrors, V2SessionSkillResponses, V2SessionSwitchAgentErrors, @@ -6240,6 +6246,43 @@ export class Session3 extends HeyApiClient { }) } + /** + * Run shell command + * + * Execute one shell command in the session's working directory. Emits a shell.started event before execution and a shell.ended event with the merged output after. + */ + public shell( + parameters: { + sessionID: string + id?: string | null + command?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "id" }, + { in: "body", key: "command" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/shell", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Compact session * @@ -6991,6 +7034,18 @@ export class Credential extends HeyApiClient { } export class Project2 extends HeyApiClient { + /** + * List projects + * + * List known projects. + */ + public list(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/api/project", + ...options, + }) + } + /** * Get current project * @@ -7997,6 +8052,20 @@ export class Vcs2 extends HeyApiClient { } } +export class Debug extends HeyApiClient { + /** + * List loaded locations + * + * List locations currently loaded by the server. + */ + public location(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/api/debug/location", + ...options, + }) + } +} + export class V2 extends HeyApiClient { private _health?: Health get health(): Health { @@ -8117,6 +8186,11 @@ export class V2 extends HeyApiClient { get vcs(): Vcs2 { return (this._vcs ??= new Vcs2({ client: this.client })) } + + private _debug?: Debug + get debug(): Debug { + return (this._debug ??= new Debug({ client: this.client })) + } } export class OpencodeClient extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 199da657b0..4612006d25 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -17,56 +17,57 @@ export type Event = | EventMessageRemoved | EventMessagePartUpdated | EventMessagePartRemoved - | EventSessionNextAgentSwitched - | EventSessionNextModelSwitched - | EventSessionNextMoved - | EventSessionNextRenamed - | EventSessionNextForked - | EventSessionNextPrompted - | EventSessionNextPromptAdmitted - | EventSessionNextExecutionSettled - | EventSessionNextContextUpdated - | EventSessionNextSynthetic - | EventSessionNextSkillActivated - | EventSessionNextShellStarted - | EventSessionNextShellEnded - | EventSessionNextStepStarted - | EventSessionNextStepEnded - | EventSessionNextStepFailed - | EventSessionNextTextStarted - | EventSessionNextTextDelta - | EventSessionNextTextEnded - | EventSessionNextReasoningStarted - | EventSessionNextReasoningDelta - | EventSessionNextReasoningEnded - | EventSessionNextToolInputStarted - | EventSessionNextToolInputDelta - | EventSessionNextToolInputEnded - | EventSessionNextToolCalled - | EventSessionNextToolProgress - | EventSessionNextToolSuccess - | EventSessionNextToolFailed - | EventSessionNextRetried - | EventSessionNextCompactionStarted - | EventSessionNextCompactionDelta - | EventSessionNextCompactionEnded - | EventSessionNextRevertStaged - | EventSessionNextRevertCleared - | EventSessionNextRevertCommitted + | EventSessionAgentSelected + | EventSessionModelSelected + | EventSessionMoved + | EventSessionRenamed + | EventSessionForked + | EventSessionPromptPromoted + | EventSessionPromptAdmitted + | EventSessionExecutionSettled + | EventSessionContextUpdated + | EventSessionSynthetic + | EventSessionSkillActivated + | EventSessionShellStarted + | EventSessionShellEnded + | EventSessionStepStarted + | EventSessionStepEnded + | EventSessionStepFailed + | EventSessionTextStarted + | EventSessionTextDelta + | EventSessionTextEnded + | EventSessionReasoningStarted + | EventSessionReasoningDelta + | EventSessionReasoningEnded + | EventSessionToolInputStarted + | EventSessionToolInputDelta + | EventSessionToolInputEnded + | EventSessionToolCalled + | EventSessionToolProgress + | EventSessionToolSuccess + | EventSessionToolFailed + | EventSessionRetried + | EventSessionCompactionStarted + | EventSessionCompactionDelta + | EventSessionCompactionEnded + | EventSessionRevertStaged + | EventSessionRevertCleared + | EventSessionRevertCommitted | EventMessagePartDelta | EventSessionDiff | EventSessionError | EventInstallationUpdated | EventInstallationUpdateAvailable - | EventFileEdited + | EventFilesystemChanged | EventReferenceUpdated | EventPermissionV2Asked | EventPermissionV2Replied | EventPluginAdded + | EventPluginUpdated | EventProjectDirectoriesUpdated | EventCommandUpdated + | EventConfigUpdated | EventSkillUpdated - | EventFileWatcherUpdated | EventPtyCreated | EventPtyUpdated | EventPtyExited @@ -89,9 +90,9 @@ export type Event = | EventTuiToastShow2 | EventTuiSessionSelect2 | EventMcpToolsChanged - | EventMcpBrowserOpenFailed | EventMcpStatusChanged | EventCommandExecuted + | EventFileEdited | EventProjectUpdated | EventSessionStatus | EventSessionIdle @@ -658,17 +659,6 @@ export type Prompt = { agents?: Array } -export type Pty = { - id: string - title: string - command: string - args: Array - cwd: string - status: "running" | "exited" - pid: number - exitCode?: number -} - export type Shell = { id: string status: "running" | "exited" | "timeout" | "killed" @@ -687,6 +677,17 @@ export type Shell = { } } +export type Pty = { + id: string + title: string + command: string + args: Array + cwd: string + status: "running" | "exited" + pid: number + exitCode?: number +} + export type Todo = { /** * Brief description of the task @@ -859,80 +860,68 @@ export type GlobalEvent = { } | { id: string - type: "session.next.agent.switched" + type: "session.agent.selected" properties: { - timestamp: number sessionID: string - messageID: string agent: string } } | { id: string - type: "session.next.model.switched" + type: "session.model.selected" properties: { - timestamp: number sessionID: string - messageID: string model: ModelRef } } | { id: string - type: "session.next.moved" + type: "session.moved" properties: { - timestamp: number sessionID: string location: LocationRef - subdirectory?: string + subpath?: string } } | { id: string - type: "session.next.renamed" + type: "session.renamed" properties: { - timestamp: number sessionID: string title: string } } | { id: string - type: "session.next.forked" + type: "session.forked" properties: { - timestamp: number sessionID: string parentID: string - messageID?: string + from?: string } } | { id: string - type: "session.next.prompted" + type: "session.prompt.promoted" properties: { - timestamp: number sessionID: string - messageID: string + inputID: string + } + } + | { + id: string + type: "session.prompt.admitted" + properties: { + sessionID: string + inputID: string prompt: Prompt delivery: "steer" | "queue" } } | { id: string - type: "session.next.prompt.admitted" + type: "session.execution.settled" properties: { - timestamp: number - sessionID: string - messageID: string - prompt: Prompt - delivery: "steer" | "queue" - } - } - | { - id: string - type: "session.next.execution.settled" - properties: { - timestamp: number sessionID: string outcome: "success" | "failure" | "interrupted" error?: SessionErrorUnknown @@ -940,21 +929,17 @@ export type GlobalEvent = { } | { id: string - type: "session.next.context.updated" + type: "session.context.updated" properties: { - timestamp: number sessionID: string - messageID: string text: string } } | { id: string - type: "session.next.synthetic" + type: "session.synthetic" properties: { - timestamp: number sessionID: string - messageID: string text: string description?: string metadata?: { @@ -964,41 +949,39 @@ export type GlobalEvent = { } | { id: string - type: "session.next.skill.activated" + type: "session.skill.activated" properties: { - timestamp: number sessionID: string - messageID: string name: string text: string } } | { id: string - type: "session.next.shell.started" + type: "session.shell.started" properties: { - timestamp: number sessionID: string - messageID: string - callID: string - command: string + shell: Shell } } | { id: string - type: "session.next.shell.ended" + type: "session.shell.ended" properties: { - timestamp: number sessionID: string - callID: string - output: string + shell: Shell + output: { + output: string + cursor: number + size: number + truncated: boolean + } } } | { id: string - type: "session.next.step.started" + type: "session.step.started" properties: { - timestamp: number sessionID: string assistantMessageID: string agent: string @@ -1008,9 +991,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.step.ended" + type: "session.step.ended" properties: { - timestamp: number sessionID: string assistantMessageID: string finish: string @@ -1030,9 +1012,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.step.failed" + type: "session.step.failed" properties: { - timestamp: number sessionID: string assistantMessageID: string error: SessionErrorUnknown @@ -1040,9 +1021,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.text.started" + type: "session.text.started" properties: { - timestamp: number sessionID: string assistantMessageID: string textID: string @@ -1050,9 +1030,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.text.delta" + type: "session.text.delta" properties: { - timestamp: number sessionID: string assistantMessageID: string textID: string @@ -1061,9 +1040,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.text.ended" + type: "session.text.ended" properties: { - timestamp: number sessionID: string assistantMessageID: string textID: string @@ -1072,9 +1050,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.reasoning.started" + type: "session.reasoning.started" properties: { - timestamp: number sessionID: string assistantMessageID: string reasoningID: string @@ -1083,9 +1060,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.reasoning.delta" + type: "session.reasoning.delta" properties: { - timestamp: number sessionID: string assistantMessageID: string reasoningID: string @@ -1094,9 +1070,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.reasoning.ended" + type: "session.reasoning.ended" properties: { - timestamp: number sessionID: string assistantMessageID: string reasoningID: string @@ -1106,9 +1081,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.tool.input.started" + type: "session.tool.input.started" properties: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -1117,9 +1091,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.tool.input.delta" + type: "session.tool.input.delta" properties: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -1128,9 +1101,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.tool.input.ended" + type: "session.tool.input.ended" properties: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -1139,9 +1111,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.tool.called" + type: "session.tool.called" properties: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -1157,9 +1128,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.tool.progress" + type: "session.tool.progress" properties: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -1171,9 +1141,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.tool.success" + type: "session.tool.success" properties: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -1191,9 +1160,8 @@ export type GlobalEvent = { } | { id: string - type: "session.next.tool.failed" + type: "session.tool.failed" properties: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -1207,41 +1175,34 @@ export type GlobalEvent = { } | { id: string - type: "session.next.retried" + type: "session.retried" properties: { - timestamp: number sessionID: string attempt: number - error: SessionNextRetryError + error: SessionRetryError } } | { id: string - type: "session.next.compaction.started" + type: "session.compaction.started" properties: { - timestamp: number sessionID: string - messageID: string reason: "auto" | "manual" } } | { id: string - type: "session.next.compaction.delta" + type: "session.compaction.delta" properties: { - timestamp: number sessionID: string - messageID: string text: string } } | { id: string - type: "session.next.compaction.ended" + type: "session.compaction.ended" properties: { - timestamp: number sessionID: string - messageID: string reason: "auto" | "manual" text: string recent: string @@ -1249,26 +1210,23 @@ export type GlobalEvent = { } | { id: string - type: "session.next.revert.staged" + type: "session.revert.staged" properties: { - timestamp: number sessionID: string revert: RevertState } } | { id: string - type: "session.next.revert.cleared" + type: "session.revert.cleared" properties: { - timestamp: number sessionID: string } } | { id: string - type: "session.next.revert.committed" + type: "session.revert.committed" properties: { - timestamp: number sessionID: string messageID: string } @@ -1324,9 +1282,10 @@ export type GlobalEvent = { } | { id: string - type: "file.edited" + type: "filesystem.changed" properties: { file: string + event: "add" | "change" | "unlink" } } | { @@ -1367,6 +1326,13 @@ export type GlobalEvent = { id: string } } + | { + id: string + type: "plugin.updated" + properties: { + [key: string]: unknown + } + } | { id: string type: "project.directories.updated" @@ -1383,17 +1349,16 @@ export type GlobalEvent = { } | { id: string - type: "skill.updated" + type: "config.updated" properties: { [key: string]: unknown } } | { id: string - type: "file.watcher.updated" + type: "skill.updated" properties: { - file: string - event: "add" | "change" | "unlink" + [key: string]: unknown } } | { @@ -1603,14 +1568,6 @@ export type GlobalEvent = { server: string } } - | { - id: string - type: "mcp.browser.open.failed" - properties: { - mcpName: string - url: string - } - } | { id: string type: "mcp.status.changed" @@ -1628,6 +1585,13 @@ export type GlobalEvent = { messageID: string } } + | { + id: string + type: "file.edited" + properties: { + file: string + } + } | { id: string type: "project.updated" @@ -1760,37 +1724,37 @@ export type GlobalEvent = { | SyncEventMessageRemoved | SyncEventMessagePartUpdated | SyncEventMessagePartRemoved - | SyncEventSessionNextAgentSwitched - | SyncEventSessionNextModelSwitched - | SyncEventSessionNextMoved - | SyncEventSessionNextRenamed - | SyncEventSessionNextForked - | SyncEventSessionNextPrompted - | SyncEventSessionNextPromptAdmitted - | SyncEventSessionNextContextUpdated - | SyncEventSessionNextSynthetic - | SyncEventSessionNextSkillActivated - | SyncEventSessionNextShellStarted - | SyncEventSessionNextShellEnded - | SyncEventSessionNextStepStarted - | SyncEventSessionNextStepEnded - | SyncEventSessionNextStepFailed - | SyncEventSessionNextTextStarted - | SyncEventSessionNextTextEnded - | SyncEventSessionNextReasoningStarted - | SyncEventSessionNextReasoningEnded - | SyncEventSessionNextToolInputStarted - | SyncEventSessionNextToolInputEnded - | SyncEventSessionNextToolCalled - | SyncEventSessionNextToolProgress - | SyncEventSessionNextToolSuccess - | SyncEventSessionNextToolFailed - | SyncEventSessionNextRetried - | SyncEventSessionNextCompactionStarted - | SyncEventSessionNextCompactionEnded - | SyncEventSessionNextRevertStaged - | SyncEventSessionNextRevertCleared - | SyncEventSessionNextRevertCommitted + | SyncEventSessionAgentSelected + | SyncEventSessionModelSelected + | SyncEventSessionMoved + | SyncEventSessionRenamed + | SyncEventSessionForked + | SyncEventSessionPromptPromoted + | SyncEventSessionPromptAdmitted + | SyncEventSessionContextUpdated + | SyncEventSessionSynthetic + | SyncEventSessionSkillActivated + | SyncEventSessionShellStarted + | SyncEventSessionShellEnded + | SyncEventSessionStepStarted + | SyncEventSessionStepEnded + | SyncEventSessionStepFailed + | SyncEventSessionTextStarted + | SyncEventSessionTextEnded + | SyncEventSessionReasoningStarted + | SyncEventSessionReasoningEnded + | SyncEventSessionToolInputStarted + | SyncEventSessionToolInputEnded + | SyncEventSessionToolCalled + | SyncEventSessionToolProgress + | SyncEventSessionToolSuccess + | SyncEventSessionToolFailed + | SyncEventSessionRetried + | SyncEventSessionCompactionStarted + | SyncEventSessionCompactionEnded + | SyncEventSessionRevertStaged + | SyncEventSessionRevertCleared + | SyncEventSessionRevertCommitted } /** @@ -2175,7 +2139,6 @@ export type Config = { primary_tools?: Array continue_loop_on_deny?: boolean mcp_timeout?: number - policies?: Array } } @@ -2910,38 +2873,56 @@ export type UnknownError1 = { ref?: string } +export type Shell1 = { + id: string + status: "running" | "exited" | "timeout" | "killed" + command: string + cwd: string + shell: string + file: string + pid?: number + exit?: number | "NaN" | "Infinity" | "-Infinity" + metadata: { + [key: string]: unknown + } + time: { + started: number | "NaN" | "Infinity" | "-Infinity" + completed?: number | "NaN" | "Infinity" | "-Infinity" + } +} + export type SessionDurableEvent = - | SessionNextAgentSwitched - | SessionNextModelSwitched - | SessionNextMoved - | SessionNextRenamed - | SessionNextForked - | SessionNextPrompted - | SessionNextPromptAdmitted - | SessionNextContextUpdated - | SessionNextSynthetic - | SessionNextSkillActivated - | SessionNextShellStarted - | SessionNextShellEnded - | SessionNextStepStarted - | SessionNextStepEnded - | SessionNextStepFailed - | SessionNextTextStarted - | SessionNextTextEnded - | SessionNextToolInputStarted - | SessionNextToolInputEnded - | SessionNextToolCalled - | SessionNextToolProgress - | SessionNextToolSuccess - | SessionNextToolFailed - | SessionNextReasoningStarted - | SessionNextReasoningEnded - | SessionNextRetried - | SessionNextCompactionStarted - | SessionNextCompactionEnded - | SessionNextRevertStaged - | SessionNextRevertCleared - | SessionNextRevertCommitted + | SessionAgentSelected + | SessionModelSelected + | SessionMoved + | SessionRenamed + | SessionForked + | SessionPromptPromoted + | SessionPromptAdmitted + | SessionContextUpdated + | SessionSynthetic + | SessionSkillActivated + | SessionShellStarted + | SessionShellEnded + | SessionStepStarted + | SessionStepEnded + | SessionStepFailed + | SessionTextStarted + | SessionTextEnded + | SessionReasoningStarted + | SessionReasoningEnded + | SessionToolInputStarted + | SessionToolInputEnded + | SessionToolCalled + | SessionToolProgress + | SessionToolSuccess + | SessionToolFailed + | SessionRetried + | SessionCompactionStarted + | SessionCompactionEnded + | SessionRevertStaged + | SessionRevertCleared + | SessionRevertCommitted export type SessionLogItem = SessionDurableEvent | EventLogSynced @@ -2996,35 +2977,13 @@ export type OutputFormat1 = retryCount?: number } -export type Shell1 = { - id: string - status: "running" | "exited" | "timeout" | "killed" - command: string - cwd: string - shell: string - file: string - pid?: number - exit?: number | "NaN" | "Infinity" | "-Infinity" - metadata: { - [key: string]: unknown - } - time: { - started: number | "NaN" | "Infinity" | "-Infinity" - completed?: number | "NaN" | "Infinity" | "-Infinity" - } -} - export type SessionStatus2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "session.status" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { sessionID: string @@ -3034,15 +2993,11 @@ export type SessionStatus2 = { export type QuestionReplied2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "question.replied" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { sessionID: string @@ -3053,15 +3008,11 @@ export type QuestionReplied2 = { export type QuestionRejected2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "question.rejected" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { sessionID: string @@ -3082,56 +3033,57 @@ export type V2Event = | MessageRemoved | MessagePartUpdated | MessagePartRemoved - | SessionNextAgentSwitched - | SessionNextModelSwitched - | SessionNextMoved - | SessionNextRenamed - | SessionNextForked - | SessionNextPrompted - | SessionNextPromptAdmitted - | SessionNextExecutionSettled - | SessionNextContextUpdated - | SessionNextSynthetic - | SessionNextSkillActivated - | SessionNextShellStarted - | SessionNextShellEnded - | SessionNextStepStarted - | SessionNextStepEnded - | SessionNextStepFailed - | SessionNextTextStarted - | SessionNextTextDelta - | SessionNextTextEnded - | SessionNextReasoningStarted - | SessionNextReasoningDelta - | SessionNextReasoningEnded - | SessionNextToolInputStarted - | SessionNextToolInputDelta - | SessionNextToolInputEnded - | SessionNextToolCalled - | SessionNextToolProgress - | SessionNextToolSuccess - | SessionNextToolFailed - | SessionNextRetried - | SessionNextCompactionStarted - | SessionNextCompactionDelta - | SessionNextCompactionEnded - | SessionNextRevertStaged - | SessionNextRevertCleared - | SessionNextRevertCommitted + | SessionAgentSelected + | SessionModelSelected + | SessionMoved + | SessionRenamed + | SessionForked + | SessionPromptPromoted + | SessionPromptAdmitted + | SessionExecutionSettled + | SessionContextUpdated + | SessionSynthetic + | SessionSkillActivated + | SessionShellStarted + | SessionShellEnded + | SessionStepStarted + | SessionStepEnded + | SessionStepFailed + | SessionTextStarted + | SessionTextDelta + | SessionTextEnded + | SessionReasoningStarted + | SessionReasoningDelta + | SessionReasoningEnded + | SessionToolInputStarted + | SessionToolInputDelta + | SessionToolInputEnded + | SessionToolCalled + | SessionToolProgress + | SessionToolSuccess + | SessionToolFailed + | SessionRetried + | SessionCompactionStarted + | SessionCompactionDelta + | SessionCompactionEnded + | SessionRevertStaged + | SessionRevertCleared + | SessionRevertCommitted | MessagePartDelta | SessionDiff | SessionError | InstallationUpdated | InstallationUpdateAvailable - | FileEdited + | FilesystemChanged | ReferenceUpdated | PermissionV2Asked | PermissionV2Replied | PluginAdded + | PluginUpdated | ProjectDirectoriesUpdated | CommandUpdated + | ConfigUpdated | SkillUpdated - | FileWatcherUpdated | PtyCreated | PtyUpdated | PtyExited @@ -3154,9 +3106,9 @@ export type V2Event = | TuiToastShow | TuiSessionSelect | McpToolsChanged - | McpBrowserOpenFailed | McpStatusChanged | CommandExecuted + | FileEdited | ProjectUpdated | SessionStatus2 | SessionIdle @@ -3353,7 +3305,7 @@ export type ToolFileContent = { export type LlmToolContent = ToolTextContent | ToolFileContent -export type SessionNextRetryError = { +export type SessionRetryError = { message: string statusCode?: number isRetryable: boolean @@ -3676,155 +3628,140 @@ export type SyncEventMessagePartRemoved = { } } -export type SyncEventSessionNextAgentSwitched = { +export type SyncEventSessionAgentSelected = { type: "sync" id: string syncEvent: { - type: "session.next.agent.switched.1" + type: "session.agent.selected.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string - messageID: string agent: string } } } -export type SyncEventSessionNextModelSwitched = { +export type SyncEventSessionModelSelected = { type: "sync" id: string syncEvent: { - type: "session.next.model.switched.1" + type: "session.model.selected.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string - messageID: string model: ModelRef } } } -export type SyncEventSessionNextMoved = { +export type SyncEventSessionMoved = { type: "sync" id: string syncEvent: { - type: "session.next.moved.1" + type: "session.moved.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string location: LocationRef - subdirectory?: string + subpath?: string } } } -export type SyncEventSessionNextRenamed = { +export type SyncEventSessionRenamed = { type: "sync" id: string syncEvent: { - type: "session.next.renamed.1" + type: "session.renamed.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string title: string } } } -export type SyncEventSessionNextForked = { +export type SyncEventSessionForked = { type: "sync" id: string syncEvent: { - type: "session.next.forked.1" + type: "session.forked.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string parentID: string - messageID?: string + from?: string } } } -export type SyncEventSessionNextPrompted = { +export type SyncEventSessionPromptPromoted = { type: "sync" id: string syncEvent: { - type: "session.next.prompted.1" + type: "session.prompt.promoted.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string - messageID: string + inputID: string + } + } +} + +export type SyncEventSessionPromptAdmitted = { + type: "sync" + id: string + syncEvent: { + type: "session.prompt.admitted.1" + id: string + seq: number + aggregateID: string + data: { + sessionID: string + inputID: string prompt: Prompt delivery: "steer" | "queue" } } } -export type SyncEventSessionNextPromptAdmitted = { +export type SyncEventSessionContextUpdated = { type: "sync" id: string syncEvent: { - type: "session.next.prompt.admitted.1" + type: "session.context.updated.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string - messageID: string - prompt: Prompt - delivery: "steer" | "queue" - } - } -} - -export type SyncEventSessionNextContextUpdated = { - type: "sync" - id: string - syncEvent: { - type: "session.next.context.updated.1" - id: string - seq: number - aggregateID: string - data: { - timestamp: number - sessionID: string - messageID: string text: string } } } -export type SyncEventSessionNextSynthetic = { +export type SyncEventSessionSynthetic = { type: "sync" id: string syncEvent: { - type: "session.next.synthetic.1" + type: "session.synthetic.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string - messageID: string text: string description?: string metadata?: { @@ -3834,69 +3771,67 @@ export type SyncEventSessionNextSynthetic = { } } -export type SyncEventSessionNextSkillActivated = { +export type SyncEventSessionSkillActivated = { type: "sync" id: string syncEvent: { - type: "session.next.skill.activated.1" + type: "session.skill.activated.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string - messageID: string name: string text: string } } } -export type SyncEventSessionNextShellStarted = { +export type SyncEventSessionShellStarted = { type: "sync" id: string syncEvent: { - type: "session.next.shell.started.1" + type: "session.shell.started.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string - messageID: string - callID: string - command: string + shell: Shell } } } -export type SyncEventSessionNextShellEnded = { +export type SyncEventSessionShellEnded = { type: "sync" id: string syncEvent: { - type: "session.next.shell.ended.1" + type: "session.shell.ended.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string - callID: string - output: string + shell: Shell + output: { + output: string + cursor: number + size: number + truncated: boolean + } } } } -export type SyncEventSessionNextStepStarted = { +export type SyncEventSessionStepStarted = { type: "sync" id: string syncEvent: { - type: "session.next.step.started.1" + type: "session.step.started.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string assistantMessageID: string agent: string @@ -3906,16 +3841,15 @@ export type SyncEventSessionNextStepStarted = { } } -export type SyncEventSessionNextStepEnded = { +export type SyncEventSessionStepEnded = { type: "sync" id: string syncEvent: { - type: "session.next.step.ended.2" + type: "session.step.ended.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string assistantMessageID: string finish: string @@ -3935,16 +3869,15 @@ export type SyncEventSessionNextStepEnded = { } } -export type SyncEventSessionNextStepFailed = { +export type SyncEventSessionStepFailed = { type: "sync" id: string syncEvent: { - type: "session.next.step.failed.2" + type: "session.step.failed.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string assistantMessageID: string error: SessionErrorUnknown @@ -3952,16 +3885,15 @@ export type SyncEventSessionNextStepFailed = { } } -export type SyncEventSessionNextTextStarted = { +export type SyncEventSessionTextStarted = { type: "sync" id: string syncEvent: { - type: "session.next.text.started.1" + type: "session.text.started.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string assistantMessageID: string textID: string @@ -3969,16 +3901,15 @@ export type SyncEventSessionNextTextStarted = { } } -export type SyncEventSessionNextTextEnded = { +export type SyncEventSessionTextEnded = { type: "sync" id: string syncEvent: { - type: "session.next.text.ended.1" + type: "session.text.ended.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string assistantMessageID: string textID: string @@ -3987,16 +3918,15 @@ export type SyncEventSessionNextTextEnded = { } } -export type SyncEventSessionNextReasoningStarted = { +export type SyncEventSessionReasoningStarted = { type: "sync" id: string syncEvent: { - type: "session.next.reasoning.started.1" + type: "session.reasoning.started.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string assistantMessageID: string reasoningID: string @@ -4005,16 +3935,15 @@ export type SyncEventSessionNextReasoningStarted = { } } -export type SyncEventSessionNextReasoningEnded = { +export type SyncEventSessionReasoningEnded = { type: "sync" id: string syncEvent: { - type: "session.next.reasoning.ended.1" + type: "session.reasoning.ended.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string assistantMessageID: string reasoningID: string @@ -4024,16 +3953,15 @@ export type SyncEventSessionNextReasoningEnded = { } } -export type SyncEventSessionNextToolInputStarted = { +export type SyncEventSessionToolInputStarted = { type: "sync" id: string syncEvent: { - type: "session.next.tool.input.started.1" + type: "session.tool.input.started.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -4042,16 +3970,15 @@ export type SyncEventSessionNextToolInputStarted = { } } -export type SyncEventSessionNextToolInputEnded = { +export type SyncEventSessionToolInputEnded = { type: "sync" id: string syncEvent: { - type: "session.next.tool.input.ended.1" + type: "session.tool.input.ended.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -4060,16 +3987,15 @@ export type SyncEventSessionNextToolInputEnded = { } } -export type SyncEventSessionNextToolCalled = { +export type SyncEventSessionToolCalled = { type: "sync" id: string syncEvent: { - type: "session.next.tool.called.1" + type: "session.tool.called.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -4085,16 +4011,15 @@ export type SyncEventSessionNextToolCalled = { } } -export type SyncEventSessionNextToolProgress = { +export type SyncEventSessionToolProgress = { type: "sync" id: string syncEvent: { - type: "session.next.tool.progress.1" + type: "session.tool.progress.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -4106,16 +4031,15 @@ export type SyncEventSessionNextToolProgress = { } } -export type SyncEventSessionNextToolSuccess = { +export type SyncEventSessionToolSuccess = { type: "sync" id: string syncEvent: { - type: "session.next.tool.success.1" + type: "session.tool.success.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -4133,16 +4057,15 @@ export type SyncEventSessionNextToolSuccess = { } } -export type SyncEventSessionNextToolFailed = { +export type SyncEventSessionToolFailed = { type: "sync" id: string syncEvent: { - type: "session.next.tool.failed.1" + type: "session.tool.failed.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -4156,52 +4079,47 @@ export type SyncEventSessionNextToolFailed = { } } -export type SyncEventSessionNextRetried = { +export type SyncEventSessionRetried = { type: "sync" id: string syncEvent: { - type: "session.next.retried.1" + type: "session.retried.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string attempt: number - error: SessionNextRetryError + error: SessionRetryError } } } -export type SyncEventSessionNextCompactionStarted = { +export type SyncEventSessionCompactionStarted = { type: "sync" id: string syncEvent: { - type: "session.next.compaction.started.1" + type: "session.compaction.started.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string - messageID: string reason: "auto" | "manual" } } } -export type SyncEventSessionNextCompactionEnded = { +export type SyncEventSessionCompactionEnded = { type: "sync" id: string syncEvent: { - type: "session.next.compaction.ended.1" + type: "session.compaction.ended.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string - messageID: string reason: "auto" | "manual" text: string recent: string @@ -4209,47 +4127,44 @@ export type SyncEventSessionNextCompactionEnded = { } } -export type SyncEventSessionNextRevertStaged = { +export type SyncEventSessionRevertStaged = { type: "sync" id: string syncEvent: { - type: "session.next.revert.staged.1" + type: "session.revert.staged.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string revert: RevertState } } } -export type SyncEventSessionNextRevertCleared = { +export type SyncEventSessionRevertCleared = { type: "sync" id: string syncEvent: { - type: "session.next.revert.cleared.1" + type: "session.revert.cleared.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string } } } -export type SyncEventSessionNextRevertCommitted = { +export type SyncEventSessionRevertCommitted = { type: "sync" id: string syncEvent: { - type: "session.next.revert.committed.1" + type: "session.revert.committed.1" id: string seq: number aggregateID: string data: { - timestamp: number sessionID: string messageID: string } @@ -4269,14 +4184,6 @@ export type ConfigV2ReferenceLocal = { hidden?: boolean } -export type PolicyEffect = "allow" | "deny" - -export type ConfigV2ExperimentalPolicy = { - action: "provider.use" - effect: PolicyEffect - resource: string -} - export type ProjectDirectory = { directory: string strategy?: string @@ -4390,7 +4297,7 @@ export type SessionInputAdmitted = { promotedSeq?: number } -export type SessionMessageAgentSwitched = { +export type SessionMessageAgentSelected = { id: string metadata?: { [key: string]: unknown @@ -4402,7 +4309,7 @@ export type SessionMessageAgentSwitched = { agent: string } -export type SessionMessageModelSwitched = { +export type SessionMessageModelSelected = { id: string metadata?: { [key: string]: unknown @@ -4412,6 +4319,7 @@ export type SessionMessageModelSwitched = { } type: "model-switched" model: ModelRef + previous?: ModelRef } export type SessionMessageUser = { @@ -4477,9 +4385,13 @@ export type SessionMessageShell = { completed?: number } type: "shell" - callID: string - command: string - output: string + shell: Shell + output?: { + output: string + cursor: number + size: number + truncated: boolean + } } export type SessionMessageAssistantText = { @@ -4611,8 +4523,8 @@ export type SessionMessageCompaction = { } export type SessionMessage = - | SessionMessageAgentSwitched - | SessionMessageModelSwitched + | SessionMessageAgentSelected + | SessionMessageModelSelected | SessionMessageUser | SessionMessageSynthetic | SessionMessageSystem @@ -4628,183 +4540,177 @@ export type SessionContextEntryInfo = { value: unknown } -export type SessionNextAgentSwitched = { +export type SessionAgentSelected = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.agent.switched" - durable?: { + type: "session.agent.selected" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string - messageID: string agent: string } } -export type SessionNextModelSwitched = { +export type SessionModelSelected = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.model.switched" - durable?: { + type: "session.model.selected" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string - messageID: string model: ModelRef } } -export type SessionNextMoved = { +export type SessionMoved = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.moved" - durable?: { + type: "session.moved" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string location: LocationRef - subdirectory?: string + subpath?: string } } -export type SessionNextRenamed = { +export type SessionRenamed = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.renamed" - durable?: { + type: "session.renamed" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string title: string } } -export type SessionNextForked = { +export type SessionForked = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.forked" - durable?: { + type: "session.forked" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string parentID: string - messageID?: string + from?: string } } -export type SessionNextPrompted = { +export type SessionPromptPromoted = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.prompted" - durable?: { + type: "session.prompt.promoted" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string - messageID: string + inputID: string + } +} + +export type SessionPromptAdmitted = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.prompt.admitted" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + inputID: string prompt: Prompt delivery: "steer" | "queue" } } -export type SessionNextPromptAdmitted = { +export type SessionContextUpdated = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.prompt.admitted" - durable?: { + type: "session.context.updated" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string - messageID: string - prompt: Prompt - delivery: "steer" | "queue" - } -} - -export type SessionNextContextUpdated = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.context.updated" - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - messageID: string text: string } } -export type SessionNextSynthetic = { +export type SessionSynthetic = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.synthetic" - durable?: { + type: "session.synthetic" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string - messageID: string text: string description?: string metadata?: { @@ -4813,82 +4719,84 @@ export type SessionNextSynthetic = { } } -export type SessionNextSkillActivated = { +export type SessionSkillActivated = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.skill.activated" - durable?: { + type: "session.skill.activated" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string - messageID: string name: string text: string } } -export type SessionNextShellStarted = { +export type SessionShellStarted = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.shell.started" - durable?: { + type: "session.shell.started" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string - messageID: string - callID: string - command: string + shell: Shell1 } } -export type SessionNextShellEnded = { +export type SessionShellEnded = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.shell.ended" - durable?: { + type: "session.shell.ended" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string - callID: string - output: string + shell: Shell1 + output: { + output: string + cursor: number + size: number + truncated: boolean + } } } -export type SessionNextStepStarted = { +export type SessionStepStarted = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.step.started" - durable?: { + type: "session.step.started" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string assistantMessageID: string agent: string @@ -4897,20 +4805,20 @@ export type SessionNextStepStarted = { } } -export type SessionNextStepEnded = { +export type SessionStepEnded = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.step.ended" - durable?: { + type: "session.step.ended" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string assistantMessageID: string finish: string @@ -4929,60 +4837,60 @@ export type SessionNextStepEnded = { } } -export type SessionNextStepFailed = { +export type SessionStepFailed = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.step.failed" - durable?: { + type: "session.step.failed" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string assistantMessageID: string error: SessionErrorUnknown } } -export type SessionNextTextStarted = { +export type SessionTextStarted = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.text.started" - durable?: { + type: "session.text.started" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string assistantMessageID: string textID: string } } -export type SessionNextTextEnded = { +export type SessionTextEnded = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.text.ended" - durable?: { + type: "session.text.ended" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string assistantMessageID: string textID: string @@ -4990,20 +4898,63 @@ export type SessionNextTextEnded = { } } -export type SessionNextToolInputStarted = { +export type SessionReasoningStarted = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.tool.input.started" - durable?: { + type: "session.reasoning.started" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + assistantMessageID: string + reasoningID: string + providerMetadata?: LlmProviderMetadata + } +} + +export type SessionReasoningEnded = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.reasoning.ended" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + assistantMessageID: string + reasoningID: string + text: string + providerMetadata?: LlmProviderMetadata + } +} + +export type SessionToolInputStarted = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.tool.input.started" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -5011,20 +4962,20 @@ export type SessionNextToolInputStarted = { } } -export type SessionNextToolInputEnded = { +export type SessionToolInputEnded = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.tool.input.ended" - durable?: { + type: "session.tool.input.ended" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -5032,20 +4983,20 @@ export type SessionNextToolInputEnded = { } } -export type SessionNextToolCalled = { +export type SessionToolCalled = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.tool.called" - durable?: { + type: "session.tool.called" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -5060,20 +5011,20 @@ export type SessionNextToolCalled = { } } -export type SessionNextToolProgress = { +export type SessionToolProgress = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.tool.progress" - durable?: { + type: "session.tool.progress" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -5084,20 +5035,20 @@ export type SessionNextToolProgress = { } } -export type SessionNextToolSuccess = { +export type SessionToolSuccess = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.tool.success" - durable?: { + type: "session.tool.success" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -5114,20 +5065,20 @@ export type SessionNextToolSuccess = { } } -export type SessionNextToolFailed = { +export type SessionToolFailed = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.tool.failed" - durable?: { + type: "session.tool.failed" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -5140,162 +5091,117 @@ export type SessionNextToolFailed = { } } -export type SessionNextReasoningStarted = { +export type SessionRetried = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.reasoning.started" - durable?: { + type: "session.retried" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number - sessionID: string - assistantMessageID: string - reasoningID: string - providerMetadata?: LlmProviderMetadata - } -} - -export type SessionNextReasoningEnded = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.reasoning.ended" - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - assistantMessageID: string - reasoningID: string - text: string - providerMetadata?: LlmProviderMetadata - } -} - -export type SessionNextRetried = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.retried" - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - data: { - timestamp: number sessionID: string attempt: number - error: SessionNextRetryError + error: SessionRetryError } } -export type SessionNextCompactionStarted = { +export type SessionCompactionStarted = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.compaction.started" - durable?: { + type: "session.compaction.started" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string - messageID: string reason: "auto" | "manual" } } -export type SessionNextCompactionEnded = { +export type SessionCompactionEnded = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.compaction.ended" - durable?: { + type: "session.compaction.ended" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string - messageID: string reason: "auto" | "manual" text: string recent: string } } -export type SessionNextRevertStaged = { +export type SessionRevertStaged = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.revert.staged" - durable?: { + type: "session.revert.staged" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string revert: RevertState } } -export type SessionNextRevertCleared = { +export type SessionRevertCleared = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.revert.cleared" - durable?: { + type: "session.revert.cleared" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string } } -export type SessionNextRevertCommitted = { +export type SessionRevertCommitted = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.revert.committed" - durable?: { + type: "session.revert.committed" + durable: { aggregateID: string seq: number version: number } location?: LocationRef data: { - timestamp: number sessionID: string messageID: string } @@ -5522,8 +5428,8 @@ export type McpStatusConnected2 = { status: "connected" } -export type McpStatusDisconnected = { - status: "disconnected" +export type McpStatusPending = { + status: "pending" } export type McpStatusDisabled2 = { @@ -5548,7 +5454,7 @@ export type McpServer = { name: string status: | McpStatusConnected2 - | McpStatusDisconnected + | McpStatusPending | McpStatusDisabled2 | McpStatusFailed2 | McpStatusNeedsAuth2 @@ -5630,15 +5536,11 @@ export type SkillV2Info = { export type ModelsDevRefreshed = { id: string + created: number metadata?: { [key: string]: unknown } type: "models-dev.refreshed" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { [key: string]: unknown @@ -5647,15 +5549,11 @@ export type ModelsDevRefreshed = { export type IntegrationUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "integration.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { [key: string]: unknown @@ -5664,15 +5562,11 @@ export type IntegrationUpdated = { export type IntegrationConnectionUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "integration.connection.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { integrationID: string @@ -5681,15 +5575,11 @@ export type IntegrationConnectionUpdated = { export type CatalogUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "catalog.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { [key: string]: unknown @@ -5698,15 +5588,11 @@ export type CatalogUpdated = { export type AgentUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "agent.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { [key: string]: unknown @@ -5715,11 +5601,12 @@ export type AgentUpdated = { export type SessionCreated = { id: string + created: number metadata?: { [key: string]: unknown } type: "session.created" - durable?: { + durable: { aggregateID: string seq: number version: number @@ -5733,11 +5620,12 @@ export type SessionCreated = { export type SessionUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "session.updated" - durable?: { + durable: { aggregateID: string seq: number version: number @@ -5751,11 +5639,12 @@ export type SessionUpdated = { export type SessionDeleted = { id: string + created: number metadata?: { [key: string]: unknown } type: "session.deleted" - durable?: { + durable: { aggregateID: string seq: number version: number @@ -5769,11 +5658,12 @@ export type SessionDeleted = { export type MessageUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "message.updated" - durable?: { + durable: { aggregateID: string seq: number version: number @@ -5787,11 +5677,12 @@ export type MessageUpdated = { export type MessageRemoved = { id: string + created: number metadata?: { [key: string]: unknown } type: "message.removed" - durable?: { + durable: { aggregateID: string seq: number version: number @@ -5805,11 +5696,12 @@ export type MessageRemoved = { export type MessagePartUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "message.part.updated" - durable?: { + durable: { aggregateID: string seq: number version: number @@ -5824,11 +5716,12 @@ export type MessagePartUpdated = { export type MessagePartRemoved = { id: string + created: number metadata?: { [key: string]: unknown } type: "message.part.removed" - durable?: { + durable: { aggregateID: string seq: number version: number @@ -5841,40 +5734,30 @@ export type MessagePartRemoved = { } } -export type SessionNextExecutionSettled = { +export type SessionExecutionSettled = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.execution.settled" - durable?: { - aggregateID: string - seq: number - version: number - } + type: "session.execution.settled" location?: LocationRef data: { - timestamp: number sessionID: string outcome: "success" | "failure" | "interrupted" error?: SessionErrorUnknown } } -export type SessionNextTextDelta = { +export type SessionTextDelta = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.text.delta" - durable?: { - aggregateID: string - seq: number - version: number - } + type: "session.text.delta" location?: LocationRef data: { - timestamp: number sessionID: string assistantMessageID: string textID: string @@ -5882,20 +5765,15 @@ export type SessionNextTextDelta = { } } -export type SessionNextReasoningDelta = { +export type SessionReasoningDelta = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.reasoning.delta" - durable?: { - aggregateID: string - seq: number - version: number - } + type: "session.reasoning.delta" location?: LocationRef data: { - timestamp: number sessionID: string assistantMessageID: string reasoningID: string @@ -5903,20 +5781,15 @@ export type SessionNextReasoningDelta = { } } -export type SessionNextToolInputDelta = { +export type SessionToolInputDelta = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.tool.input.delta" - durable?: { - aggregateID: string - seq: number - version: number - } + type: "session.tool.input.delta" location?: LocationRef data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -5924,37 +5797,27 @@ export type SessionNextToolInputDelta = { } } -export type SessionNextCompactionDelta = { +export type SessionCompactionDelta = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.compaction.delta" - durable?: { - aggregateID: string - seq: number - version: number - } + type: "session.compaction.delta" location?: LocationRef data: { - timestamp: number sessionID: string - messageID: string text: string } } export type MessagePartDelta = { id: string + created: number metadata?: { [key: string]: unknown } type: "message.part.delta" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { sessionID: string @@ -5967,15 +5830,11 @@ export type MessagePartDelta = { export type SessionDiff = { id: string + created: number metadata?: { [key: string]: unknown } type: "session.diff" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { sessionID: string @@ -5985,15 +5844,11 @@ export type SessionDiff = { export type SessionError = { id: string + created: number metadata?: { [key: string]: unknown } type: "session.error" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { sessionID?: string @@ -6011,15 +5866,11 @@ export type SessionError = { export type InstallationUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "installation.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { version: string @@ -6028,49 +5879,38 @@ export type InstallationUpdated = { export type InstallationUpdateAvailable = { id: string + created: number metadata?: { [key: string]: unknown } type: "installation.update-available" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { version: string } } -export type FileEdited = { +export type FilesystemChanged = { id: string + created: number metadata?: { [key: string]: unknown } - type: "file.edited" - durable?: { - aggregateID: string - seq: number - version: number - } + type: "filesystem.changed" location?: LocationRef data: { file: string + event: "add" | "change" | "unlink" } } export type ReferenceUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "reference.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { [key: string]: unknown @@ -6079,15 +5919,11 @@ export type ReferenceUpdated = { export type PermissionV2Asked = { id: string + created: number metadata?: { [key: string]: unknown } type: "permission.v2.asked" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { id: string @@ -6104,15 +5940,11 @@ export type PermissionV2Asked = { export type PermissionV2Replied = { id: string + created: number metadata?: { [key: string]: unknown } type: "permission.v2.replied" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { sessionID: string @@ -6123,32 +5955,37 @@ export type PermissionV2Replied = { export type PluginAdded = { id: string + created: number metadata?: { [key: string]: unknown } type: "plugin.added" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { id: string } } +export type PluginUpdated = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "plugin.updated" + location?: LocationRef + data: { + [key: string]: unknown + } +} + export type ProjectDirectoriesUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "project.directories.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { projectID: string @@ -6157,15 +5994,24 @@ export type ProjectDirectoriesUpdated = { export type CommandUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "command.updated" - durable?: { - aggregateID: string - seq: number - version: number + location?: LocationRef + data: { + [key: string]: unknown } +} + +export type ConfigUpdated = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "config.updated" location?: LocationRef data: { [key: string]: unknown @@ -6174,50 +6020,24 @@ export type CommandUpdated = { export type SkillUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "skill.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { [key: string]: unknown } } -export type FileWatcherUpdated = { - id: string - metadata?: { - [key: string]: unknown - } - type: "file.watcher.updated" - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - data: { - file: string - event: "add" | "change" | "unlink" - } -} - export type PtyCreated = { id: string + created: number metadata?: { [key: string]: unknown } type: "pty.created" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { info: Pty @@ -6226,15 +6046,11 @@ export type PtyCreated = { export type PtyUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "pty.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { info: Pty @@ -6243,15 +6059,11 @@ export type PtyUpdated = { export type PtyExited = { id: string + created: number metadata?: { [key: string]: unknown } type: "pty.exited" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { id: string @@ -6261,15 +6073,11 @@ export type PtyExited = { export type PtyDeleted = { id: string + created: number metadata?: { [key: string]: unknown } type: "pty.deleted" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { id: string @@ -6278,15 +6086,11 @@ export type PtyDeleted = { export type ShellCreated = { id: string + created: number metadata?: { [key: string]: unknown } type: "shell.created" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { info: Shell1 @@ -6295,15 +6099,11 @@ export type ShellCreated = { export type ShellExited = { id: string + created: number metadata?: { [key: string]: unknown } type: "shell.exited" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { id: string @@ -6314,15 +6114,11 @@ export type ShellExited = { export type ShellDeleted = { id: string + created: number metadata?: { [key: string]: unknown } type: "shell.deleted" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { id: string @@ -6331,15 +6127,11 @@ export type ShellDeleted = { export type QuestionV2Asked = { id: string + created: number metadata?: { [key: string]: unknown } type: "question.v2.asked" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { id: string @@ -6354,15 +6146,11 @@ export type QuestionV2Asked = { export type QuestionV2Replied = { id: string + created: number metadata?: { [key: string]: unknown } type: "question.v2.replied" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { sessionID: string @@ -6373,15 +6161,11 @@ export type QuestionV2Replied = { export type QuestionV2Rejected = { id: string + created: number metadata?: { [key: string]: unknown } type: "question.v2.rejected" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { sessionID: string @@ -6421,15 +6205,11 @@ export type FormIntegerField1 = { export type FormCreated = { id: string + created: number metadata?: { [key: string]: unknown } type: "form.created" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { form: FormFormInfo | FormUrlInfo @@ -6440,15 +6220,11 @@ export type FormValue1 = string | number | "NaN" | "Infinity" | "-Infinity" | bo export type FormReplied = { id: string + created: number metadata?: { [key: string]: unknown } type: "form.replied" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { id: string @@ -6459,15 +6235,11 @@ export type FormReplied = { export type FormCancelled = { id: string + created: number metadata?: { [key: string]: unknown } type: "form.cancelled" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { id: string @@ -6477,15 +6249,11 @@ export type FormCancelled = { export type TodoUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "todo.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { sessionID: string @@ -6495,15 +6263,11 @@ export type TodoUpdated = { export type LspUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "lsp.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { [key: string]: unknown @@ -6512,15 +6276,11 @@ export type LspUpdated = { export type PermissionAsked = { id: string + created: number metadata?: { [key: string]: unknown } type: "permission.asked" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { id: string @@ -6540,15 +6300,11 @@ export type PermissionAsked = { export type PermissionReplied = { id: string + created: number metadata?: { [key: string]: unknown } type: "permission.replied" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { sessionID: string @@ -6559,15 +6315,11 @@ export type PermissionReplied = { export type TuiPromptAppend = { id: string + created: number metadata?: { [key: string]: unknown } type: "tui.prompt.append" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { text: string @@ -6576,15 +6328,11 @@ export type TuiPromptAppend = { export type TuiCommandExecute = { id: string + created: number metadata?: { [key: string]: unknown } type: "tui.command.execute" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { command: @@ -6611,15 +6359,11 @@ export type TuiCommandExecute = { export type TuiToastShow = { id: string + created: number metadata?: { [key: string]: unknown } type: "tui.toast.show" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { title?: string @@ -6631,15 +6375,11 @@ export type TuiToastShow = { export type TuiSessionSelect = { id: string + created: number metadata?: { [key: string]: unknown } type: "tui.session.select" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { /** @@ -6651,50 +6391,24 @@ export type TuiSessionSelect = { export type McpToolsChanged = { id: string + created: number metadata?: { [key: string]: unknown } type: "mcp.tools.changed" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { server: string } } -export type McpBrowserOpenFailed = { - id: string - metadata?: { - [key: string]: unknown - } - type: "mcp.browser.open.failed" - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - data: { - mcpName: string - url: string - } -} - export type McpStatusChanged = { id: string + created: number metadata?: { [key: string]: unknown } type: "mcp.status.changed" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { server: string @@ -6703,15 +6417,11 @@ export type McpStatusChanged = { export type CommandExecuted = { id: string + created: number metadata?: { [key: string]: unknown } type: "command.executed" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { name: string @@ -6721,17 +6431,26 @@ export type CommandExecuted = { } } +export type FileEdited = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "file.edited" + location?: LocationRef + data: { + file: string + } +} + export type ProjectUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "project.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { id: string @@ -6747,15 +6466,11 @@ export type ProjectUpdated = { export type SessionIdle = { id: string + created: number metadata?: { [key: string]: unknown } type: "session.idle" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { sessionID: string @@ -6764,15 +6479,11 @@ export type SessionIdle = { export type QuestionAsked = { id: string + created: number metadata?: { [key: string]: unknown } type: "question.asked" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { id: string @@ -6787,15 +6498,11 @@ export type QuestionAsked = { export type SessionCompacted = { id: string + created: number metadata?: { [key: string]: unknown } type: "session.compacted" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { sessionID: string @@ -6804,15 +6511,11 @@ export type SessionCompacted = { export type VcsBranchUpdated = { id: string + created: number metadata?: { [key: string]: unknown } type: "vcs.branch.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { branch?: string @@ -6821,15 +6524,11 @@ export type VcsBranchUpdated = { export type WorkspaceReady = { id: string + created: number metadata?: { [key: string]: unknown } type: "workspace.ready" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { name: string @@ -6838,15 +6537,11 @@ export type WorkspaceReady = { export type WorkspaceFailed = { id: string + created: number metadata?: { [key: string]: unknown } type: "workspace.failed" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { message: string @@ -6855,15 +6550,11 @@ export type WorkspaceFailed = { export type WorkspaceStatus = { id: string + created: number metadata?: { [key: string]: unknown } type: "workspace.status" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { workspaceID: string @@ -6873,15 +6564,11 @@ export type WorkspaceStatus = { export type WorktreeReady = { id: string + created: number metadata?: { [key: string]: unknown } type: "worktree.ready" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { name: string @@ -6891,15 +6578,11 @@ export type WorktreeReady = { export type WorktreeFailed = { id: string + created: number metadata?: { [key: string]: unknown } type: "worktree.failed" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { message: string @@ -6908,15 +6591,11 @@ export type WorktreeFailed = { export type ServerConnected = { id: string + created: number metadata?: { [key: string]: unknown } type: "server.connected" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { [key: string]: unknown @@ -6925,15 +6604,11 @@ export type ServerConnected = { export type GlobalDisposed = { id: string + created: number metadata?: { [key: string]: unknown } type: "global.disposed" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef data: { [key: string]: unknown @@ -7107,113 +6782,97 @@ export type EventMessagePartRemoved = { } } -export type EventSessionNextAgentSwitched = { +export type EventSessionAgentSelected = { id: string - type: "session.next.agent.switched" + type: "session.agent.selected" properties: { - timestamp: number sessionID: string - messageID: string agent: string } } -export type EventSessionNextModelSwitched = { +export type EventSessionModelSelected = { id: string - type: "session.next.model.switched" + type: "session.model.selected" properties: { - timestamp: number sessionID: string - messageID: string model: ModelRef } } -export type EventSessionNextMoved = { +export type EventSessionMoved = { id: string - type: "session.next.moved" + type: "session.moved" properties: { - timestamp: number sessionID: string location: LocationRef - subdirectory?: string + subpath?: string } } -export type EventSessionNextRenamed = { +export type EventSessionRenamed = { id: string - type: "session.next.renamed" + type: "session.renamed" properties: { - timestamp: number sessionID: string title: string } } -export type EventSessionNextForked = { +export type EventSessionForked = { id: string - type: "session.next.forked" + type: "session.forked" properties: { - timestamp: number sessionID: string parentID: string - messageID?: string + from?: string } } -export type EventSessionNextPrompted = { +export type EventSessionPromptPromoted = { id: string - type: "session.next.prompted" + type: "session.prompt.promoted" properties: { - timestamp: number sessionID: string - messageID: string + inputID: string + } +} + +export type EventSessionPromptAdmitted = { + id: string + type: "session.prompt.admitted" + properties: { + sessionID: string + inputID: string prompt: Prompt delivery: "steer" | "queue" } } -export type EventSessionNextPromptAdmitted = { +export type EventSessionExecutionSettled = { id: string - type: "session.next.prompt.admitted" + type: "session.execution.settled" properties: { - timestamp: number - sessionID: string - messageID: string - prompt: Prompt - delivery: "steer" | "queue" - } -} - -export type EventSessionNextExecutionSettled = { - id: string - type: "session.next.execution.settled" - properties: { - timestamp: number sessionID: string outcome: "success" | "failure" | "interrupted" error?: SessionErrorUnknown } } -export type EventSessionNextContextUpdated = { +export type EventSessionContextUpdated = { id: string - type: "session.next.context.updated" + type: "session.context.updated" properties: { - timestamp: number sessionID: string - messageID: string text: string } } -export type EventSessionNextSynthetic = { +export type EventSessionSynthetic = { id: string - type: "session.next.synthetic" + type: "session.synthetic" properties: { - timestamp: number sessionID: string - messageID: string text: string description?: string metadata?: { @@ -7222,46 +6881,44 @@ export type EventSessionNextSynthetic = { } } -export type EventSessionNextSkillActivated = { +export type EventSessionSkillActivated = { id: string - type: "session.next.skill.activated" + type: "session.skill.activated" properties: { - timestamp: number sessionID: string - messageID: string name: string text: string } } -export type EventSessionNextShellStarted = { +export type EventSessionShellStarted = { id: string - type: "session.next.shell.started" + type: "session.shell.started" properties: { - timestamp: number sessionID: string - messageID: string - callID: string - command: string + shell: Shell2 } } -export type EventSessionNextShellEnded = { +export type EventSessionShellEnded = { id: string - type: "session.next.shell.ended" + type: "session.shell.ended" properties: { - timestamp: number sessionID: string - callID: string - output: string + shell: Shell2 + output: { + output: string + cursor: number + size: number + truncated: boolean + } } } -export type EventSessionNextStepStarted = { +export type EventSessionStepStarted = { id: string - type: "session.next.step.started" + type: "session.step.started" properties: { - timestamp: number sessionID: string assistantMessageID: string agent: string @@ -7270,11 +6927,10 @@ export type EventSessionNextStepStarted = { } } -export type EventSessionNextStepEnded = { +export type EventSessionStepEnded = { id: string - type: "session.next.step.ended" + type: "session.step.ended" properties: { - timestamp: number sessionID: string assistantMessageID: string finish: string @@ -7293,33 +6949,30 @@ export type EventSessionNextStepEnded = { } } -export type EventSessionNextStepFailed = { +export type EventSessionStepFailed = { id: string - type: "session.next.step.failed" + type: "session.step.failed" properties: { - timestamp: number sessionID: string assistantMessageID: string error: SessionErrorUnknown } } -export type EventSessionNextTextStarted = { +export type EventSessionTextStarted = { id: string - type: "session.next.text.started" + type: "session.text.started" properties: { - timestamp: number sessionID: string assistantMessageID: string textID: string } } -export type EventSessionNextTextDelta = { +export type EventSessionTextDelta = { id: string - type: "session.next.text.delta" + type: "session.text.delta" properties: { - timestamp: number sessionID: string assistantMessageID: string textID: string @@ -7327,11 +6980,10 @@ export type EventSessionNextTextDelta = { } } -export type EventSessionNextTextEnded = { +export type EventSessionTextEnded = { id: string - type: "session.next.text.ended" + type: "session.text.ended" properties: { - timestamp: number sessionID: string assistantMessageID: string textID: string @@ -7339,11 +6991,10 @@ export type EventSessionNextTextEnded = { } } -export type EventSessionNextReasoningStarted = { +export type EventSessionReasoningStarted = { id: string - type: "session.next.reasoning.started" + type: "session.reasoning.started" properties: { - timestamp: number sessionID: string assistantMessageID: string reasoningID: string @@ -7351,11 +7002,10 @@ export type EventSessionNextReasoningStarted = { } } -export type EventSessionNextReasoningDelta = { +export type EventSessionReasoningDelta = { id: string - type: "session.next.reasoning.delta" + type: "session.reasoning.delta" properties: { - timestamp: number sessionID: string assistantMessageID: string reasoningID: string @@ -7363,11 +7013,10 @@ export type EventSessionNextReasoningDelta = { } } -export type EventSessionNextReasoningEnded = { +export type EventSessionReasoningEnded = { id: string - type: "session.next.reasoning.ended" + type: "session.reasoning.ended" properties: { - timestamp: number sessionID: string assistantMessageID: string reasoningID: string @@ -7376,11 +7025,10 @@ export type EventSessionNextReasoningEnded = { } } -export type EventSessionNextToolInputStarted = { +export type EventSessionToolInputStarted = { id: string - type: "session.next.tool.input.started" + type: "session.tool.input.started" properties: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -7388,11 +7036,10 @@ export type EventSessionNextToolInputStarted = { } } -export type EventSessionNextToolInputDelta = { +export type EventSessionToolInputDelta = { id: string - type: "session.next.tool.input.delta" + type: "session.tool.input.delta" properties: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -7400,11 +7047,10 @@ export type EventSessionNextToolInputDelta = { } } -export type EventSessionNextToolInputEnded = { +export type EventSessionToolInputEnded = { id: string - type: "session.next.tool.input.ended" + type: "session.tool.input.ended" properties: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -7412,11 +7058,10 @@ export type EventSessionNextToolInputEnded = { } } -export type EventSessionNextToolCalled = { +export type EventSessionToolCalled = { id: string - type: "session.next.tool.called" + type: "session.tool.called" properties: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -7431,11 +7076,10 @@ export type EventSessionNextToolCalled = { } } -export type EventSessionNextToolProgress = { +export type EventSessionToolProgress = { id: string - type: "session.next.tool.progress" + type: "session.tool.progress" properties: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -7446,11 +7090,10 @@ export type EventSessionNextToolProgress = { } } -export type EventSessionNextToolSuccess = { +export type EventSessionToolSuccess = { id: string - type: "session.next.tool.success" + type: "session.tool.success" properties: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -7467,11 +7110,10 @@ export type EventSessionNextToolSuccess = { } } -export type EventSessionNextToolFailed = { +export type EventSessionToolFailed = { id: string - type: "session.next.tool.failed" + type: "session.tool.failed" properties: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -7484,76 +7126,66 @@ export type EventSessionNextToolFailed = { } } -export type EventSessionNextRetried = { +export type EventSessionRetried = { id: string - type: "session.next.retried" + type: "session.retried" properties: { - timestamp: number sessionID: string attempt: number - error: SessionNextRetryError + error: SessionRetryError } } -export type EventSessionNextCompactionStarted = { +export type EventSessionCompactionStarted = { id: string - type: "session.next.compaction.started" + type: "session.compaction.started" properties: { - timestamp: number sessionID: string - messageID: string reason: "auto" | "manual" } } -export type EventSessionNextCompactionDelta = { +export type EventSessionCompactionDelta = { id: string - type: "session.next.compaction.delta" + type: "session.compaction.delta" properties: { - timestamp: number sessionID: string - messageID: string text: string } } -export type EventSessionNextCompactionEnded = { +export type EventSessionCompactionEnded = { id: string - type: "session.next.compaction.ended" + type: "session.compaction.ended" properties: { - timestamp: number sessionID: string - messageID: string reason: "auto" | "manual" text: string recent: string } } -export type EventSessionNextRevertStaged = { +export type EventSessionRevertStaged = { id: string - type: "session.next.revert.staged" + type: "session.revert.staged" properties: { - timestamp: number sessionID: string revert: RevertState } } -export type EventSessionNextRevertCleared = { +export type EventSessionRevertCleared = { id: string - type: "session.next.revert.cleared" + type: "session.revert.cleared" properties: { - timestamp: number sessionID: string } } -export type EventSessionNextRevertCommitted = { +export type EventSessionRevertCommitted = { id: string - type: "session.next.revert.committed" + type: "session.revert.committed" properties: { - timestamp: number sessionID: string messageID: string } @@ -7613,11 +7245,12 @@ export type EventInstallationUpdateAvailable = { } } -export type EventFileEdited = { +export type EventFilesystemChanged = { id: string - type: "file.edited" + type: "filesystem.changed" properties: { file: string + event: "add" | "change" | "unlink" } } @@ -7663,6 +7296,14 @@ export type EventPluginAdded = { } } +export type EventPluginUpdated = { + id: string + type: "plugin.updated" + properties: { + [key: string]: unknown + } +} + export type EventProjectDirectoriesUpdated = { id: string type: "project.directories.updated" @@ -7679,20 +7320,19 @@ export type EventCommandUpdated = { } } -export type EventSkillUpdated = { +export type EventConfigUpdated = { id: string - type: "skill.updated" + type: "config.updated" properties: { [key: string]: unknown } } -export type EventFileWatcherUpdated = { +export type EventSkillUpdated = { id: string - type: "file.watcher.updated" + type: "skill.updated" properties: { - file: string - event: "add" | "change" | "unlink" + [key: string]: unknown } } @@ -7893,15 +7533,6 @@ export type EventMcpToolsChanged = { } } -export type EventMcpBrowserOpenFailed = { - id: string - type: "mcp.browser.open.failed" - properties: { - mcpName: string - url: string - } -} - export type EventMcpStatusChanged = { id: string type: "mcp.status.changed" @@ -7921,6 +7552,14 @@ export type EventCommandExecuted = { } } +export type EventFileEdited = { + id: string + type: "file.edited" + properties: { + file: string + } +} + export type EventProjectUpdated = { id: string type: "project.updated" @@ -8355,7 +7994,7 @@ export type UnknownErrorV2 = { ref?: string | null } -export type SessionMessageAgentSwitched2 = { +export type SessionMessageAgentSelected2 = { id: string metadata?: { [key: string]: unknown @@ -8367,7 +8006,7 @@ export type SessionMessageAgentSwitched2 = { agent: string } -export type SessionMessageModelSwitched2 = { +export type SessionMessageModelSelected2 = { id: string metadata?: { [key: string]: unknown @@ -8377,6 +8016,7 @@ export type SessionMessageModelSwitched2 = { } type: "model-switched" model: ModelRef2 + previous?: ModelRef2 } export type SessionMessageUser2 = { @@ -8432,6 +8072,24 @@ export type SessionMessageSkill2 = { text: string } +export type ShellV2 = { + id: string + status: "running" | "exited" | "timeout" | "killed" + command: string + cwd: string + shell: string + file: string + pid?: number + exit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + metadata: { + [key: string]: unknown + } + time: { + started: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + completed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } +} + export type SessionMessageShell2 = { id: string metadata?: { @@ -8442,9 +8100,13 @@ export type SessionMessageShell2 = { completed?: number } type: "shell" - callID: string - command: string - output: string + shell: ShellV2 + output?: { + output: string + cursor: number + size: number + truncated: boolean + } } export type SessionMessageAssistantText2 = { @@ -8601,8 +8263,8 @@ export type SessionMessageCompaction2 = { } export type SessionMessage2 = - | SessionMessageAgentSwitched2 - | SessionMessageModelSwitched2 + | SessionMessageAgentSelected2 + | SessionMessageModelSelected2 | SessionMessageUser2 | SessionMessageSynthetic2 | SessionMessageSystem2 @@ -8621,183 +8283,177 @@ export type SessionContextEntryInfo2 = { value: unknown } -export type SessionNextAgentSwitched2 = { +export type SessionAgentSelected2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.agent.switched" - durable?: { + type: "session.agent.selected" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string - messageID: string agent: string } } -export type SessionNextModelSwitched2 = { +export type SessionModelSelected2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.model.switched" - durable?: { + type: "session.model.selected" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string - messageID: string model: ModelRef2 } } -export type SessionNextMoved2 = { +export type SessionMoved2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.moved" - durable?: { + type: "session.moved" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string location: LocationRef2 - subdirectory?: string + subpath?: string } } -export type SessionNextRenamed2 = { +export type SessionRenamed2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.renamed" - durable?: { + type: "session.renamed" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string title: string } } -export type SessionNextForked2 = { +export type SessionForked2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.forked" - durable?: { + type: "session.forked" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string parentID: string - messageID?: string + from?: string } } -export type SessionNextPrompted2 = { +export type SessionPromptPromoted2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.prompted" - durable?: { + type: "session.prompt.promoted" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string - messageID: string + inputID: string + } +} + +export type SessionPromptAdmitted2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.prompt.admitted" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef2 + data: { + sessionID: string + inputID: string prompt: PromptV2 delivery: "steer" | "queue" } } -export type SessionNextPromptAdmitted2 = { +export type SessionContextUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.prompt.admitted" - durable?: { + type: "session.context.updated" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string - messageID: string - prompt: PromptV2 - delivery: "steer" | "queue" - } -} - -export type SessionNextContextUpdated2 = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.context.updated" - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - timestamp: number - sessionID: string - messageID: string text: string } } -export type SessionNextSynthetic2 = { +export type SessionSynthetic2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.synthetic" - durable?: { + type: "session.synthetic" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string - messageID: string text: string description?: string metadata?: { @@ -8806,82 +8462,102 @@ export type SessionNextSynthetic2 = { } } -export type SessionNextSkillActivated2 = { +export type SessionSkillActivated2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.skill.activated" - durable?: { + type: "session.skill.activated" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string - messageID: string name: string text: string } } -export type SessionNextShellStarted2 = { +export type Shell1V2 = { id: string - metadata?: { + status: "running" | "exited" | "timeout" | "killed" + command: string + cwd: string + shell: string + file: string + pid?: number + exit?: number | "NaN" | "Infinity" | "-Infinity" + metadata: { [key: string]: unknown } - type: "session.next.shell.started" - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - timestamp: number - sessionID: string - messageID: string - callID: string - command: string + time: { + started: number | "NaN" | "Infinity" | "-Infinity" + completed?: number | "NaN" | "Infinity" | "-Infinity" } } -export type SessionNextShellEnded2 = { +export type SessionShellStarted2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.shell.ended" - durable?: { + type: "session.shell.started" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string - callID: string - output: string + shell: Shell1V2 } } -export type SessionNextStepStarted2 = { +export type SessionShellEnded2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.step.started" - durable?: { + type: "session.shell.ended" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef2 + data: { + sessionID: string + shell: Shell1V2 + output: { + output: string + cursor: number + size: number + truncated: boolean + } + } +} + +export type SessionStepStarted2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.step.started" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string assistantMessageID: string agent: string @@ -8890,20 +8566,20 @@ export type SessionNextStepStarted2 = { } } -export type SessionNextStepEnded2 = { +export type SessionStepEnded2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.step.ended" - durable?: { + type: "session.step.ended" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string assistantMessageID: string finish: string @@ -8922,60 +8598,60 @@ export type SessionNextStepEnded2 = { } } -export type SessionNextStepFailed2 = { +export type SessionStepFailed2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.step.failed" - durable?: { + type: "session.step.failed" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string assistantMessageID: string error: SessionErrorUnknown2 } } -export type SessionNextTextStarted2 = { +export type SessionTextStarted2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.text.started" - durable?: { + type: "session.text.started" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string assistantMessageID: string textID: string } } -export type SessionNextTextEnded2 = { +export type SessionTextEnded2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.text.ended" - durable?: { + type: "session.text.ended" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string assistantMessageID: string textID: string @@ -8983,68 +8659,123 @@ export type SessionNextTextEnded2 = { } } -export type SessionNextToolInputStarted2 = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.tool.input.started" - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - timestamp: number - sessionID: string - assistantMessageID: string - callID: string - name: string - } -} - -export type SessionNextToolInputEnded2 = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.tool.input.ended" - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - timestamp: number - sessionID: string - assistantMessageID: string - callID: string - text: string - } -} - export type LlmProviderMetadata3 = { [key: string]: { [key: string]: unknown } } -export type SessionNextToolCalled2 = { +export type SessionReasoningStarted2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.tool.called" - durable?: { + type: "session.reasoning.started" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef2 + data: { + sessionID: string + assistantMessageID: string + reasoningID: string + providerMetadata?: LlmProviderMetadata3 + } +} + +export type LlmProviderMetadata4 = { + [key: string]: { + [key: string]: unknown + } +} + +export type SessionReasoningEnded2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.reasoning.ended" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef2 + data: { + sessionID: string + assistantMessageID: string + reasoningID: string + text: string + providerMetadata?: LlmProviderMetadata4 + } +} + +export type SessionToolInputStarted2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.tool.input.started" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef2 + data: { + sessionID: string + assistantMessageID: string + callID: string + name: string + } +} + +export type SessionToolInputEnded2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.tool.input.ended" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef2 + data: { + sessionID: string + assistantMessageID: string + callID: string + text: string + } +} + +export type LlmProviderMetadata5 = { + [key: string]: { + [key: string]: unknown + } +} + +export type SessionToolCalled2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.tool.called" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -9054,25 +8785,25 @@ export type SessionNextToolCalled2 = { } provider: { executed: boolean - metadata?: LlmProviderMetadata3 + metadata?: LlmProviderMetadata5 } } } -export type SessionNextToolProgress2 = { +export type SessionToolProgress2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.tool.progress" - durable?: { + type: "session.tool.progress" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -9083,26 +8814,26 @@ export type SessionNextToolProgress2 = { } } -export type LlmProviderMetadata4 = { +export type LlmProviderMetadata6 = { [key: string]: { [key: string]: unknown } } -export type SessionNextToolSuccess2 = { +export type SessionToolSuccess2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.tool.success" - durable?: { + type: "session.tool.success" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -9114,99 +8845,44 @@ export type SessionNextToolSuccess2 = { result?: unknown provider: { executed: boolean - metadata?: LlmProviderMetadata4 + metadata?: LlmProviderMetadata6 } } } -export type LlmProviderMetadata5 = { - [key: string]: { - [key: string]: unknown - } -} - -export type SessionNextToolFailed2 = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.tool.failed" - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - timestamp: number - sessionID: string - assistantMessageID: string - callID: string - error: SessionErrorUnknown2 - result?: unknown - provider: { - executed: boolean - metadata?: LlmProviderMetadata5 - } - } -} - -export type LlmProviderMetadata6 = { - [key: string]: { - [key: string]: unknown - } -} - -export type SessionNextReasoningStarted2 = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.reasoning.started" - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - timestamp: number - sessionID: string - assistantMessageID: string - reasoningID: string - providerMetadata?: LlmProviderMetadata6 - } -} - export type LlmProviderMetadata7 = { [key: string]: { [key: string]: unknown } } -export type SessionNextReasoningEnded2 = { +export type SessionToolFailed2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.reasoning.ended" - durable?: { + type: "session.tool.failed" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string assistantMessageID: string - reasoningID: string - text: string - providerMetadata?: LlmProviderMetadata7 + callID: string + error: SessionErrorUnknown2 + result?: unknown + provider: { + executed: boolean + metadata?: LlmProviderMetadata7 + } } } -export type SessionNextRetryError2 = { +export type SessionRetryError2 = { message: string statusCode?: number isRetryable: boolean @@ -9219,156 +8895,154 @@ export type SessionNextRetryError2 = { } } -export type SessionNextRetried2 = { +export type SessionRetried2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.retried" - durable?: { + type: "session.retried" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string attempt: number - error: SessionNextRetryError2 + error: SessionRetryError2 } } -export type SessionNextCompactionStarted2 = { +export type SessionCompactionStarted2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.compaction.started" - durable?: { + type: "session.compaction.started" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string - messageID: string reason: "auto" | "manual" } } -export type SessionNextCompactionEnded2 = { +export type SessionCompactionEnded2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.compaction.ended" - durable?: { + type: "session.compaction.ended" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string - messageID: string reason: "auto" | "manual" text: string recent: string } } -export type SessionNextRevertStaged2 = { +export type SessionRevertStaged2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.revert.staged" - durable?: { + type: "session.revert.staged" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string revert: RevertState2 } } -export type SessionNextRevertCleared2 = { +export type SessionRevertCleared2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.revert.cleared" - durable?: { + type: "session.revert.cleared" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string } } -export type SessionNextRevertCommitted2 = { +export type SessionRevertCommitted2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.revert.committed" - durable?: { + type: "session.revert.committed" + durable: { aggregateID: string seq: number version: number } location?: LocationRef2 data: { - timestamp: number sessionID: string messageID: string } } export type SessionDurableEventV2 = - | SessionNextAgentSwitched2 - | SessionNextModelSwitched2 - | SessionNextMoved2 - | SessionNextRenamed2 - | SessionNextForked2 - | SessionNextPrompted2 - | SessionNextPromptAdmitted2 - | SessionNextContextUpdated2 - | SessionNextSynthetic2 - | SessionNextSkillActivated2 - | SessionNextShellStarted2 - | SessionNextShellEnded2 - | SessionNextStepStarted2 - | SessionNextStepEnded2 - | SessionNextStepFailed2 - | SessionNextTextStarted2 - | SessionNextTextEnded2 - | SessionNextToolInputStarted2 - | SessionNextToolInputEnded2 - | SessionNextToolCalled2 - | SessionNextToolProgress2 - | SessionNextToolSuccess2 - | SessionNextToolFailed2 - | SessionNextReasoningStarted2 - | SessionNextReasoningEnded2 - | SessionNextRetried2 - | SessionNextCompactionStarted2 - | SessionNextCompactionEnded2 - | SessionNextRevertStaged2 - | SessionNextRevertCleared2 - | SessionNextRevertCommitted2 + | SessionAgentSelected2 + | SessionModelSelected2 + | SessionMoved2 + | SessionRenamed2 + | SessionForked2 + | SessionPromptPromoted2 + | SessionPromptAdmitted2 + | SessionContextUpdated2 + | SessionSynthetic2 + | SessionSkillActivated2 + | SessionShellStarted2 + | SessionShellEnded2 + | SessionStepStarted2 + | SessionStepEnded2 + | SessionStepFailed2 + | SessionTextStarted2 + | SessionTextEnded2 + | SessionReasoningStarted2 + | SessionReasoningEnded2 + | SessionToolInputStarted2 + | SessionToolInputEnded2 + | SessionToolCalled2 + | SessionToolProgress2 + | SessionToolSuccess2 + | SessionToolFailed2 + | SessionRetried2 + | SessionCompactionStarted2 + | SessionCompactionEnded2 + | SessionRevertStaged2 + | SessionRevertCleared2 + | SessionRevertCommitted2 /** * Marker emitted once when a log read reaches its captured watermark. The reader holds every event committed at or below seq. @@ -9621,8 +9295,8 @@ export type McpStatusConnected3 = { status: "connected" } -export type McpStatusDisconnected2 = { - status: "disconnected" +export type McpStatusPending2 = { + status: "pending" } export type McpStatusDisabled3 = { @@ -9647,7 +9321,7 @@ export type McpServer2 = { name: string status: | McpStatusConnected3 - | McpStatusDisconnected2 + | McpStatusPending2 | McpStatusDisabled3 | McpStatusFailed3 | McpStatusNeedsAuth3 @@ -9655,6 +9329,38 @@ export type McpServer2 = { integrationID?: string } +export type ProjectVcs2 = string + +export type ProjectIcon2 = { + url?: string + override?: string + color?: string +} + +export type ProjectCommands2 = { + /** + * Startup script to run when creating a new workspace (worktree) + */ + start?: string +} + +export type ProjectTime2 = { + created: number + updated: number + initialized?: number +} + +export type ProjectV2 = { + id: string + worktree: string + vcs?: ProjectVcs2 + name?: string + icon?: ProjectIcon2 + commands?: ProjectCommands2 + time: ProjectTime2 + sandboxes: Array +} + export type ProjectCurrent2 = { id: string directory: string @@ -9885,15 +9591,11 @@ export type SkillV2Info2 = { export type ModelsDevRefreshed2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "models-dev.refreshed" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: | { @@ -9904,15 +9606,11 @@ export type ModelsDevRefreshed2 = { export type IntegrationUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "integration.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: | { @@ -9923,15 +9621,11 @@ export type IntegrationUpdated2 = { export type IntegrationConnectionUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "integration.connection.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { integrationID: string @@ -9940,15 +9634,11 @@ export type IntegrationConnectionUpdated2 = { export type CatalogUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "catalog.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: | { @@ -9959,15 +9649,11 @@ export type CatalogUpdated2 = { export type AgentUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "agent.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: | { @@ -10049,11 +9735,12 @@ export type SessionV2 = { export type SessionCreated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "session.created" - durable?: { + durable: { aggregateID: string seq: number version: number @@ -10067,11 +9754,12 @@ export type SessionCreated2 = { export type SessionUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "session.updated" - durable?: { + durable: { aggregateID: string seq: number version: number @@ -10085,11 +9773,12 @@ export type SessionUpdated2 = { export type SessionDeleted2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "session.deleted" - durable?: { + durable: { aggregateID: string seq: number version: number @@ -10259,11 +9948,12 @@ export type MessageV2 = UserMessageV2 | AssistantMessageV2 export type MessageUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "message.updated" - durable?: { + durable: { aggregateID: string seq: number version: number @@ -10277,11 +9967,12 @@ export type MessageUpdated2 = { export type MessageRemoved2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "message.removed" - durable?: { + durable: { aggregateID: string seq: number version: number @@ -10558,11 +10249,12 @@ export type PartV2 = export type MessagePartUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "message.part.updated" - durable?: { + durable: { aggregateID: string seq: number version: number @@ -10577,11 +10269,12 @@ export type MessagePartUpdated2 = { export type MessagePartRemoved2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "message.part.removed" - durable?: { + durable: { aggregateID: string seq: number version: number @@ -10594,40 +10287,30 @@ export type MessagePartRemoved2 = { } } -export type SessionNextExecutionSettled2 = { +export type SessionExecutionSettled2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.execution.settled" - durable?: { - aggregateID: string - seq: number - version: number - } + type: "session.execution.settled" location?: LocationRef2 data: { - timestamp: number sessionID: string outcome: "success" | "failure" | "interrupted" error?: SessionErrorUnknown2 } } -export type SessionNextTextDelta2 = { +export type SessionTextDelta2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.text.delta" - durable?: { - aggregateID: string - seq: number - version: number - } + type: "session.text.delta" location?: LocationRef2 data: { - timestamp: number sessionID: string assistantMessageID: string textID: string @@ -10635,20 +10318,15 @@ export type SessionNextTextDelta2 = { } } -export type SessionNextReasoningDelta2 = { +export type SessionReasoningDelta2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.reasoning.delta" - durable?: { - aggregateID: string - seq: number - version: number - } + type: "session.reasoning.delta" location?: LocationRef2 data: { - timestamp: number sessionID: string assistantMessageID: string reasoningID: string @@ -10656,20 +10334,15 @@ export type SessionNextReasoningDelta2 = { } } -export type SessionNextToolInputDelta2 = { +export type SessionToolInputDelta2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.tool.input.delta" - durable?: { - aggregateID: string - seq: number - version: number - } + type: "session.tool.input.delta" location?: LocationRef2 data: { - timestamp: number sessionID: string assistantMessageID: string callID: string @@ -10677,54 +10350,41 @@ export type SessionNextToolInputDelta2 = { } } -export type SessionNextCompactionDelta2 = { +export type SessionCompactionDelta2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "session.next.compaction.delta" - durable?: { - aggregateID: string - seq: number - version: number - } + type: "session.compaction.delta" location?: LocationRef2 data: { - timestamp: number sessionID: string - messageID: string text: string } } -export type FileEdited2 = { +export type FilesystemChanged2 = { id: string + created: number metadata?: { [key: string]: unknown } - type: "file.edited" - durable?: { - aggregateID: string - seq: number - version: number - } + type: "filesystem.changed" location?: LocationRef2 data: { file: string + event: "add" | "change" | "unlink" } } export type ReferenceUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "reference.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: | { @@ -10735,15 +10395,11 @@ export type ReferenceUpdated2 = { export type PermissionV2Asked2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "permission.v2.asked" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { id: string @@ -10760,15 +10416,11 @@ export type PermissionV2Asked2 = { export type PermissionV2Replied2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "permission.v2.replied" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { sessionID: string @@ -10779,32 +10431,39 @@ export type PermissionV2Replied2 = { export type PluginAdded2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "plugin.added" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { id: string } } +export type PluginUpdated2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "plugin.updated" + location?: LocationRef2 + data: + | { + [key: string]: unknown + } + | Array +} + export type ProjectDirectoriesUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "project.directories.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { projectID: string @@ -10813,15 +10472,26 @@ export type ProjectDirectoriesUpdated2 = { export type CommandUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "command.updated" - durable?: { - aggregateID: string - seq: number - version: number + location?: LocationRef2 + data: + | { + [key: string]: unknown + } + | Array +} + +export type ConfigUpdated2 = { + id: string + created: number + metadata?: { + [key: string]: unknown } + type: "config.updated" location?: LocationRef2 data: | { @@ -10832,15 +10502,11 @@ export type CommandUpdated2 = { export type SkillUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "skill.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: | { @@ -10849,24 +10515,6 @@ export type SkillUpdated2 = { | Array } -export type FileWatcherUpdated2 = { - id: string - metadata?: { - [key: string]: unknown - } - type: "file.watcher.updated" - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef2 - data: { - file: string - event: "add" | "change" | "unlink" - } -} - export type PtyV2 = { id: string title: string @@ -10880,15 +10528,11 @@ export type PtyV2 = { export type PtyCreated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "pty.created" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { info: PtyV2 @@ -10897,15 +10541,11 @@ export type PtyCreated2 = { export type PtyUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "pty.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { info: PtyV2 @@ -10914,15 +10554,11 @@ export type PtyUpdated2 = { export type PtyExited2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "pty.exited" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { id: string @@ -10932,67 +10568,37 @@ export type PtyExited2 = { export type PtyDeleted2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "pty.deleted" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { id: string } } -export type ShellV2 = { - id: string - status: "running" | "exited" | "timeout" | "killed" - command: string - cwd: string - shell: string - file: string - pid?: number - exit?: number | "NaN" | "Infinity" | "-Infinity" - metadata: { - [key: string]: unknown - } - time: { - started: number | "NaN" | "Infinity" | "-Infinity" - completed?: number | "NaN" | "Infinity" | "-Infinity" - } -} - export type ShellCreated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "shell.created" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { - info: ShellV2 + info: Shell1V2 } } export type ShellExited2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "shell.exited" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { id: string @@ -11003,15 +10609,11 @@ export type ShellExited2 = { export type ShellDeleted2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "shell.deleted" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { id: string @@ -11053,15 +10655,11 @@ export type QuestionV2Tool2 = { export type QuestionV2Asked2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "question.v2.asked" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { id: string @@ -11078,15 +10676,11 @@ export type QuestionV2Answer2 = Array export type QuestionV2Replied2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "question.v2.replied" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { sessionID: string @@ -11097,15 +10691,11 @@ export type QuestionV2Replied2 = { export type QuestionV2Rejected2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "question.v2.rejected" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { sessionID: string @@ -11208,15 +10798,11 @@ export type FormUrlInfo1 = { export type FormCreated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "form.created" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { form: FormFormInfo1 | FormUrlInfo1 @@ -11231,15 +10817,11 @@ export type FormAnswer1 = { export type FormReplied2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "form.replied" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { id: string @@ -11250,15 +10832,11 @@ export type FormReplied2 = { export type FormCancelled2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "form.cancelled" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { id: string @@ -11283,15 +10861,11 @@ export type TodoV2 = { export type TodoUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "todo.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { sessionID: string @@ -11323,15 +10897,11 @@ export type SessionStatusV2 = export type SessionStatusV22 = { id: string + created: number metadata?: { [key: string]: unknown } type: "session.status" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { sessionID: string @@ -11341,15 +10911,11 @@ export type SessionStatusV22 = { export type SessionIdle2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "session.idle" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { sessionID: string @@ -11358,15 +10924,11 @@ export type SessionIdle2 = { export type TuiPromptAppend2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "tui.prompt.append" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { text: string @@ -11375,15 +10937,11 @@ export type TuiPromptAppend2 = { export type TuiCommandExecute2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "tui.command.execute" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { command: @@ -11410,15 +10968,11 @@ export type TuiCommandExecute2 = { export type TuiToastShow2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "tui.toast.show" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { title?: string @@ -11430,15 +10984,11 @@ export type TuiToastShow2 = { export type TuiSessionSelect2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "tui.session.select" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { /** @@ -11450,15 +11000,11 @@ export type TuiSessionSelect2 = { export type InstallationUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "installation.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { version: string @@ -11467,15 +11013,11 @@ export type InstallationUpdated2 = { export type InstallationUpdateAvailable2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "installation.update-available" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { version: string @@ -11484,15 +11026,11 @@ export type InstallationUpdateAvailable2 = { export type VcsBranchUpdated2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "vcs.branch.updated" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { branch?: string @@ -11501,15 +11039,11 @@ export type VcsBranchUpdated2 = { export type McpStatusChanged2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "mcp.status.changed" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { server: string @@ -11518,15 +11052,11 @@ export type McpStatusChanged2 = { export type PermissionAsked2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "permission.asked" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { id: string @@ -11546,15 +11076,11 @@ export type PermissionAsked2 = { export type PermissionReplied2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "permission.replied" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { sessionID: string @@ -11604,15 +11130,11 @@ export type QuestionToolV2 = { export type QuestionAsked2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "question.asked" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { id: string @@ -11629,15 +11151,11 @@ export type QuestionAnswerV2 = Array export type QuestionRepliedV2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "question.replied" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { sessionID: string @@ -11648,15 +11166,11 @@ export type QuestionRepliedV2 = { export type QuestionRejectedV2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "question.rejected" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { sessionID: string @@ -11666,15 +11180,11 @@ export type QuestionRejectedV2 = { export type SessionError2 = { id: string + created: number metadata?: { [key: string]: unknown } type: "session.error" - durable?: { - aggregateID: string - seq: number - version: number - } location?: LocationRef2 data: { sessionID?: string | null @@ -11696,11 +11206,6 @@ export type V2EventServerConnected = { metadata?: { [key: string]: unknown } | null - durable?: { - aggregateID: string - seq: number - version: number - } | null location?: LocationRef2 | null type: "server.connected" data: @@ -11723,51 +11228,52 @@ export type V2EventV2 = | MessageRemoved2 | MessagePartUpdated2 | MessagePartRemoved2 - | SessionNextAgentSwitched2 - | SessionNextModelSwitched2 - | SessionNextMoved2 - | SessionNextRenamed2 - | SessionNextForked2 - | SessionNextPrompted2 - | SessionNextPromptAdmitted2 - | SessionNextExecutionSettled2 - | SessionNextContextUpdated2 - | SessionNextSynthetic2 - | SessionNextSkillActivated2 - | SessionNextShellStarted2 - | SessionNextShellEnded2 - | SessionNextStepStarted2 - | SessionNextStepEnded2 - | SessionNextStepFailed2 - | SessionNextTextStarted2 - | SessionNextTextDelta2 - | SessionNextTextEnded2 - | SessionNextReasoningStarted2 - | SessionNextReasoningDelta2 - | SessionNextReasoningEnded2 - | SessionNextToolInputStarted2 - | SessionNextToolInputDelta2 - | SessionNextToolInputEnded2 - | SessionNextToolCalled2 - | SessionNextToolProgress2 - | SessionNextToolSuccess2 - | SessionNextToolFailed2 - | SessionNextRetried2 - | SessionNextCompactionStarted2 - | SessionNextCompactionDelta2 - | SessionNextCompactionEnded2 - | SessionNextRevertStaged2 - | SessionNextRevertCleared2 - | SessionNextRevertCommitted2 - | FileEdited2 + | SessionAgentSelected2 + | SessionModelSelected2 + | SessionMoved2 + | SessionRenamed2 + | SessionForked2 + | SessionPromptPromoted2 + | SessionPromptAdmitted2 + | SessionExecutionSettled2 + | SessionContextUpdated2 + | SessionSynthetic2 + | SessionSkillActivated2 + | SessionShellStarted2 + | SessionShellEnded2 + | SessionStepStarted2 + | SessionStepEnded2 + | SessionStepFailed2 + | SessionTextStarted2 + | SessionTextDelta2 + | SessionTextEnded2 + | SessionReasoningStarted2 + | SessionReasoningDelta2 + | SessionReasoningEnded2 + | SessionToolInputStarted2 + | SessionToolInputDelta2 + | SessionToolInputEnded2 + | SessionToolCalled2 + | SessionToolProgress2 + | SessionToolSuccess2 + | SessionToolFailed2 + | SessionRetried2 + | SessionCompactionStarted2 + | SessionCompactionDelta2 + | SessionCompactionEnded2 + | SessionRevertStaged2 + | SessionRevertCleared2 + | SessionRevertCommitted2 + | FilesystemChanged2 | ReferenceUpdated2 | PermissionV2Asked2 | PermissionV2Replied2 | PluginAdded2 + | PluginUpdated2 | ProjectDirectoriesUpdated2 | CommandUpdated2 + | ConfigUpdated2 | SkillUpdated2 - | FileWatcherUpdated2 | PtyCreated2 | PtyUpdated2 | PtyExited2 @@ -11838,24 +11344,6 @@ export type ForbiddenErrorV2 = { message: string } -export type Shell1V2 = { - id: string - status: "running" | "exited" | "timeout" | "killed" - command: string - cwd: string - shell: string - file: string - pid?: number - exit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - metadata: { - [key: string]: unknown - } - time: { - started: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - completed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } -} - export type ShellNotFoundErrorV2 = { _tag: "ShellNotFoundError" id: string @@ -16090,6 +15578,8 @@ export type V2HealthGetResponses = { */ 200: { healthy: true + version: string + pid: number } } @@ -16686,6 +16176,44 @@ export type V2SessionSyntheticResponses = { export type V2SessionSyntheticResponse = V2SessionSyntheticResponses[keyof V2SessionSyntheticResponses] +export type V2SessionShellData = { + body: { + id?: string | null + command: string + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/shell" +} + +export type V2SessionShellErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedErrorV2 + /** + * SessionNotFoundError + */ + 404: SessionNotFoundErrorV2 +} + +export type V2SessionShellError = V2SessionShellErrors[keyof V2SessionShellErrors] + +export type V2SessionShellResponses = { + /** + * + */ + 204: void +} + +export type V2SessionShellResponse = V2SessionShellResponses[keyof V2SessionShellResponses] + export type V2SessionCompactData = { body?: never path: { @@ -17857,6 +17385,35 @@ export type V2CredentialUpdateResponses = { export type V2CredentialUpdateResponse = V2CredentialUpdateResponses[keyof V2CredentialUpdateResponses] +export type V2ProjectListData = { + body?: never + path?: never + query?: never + url: "/api/project" +} + +export type V2ProjectListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedErrorV2 +} + +export type V2ProjectListError = V2ProjectListErrors[keyof V2ProjectListErrors] + +export type V2ProjectListResponses = { + /** + * Success + */ + 200: Array +} + +export type V2ProjectListResponse = V2ProjectListResponses[keyof V2ProjectListResponses] + export type V2ProjectCurrentData = { body?: never path?: never @@ -19049,7 +18606,7 @@ export type V2ShellListResponses = { */ 200: { location: LocationInfo2 - data: Array + data: Array } } @@ -19093,7 +18650,7 @@ export type V2ShellCreateResponses = { */ 200: { location: LocationInfo2 - data: Shell1V2 + data: ShellV2 } } @@ -19176,7 +18733,7 @@ export type V2ShellGetResponses = { */ 200: { location: LocationInfo2 - data: Shell1V2 + data: ShellV2 } } @@ -19606,6 +19163,35 @@ export type V2VcsDiffResponses = { export type V2VcsDiffResponse = V2VcsDiffResponses[keyof V2VcsDiffResponses] +export type V2DebugLocationData = { + body?: never + path?: never + query?: never + url: "/api/debug/location" +} + +export type V2DebugLocationErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedErrorV2 +} + +export type V2DebugLocationError = V2DebugLocationErrors[keyof V2DebugLocationErrors] + +export type V2DebugLocationResponses = { + /** + * Success + */ + 200: Array +} + +export type V2DebugLocationResponse = V2DebugLocationResponses[keyof V2DebugLocationResponses] + export type PtyConnectData = { body?: never path: { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index b30c3beb35..e136ef6836 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -16410,7 +16410,7 @@ "text": { "type": "string" }, - "synthetic": { + "session.synthetic": { "type": "boolean" }, "ignored": { @@ -23071,7 +23071,7 @@ "text": { "type": "string" }, - "synthetic": { + "session.synthetic": { "type": "boolean" }, "ignored": { @@ -27479,7 +27479,7 @@ }, "type": { "type": "string", - "enum": ["synthetic"] + "enum": ["session.synthetic"] } }, "required": ["id", "time", "sessionID", "text", "type"], diff --git a/packages/server/package.json b/packages/server/package.json index 86e5683dcf..3a14a250a9 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -12,8 +12,10 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { + "@effect/platform-node": "catalog:", "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", + "@opencode-ai/simulation": "workspace:*", "drizzle-orm": "catalog:", "effect": "catalog:" }, diff --git a/packages/server/src/auth.ts b/packages/server/src/auth.ts index c71536518d..239eb0d237 100644 --- a/packages/server/src/auth.ts +++ b/packages/server/src/auth.ts @@ -1,6 +1,7 @@ export * as ServerAuth from "./auth" -import { Config as EffectConfig, Context, Effect, Layer, Option, Redacted } from "effect" +import { Context, Effect, Layer, Option, Redacted } from "effect" +import { all, option, string, withDefault } from "effect/Config" export type Credentials = { password?: string @@ -27,9 +28,9 @@ export class Config extends Context.Service()("@opencode/ServerAut this, Effect.gen(function* () { return Config.of( - yield* EffectConfig.all({ - password: EffectConfig.string("OPENCODE_SERVER_PASSWORD").pipe(EffectConfig.option), - username: EffectConfig.string("OPENCODE_SERVER_USERNAME").pipe(EffectConfig.withDefault("opencode")), + yield* all({ + password: string("OPENCODE_SERVER_PASSWORD").pipe(option), + username: string("OPENCODE_SERVER_USERNAME").pipe(withDefault("opencode")), }), ) }), diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index bfae813372..896cbbc592 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -13,6 +13,7 @@ import { EventHandler } from "./handlers/event" import { AgentHandler } from "./handlers/agent" import { PluginHandler } from "./handlers/plugin" import { HealthHandler } from "./handlers/health" +import { DebugHandler } from "./handlers/debug" import { PtyHandler } from "./handlers/pty" import { ShellHandler } from "./handlers/shell" import { QuestionHandler } from "./handlers/question" @@ -27,6 +28,7 @@ import { VcsHandler } from "./handlers/vcs" export const handlers = Layer.mergeAll( HealthHandler, + DebugHandler, LocationHandler, AgentHandler, PluginHandler, diff --git a/packages/server/src/handlers/credential.ts b/packages/server/src/handlers/credential.ts index 7e138a5d5a..49e84d49ba 100644 --- a/packages/server/src/handlers/credential.ts +++ b/packages/server/src/handlers/credential.ts @@ -8,14 +8,16 @@ export const CredentialHandler = HttpApiBuilder.group(Api, "server.credential", .handle( "credential.update", Effect.fn(function* (ctx) { - yield* (yield* Integration.Service).connection.update(ctx.params.credentialID, { label: ctx.payload.label }) + const integration = yield* Integration.Service + yield* integration.connection.update(ctx.params.credentialID, { label: ctx.payload.label }) return HttpApiSchema.NoContent.make() }), ) .handle( "credential.remove", Effect.fn(function* (ctx) { - yield* (yield* Integration.Service).connection.remove(ctx.params.credentialID) + const integration = yield* Integration.Service + yield* integration.connection.remove(ctx.params.credentialID) return HttpApiSchema.NoContent.make() }), ), diff --git a/packages/server/src/handlers/debug.ts b/packages/server/src/handlers/debug.ts new file mode 100644 index 0000000000..f5ffd536e4 --- /dev/null +++ b/packages/server/src/handlers/debug.ts @@ -0,0 +1,14 @@ +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import { Effect, Option, RcMap } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { Api } from "../api" + +export const DebugHandler = HttpApiBuilder.group(Api, "server.debug", (handlers) => + handlers.handle( + "debug.location", + Effect.fn(function* () { + const locations = Option.getOrThrow(yield* Effect.serviceOption(LocationServiceMap.Service)) + return Array.from(yield* RcMap.keys(locations.rcMap)) + }), + ), +) diff --git a/packages/server/src/handlers/event.ts b/packages/server/src/handlers/event.ts index f412137aba..1c8b94d124 100644 --- a/packages/server/src/handlers/event.ts +++ b/packages/server/src/handlers/event.ts @@ -1,9 +1,9 @@ import { EventV2 } from "@opencode-ai/core/event" -import { OpenCodeEvent } from "@opencode-ai/protocol/groups/event" +import { isOpenCodeEvent, OpenCodeEvent } from "@opencode-ai/protocol/groups/event" import { Effect, Schema, Stream } from "effect" +import { Sse } from "effect/unstable/encoding" import { HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" -import * as Sse from "effect/unstable/encoding/Sse" import { Api } from "../api" const subscriberCapacity = 256 @@ -35,7 +35,10 @@ export const EventHandler = HttpApiBuilder.group(Api, "server.event", (handlers) const output = Stream.unwrap( Effect.gen(function* () { // Acquiring the bounded stream installs its listener before readiness is observable. - const live = yield* EventV2.liveBounded(events, subscriberCapacity) + const live = yield* EventV2.liveBounded(events, { + capacity: subscriberCapacity, + accept: isOpenCodeEvent, + }) return Stream.make(connected).pipe(Stream.concat(live)) }), ).pipe(Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode())) diff --git a/packages/server/src/handlers/fs.ts b/packages/server/src/handlers/fs.ts index c7d1d43bab..d623f2d7e6 100644 --- a/packages/server/src/handlers/fs.ts +++ b/packages/server/src/handlers/fs.ts @@ -11,7 +11,8 @@ export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handler return handlers .handleRaw("fs.read", (ctx) => Effect.gen(function* () { - const file = yield* (yield* FileSystem.Service).read({ + const fs = yield* FileSystem.Service + const file = yield* fs.read({ path: RelativePath.make( decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13)), ), diff --git a/packages/server/src/handlers/health.ts b/packages/server/src/handlers/health.ts index 60000b3fc0..a73b050aa7 100644 --- a/packages/server/src/handlers/health.ts +++ b/packages/server/src/handlers/health.ts @@ -1,7 +1,10 @@ +import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" export const HealthHandler = HttpApiBuilder.group(Api, "server.health", (handlers) => - handlers.handle("health.get", () => Effect.succeed({ healthy: true as const })), + handlers.handle("health.get", () => + Effect.succeed({ healthy: true as const, version: InstallationVersion, pid: process.pid }), + ), ) diff --git a/packages/server/src/handlers/model.ts b/packages/server/src/handlers/model.ts index 5059804354..b5d6ef37cf 100644 --- a/packages/server/src/handlers/model.ts +++ b/packages/server/src/handlers/model.ts @@ -1,4 +1,6 @@ import { Catalog } from "@opencode-ai/core/catalog" +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" +import { ServiceUnavailableError } from "@opencode-ai/protocol/errors" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" @@ -17,6 +19,19 @@ export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) .handle( "model.default", Effect.fn(function* () { + const plugins = yield* PluginSupervisor.Service + yield* plugins.ready.pipe( + Effect.timeoutOrElse({ + duration: "5 seconds", + orElse: () => + Effect.fail( + new ServiceUnavailableError({ + message: "Model catalog initialization timed out", + service: "model.catalog", + }), + ), + }), + ) const catalog = yield* Catalog.Service return yield* response(catalog.model.default()) }), diff --git a/packages/server/src/handlers/permission.ts b/packages/server/src/handlers/permission.ts index 0425c14996..cbc49180f6 100644 --- a/packages/server/src/handlers/permission.ts +++ b/packages/server/src/handlers/permission.ts @@ -17,7 +17,8 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission", .handle( "permission.request.list", Effect.fn(function* () { - return yield* response((yield* PermissionV2.Service).list()) + const permission = yield* PermissionV2.Service + return yield* response(permission.list()) }), ) .handle( @@ -59,7 +60,8 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission", .handle( "session.permission.get", Effect.fn(function* (ctx) { - const request = yield* (yield* PermissionV2.Service).get(ctx.params.requestID) + const permission = yield* PermissionV2.Service + const request = yield* permission.get(ctx.params.requestID) if (!request || request.sessionID !== ctx.params.sessionID) return yield* missingRequest(ctx.params.requestID) return { data: request } }), @@ -80,8 +82,9 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission", "permission.saved.list", Effect.fn(function* (ctx) { const location = yield* Location.Service + const saved = yield* PermissionSaved.Service return { - data: yield* (yield* PermissionSaved.Service).list({ + data: yield* saved.list({ projectID: ctx.query.projectID ?? location.project.id, }), } @@ -90,7 +93,8 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission", .handle( "permission.saved.remove", Effect.fn(function* (ctx) { - yield* (yield* PermissionSaved.Service).remove(ctx.params.id) + const saved = yield* PermissionSaved.Service + yield* saved.remove(ctx.params.id) return HttpApiSchema.NoContent.make() }), ) diff --git a/packages/server/src/handlers/project.ts b/packages/server/src/handlers/project.ts index c35b019b41..79206e6326 100644 --- a/packages/server/src/handlers/project.ts +++ b/packages/server/src/handlers/project.ts @@ -6,6 +6,7 @@ import { Api } from "../api" export const ProjectHandler = HttpApiBuilder.group(Api, "server.project", (handlers) => handlers + .handle("project.list", () => Project.Service.use((project) => project.list())) .handle("project.current", () => Location.Service.use((location) => Effect.succeed({ id: location.project.id, directory: location.project.directory }), diff --git a/packages/server/src/handlers/pty.ts b/packages/server/src/handlers/pty.ts index cda2cf43e9..7f8a9ea64e 100644 --- a/packages/server/src/handlers/pty.ts +++ b/packages/server/src/handlers/pty.ts @@ -5,7 +5,7 @@ import { Location } from "@opencode-ai/core/location" import { Effect, Queue } from "effect" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" -import * as Socket from "effect/unstable/socket/Socket" +import { Socket } from "effect/unstable/socket" import { Api } from "../api" import { CorsConfig, isAllowedRequestOrigin } from "../cors" import { ForbiddenError, PtyNotFoundError } from "@opencode-ai/protocol/errors" @@ -32,7 +32,8 @@ export const PtyHandler = HttpApiBuilder.group(Api, "server.pty", (handlers) => .handle( "pty.list", Effect.fn(function* () { - return yield* response((yield* Pty.Service).list()) + const pty = yield* Pty.Service + return yield* response(pty.list()) }), ) .handle( diff --git a/packages/server/src/handlers/question.ts b/packages/server/src/handlers/question.ts index 954afe0df5..d8011dd269 100644 --- a/packages/server/src/handlers/question.ts +++ b/packages/server/src/handlers/question.ts @@ -26,13 +26,15 @@ export const QuestionHandler = HttpApiBuilder.group(Api, "server.question", (han .handle( "question.request.list", Effect.fn(function* () { - return yield* response((yield* QuestionV2.Service).list()) + const question = yield* QuestionV2.Service + return yield* response(question.list()) }), ) .handle( "session.question.list", Effect.fn(function* (ctx) { - const requests = yield* (yield* QuestionV2.Service).list() + const question = yield* QuestionV2.Service + const requests = yield* question.list() return { data: requests.filter((request) => request.sessionID === ctx.params.sessionID) } }), ) diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index f6dbabdbf8..8a3cf1b130 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -323,6 +323,24 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl return HttpApiSchema.NoContent.make() }), ) + .handle( + "session.shell", + Effect.fn(function* (ctx) { + yield* session + .shell({ sessionID: ctx.params.sessionID, id: ctx.payload.id, command: ctx.payload.command }) + .pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) .handle( "session.compact", Effect.fn(function* (ctx) { diff --git a/packages/server/src/process.ts b/packages/server/src/process.ts new file mode 100644 index 0000000000..de6f4b328f --- /dev/null +++ b/packages/server/src/process.ts @@ -0,0 +1,64 @@ +export * as ServerProcess from "./process" + +import { NodeHttpClient, NodeHttpServer } from "@effect/platform-node" +import { Credential } from "@opencode-ai/core/credential" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { PermissionSaved } from "@opencode-ai/core/permission/saved" +import { Project } from "@opencode-ai/core/project" +import { HealthGroup } from "@opencode-ai/protocol/groups/health" +import { Context, Effect, Layer, Option } from "effect" +import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" +import { HttpApi, HttpApiClient } from "effect/unstable/httpapi" +import { createServer } from "node:http" +import { ServerAuth } from "./auth" +import { createRoutes } from "./routes" + +export type Options = { + readonly hostname: string + readonly port: Option.Option + readonly password: string +} + +const ReadinessApi = HttpApi.make("readiness").add(HealthGroup) + +export const start = Effect.fn("ServerProcess.start")(function* (options: Options) { + if (!options.password) return yield* Effect.fail(new Error("Missing server password")) + const address = yield* listen(options) + yield* Effect.gen(function* () { + const client = yield* HttpApiClient.make(ReadinessApi, { + baseUrl: HttpServer.formatAddress(address), + transformClient: HttpClient.mapRequest((request) => + HttpClientRequest.setHeader( + request, + "authorization", + ServerAuth.header({ username: "opencode", password: options.password }) ?? "", + ), + ), + }) + yield* client["server.health"]["health.get"]({}) + }).pipe(Effect.provide(NodeHttpClient.layerNodeHttp)) + return address +}) + +function listen(options: Options) { + if (Option.isSome(options.port)) return bind(options.hostname, options.port.value, options.password) + const next = (port: number): ReturnType => + bind(options.hostname, port, options.password).pipe( + Effect.catch((error) => (port === 65_535 ? Effect.fail(error) : next(port + 1))), + ) + return next(4096) +} + +function bind(hostname: string, port: number, password: string) { + const server = createServer() + return Layer.build( + HttpRouter.serve(createRoutes(password), { disableListenLog: true }).pipe( + Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })), + Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node, Project.node]))), + ), + ).pipe( + Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))), + Effect.map((context) => Context.get(context, HttpServer.HttpServer).address), + ) +} diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 30c3d961b7..606fde0b84 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -3,9 +3,12 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { EventV2 } from "@opencode-ai/core/event" +import { EventLogger } from "@opencode-ai/core/event-logger" +import { Observability } from "@opencode-ai/core/observability" import { Credential } from "@opencode-ai/core/credential" import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { PtyTicket } from "@opencode-ai/core/pty/ticket" +import { Project } from "@opencode-ai/core/project" import { SessionV2 } from "@opencode-ai/core/session" import { SessionExecution } from "@opencode-ai/core/session/execution" import { Job } from "@opencode-ai/core/job" @@ -23,16 +26,18 @@ import { handlers } from "./handlers" import { authorizationLayer } from "./middleware/authorization" import { schemaErrorLayer } from "./middleware/schema-error" import { PtyEnvironment } from "./pty-environment" -import { layer as locationLayer } from "./location" +import { layer } from "./location" import { formLocationLayer } from "./middleware/form-location" import { sessionLocationLayer } from "./middleware/session-location" const applicationServices = LayerNode.group([ Database.node, EventV2.node, + EventLogger.node, httpClient, ToolOutputStore.cleanupNode, Job.node, + Project.node, SessionV2.node, PluginRuntime.providerNode, PermissionSaved.node, @@ -72,21 +77,22 @@ function makeRoutes( const serviceLayer = simulationEnabled() ? Layer.unwrap( Effect.gen(function* () { - const { simulationReplacements } = yield* Effect.promise(() => import("./simulation")) + const { simulationReplacements } = yield* Effect.promise(() => import("@opencode-ai/simulation/backend")) return AppNodeBuilder.build(applicationServices, [...replacements, ...simulationReplacements]) }), ) : AppNodeBuilder.build(applicationServices, replacements) return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( - Layer.provide(handlers), + Layer.provide(handlers.pipe(Layer.provide(serviceLayer))), Layer.provide(formLocationLayer), Layer.provide(sessionLocationLayer), - Layer.provide(locationLayer), + Layer.provide(layer), Layer.provide(authorizationLayer), Layer.provide(schemaErrorLayer), Layer.provide(auth), Layer.provide(serviceLayer), + Layer.provide(Observability.layer), ) } @@ -97,4 +103,4 @@ function simulationEnabled() { export const routes = createRoutes() export const webHandler = () => - HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices)), { disableLogger: true }) + HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices))) diff --git a/packages/server/src/simulation/index.ts b/packages/server/src/simulation/index.ts deleted file mode 100644 index 3f499a5bf8..0000000000 --- a/packages/server/src/simulation/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { LayerNode } from "@opencode-ai/core/effect/layer-node" - -/** - * Layer replacements applied when the server is built in simulation mode. - * - * Empty for now; simulation-mode implementations will populate this with - * replacement nodes/layers that swap real services for simulated ones (e.g. - * a fake filesystem). The server merges these into the app node build when - * `OPENCODE_SIMULATION` is enabled, via a dynamic import so this module is - * never loaded eagerly. - */ -export const simulationReplacements: LayerNode.Replacements = [] - -export * as Simulation from "./index" diff --git a/packages/simulation/package.json b/packages/simulation/package.json new file mode 100644 index 0000000000..29613ff28a --- /dev/null +++ b/packages/simulation/package.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/simulation", + "version": "1.17.13", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + "./backend": "./src/backend/index.ts", + "./backend/*": "./src/backend/*.ts", + "./frontend": "./src/frontend/simulation.ts", + "./frontend/*": "./src/frontend/*.ts", + "./protocol": "./src/protocol/index.ts" + }, + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@opencode-ai/core": "workspace:*", + "@opencode-ai/llm": "workspace:*", + "@opentui/core": "catalog:", + "effect": "catalog:" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + } +} diff --git a/packages/simulation/src/backend/control.ts b/packages/simulation/src/backend/control.ts new file mode 100644 index 0000000000..10bded13af --- /dev/null +++ b/packages/simulation/src/backend/control.ts @@ -0,0 +1,111 @@ +import { Effect } from "effect" +import { SimulationProtocol } from "../protocol" +import { SimulationLLMExchange } from "./llm-exchange" +import { SimulationNetwork } from "./network" + +/** + * Backend-hosted simulation control WebSocket. + * + * JSON-RPC 2.0 over a loopback WebSocket, mirroring the protocol of the TUI + * simulation server. Drivers connect directly (standalone topology; no + * frontend proxy) to answer LLM exchanges and inspect the simulated network. + * This is also the headless-simulation interface: it works with no TUI at + * all. + * + * Methods: + * - `llm.attach` -> subscribe; pending and future exchanges arrive + * as `llm.request` notifications + * - `llm.chunk` { id, items } append response items to an exchange + * - `llm.finish` { id, reason? } finish an exchange + * - `llm.pending` list open exchanges + * - `network.log` simulated network request log + */ + +const DefaultPort = 40950 +const MaxPortAttempts = 100 + +type ControlSocket = Bun.ServerWebSocket<{ unsubscribe?: () => void }> + +function parseRequest(input: string | Buffer) { + return SimulationProtocol.JsonRpc.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString())) +} + +async function handle(socket: ControlSocket, request: SimulationProtocol.JsonRpc.Request): Promise { + switch (request.method) { + case "llm.attach": { + socket.data.unsubscribe?.() + socket.data.unsubscribe = SimulationLLMExchange.subscribe((exchange) => { + socket.send(JSON.stringify({ jsonrpc: "2.0", method: "llm.request", params: exchange })) + }) + return { attached: true } + } + case "llm.chunk": { + const params = await SimulationProtocol.Backend.decodeChunkParams(request.params) + await Effect.runPromise( + SimulationLLMExchange.push( + params.id, + params.items.map((item) => ({ type: "item", item }) as const), + ), + ) + return { ok: true } + } + case "llm.finish": { + const params = await SimulationProtocol.Backend.decodeFinishParams(request.params) + await Effect.runPromise(SimulationLLMExchange.push(params.id, [{ type: "finish", reason: params.reason }])) + return { ok: true } + } + case "llm.pending": + return { exchanges: SimulationLLMExchange.pending() } + case "network.log": + return { entries: SimulationNetwork.log() } + } + throw new Error(`Unknown simulation control method: ${request.method}`) +} + +function serve(port = DefaultPort, attempts = MaxPortAttempts): Bun.Server<{ unsubscribe?: () => void }> { + try { + return Bun.serve<{ unsubscribe?: () => void }>({ + hostname: "127.0.0.1", + port, + fetch(request, server) { + if (server.upgrade(request, { data: {} })) return undefined + return new Response("opencode simulation control websocket", { status: 426 }) + }, + websocket: { + close(socket) { + socket.data.unsubscribe?.() + }, + async message(socket, message) { + let request: SimulationProtocol.JsonRpc.Request | undefined + try { + request = parseRequest(message) + const result = await handle(socket, request) + const response = SimulationProtocol.JsonRpc.success(request.id, result) + if (response) socket.send(JSON.stringify(response)) + } catch (error) { + socket.send(JSON.stringify(SimulationProtocol.JsonRpc.failure(request?.id, error))) + } + }, + }, + }) + } catch (error) { + const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase() + const unavailable = message.includes("eaddrinuse") || message.includes("in use") + if (!unavailable || attempts <= 1 || port >= 65535) throw error + return serve(port + 1, attempts - 1) + } +} + +export function start() { + const server = serve() + const url = `ws://${server.hostname}:${server.port}` + process.stderr.write(`opencode simulation backend control websocket: ${url}\n`) + return { + url, + stop: () => { + server.stop(true) + }, + } +} + +export * as SimulationControl from "./control" diff --git a/packages/simulation/src/backend/filesystem.ts b/packages/simulation/src/backend/filesystem.ts new file mode 100644 index 0000000000..01a7b00329 --- /dev/null +++ b/packages/simulation/src/backend/filesystem.ts @@ -0,0 +1,390 @@ +import { Effect, FileSystem, Layer, Option, Stream } from "effect" +import { systemError, type PlatformError, type SystemErrorTag } from "effect/PlatformError" +import nodeFs from "fs" +import path from "path" + +/** + * In-memory simulated `FileSystem.FileSystem`. + * + * Replaces the `NodeFileSystem` platform node when the server runs in + * simulation mode. Backed by a flat map of absolute paths to entries and + * rooted at a single directory (the simulation anchor): paths that resolve + * outside the root fail with `PermissionDenied` so host filesystem escapes + * are loud. Only the operations the app actually uses are implemented; + * everything else dies with a clear defect. + * + * Inspired by the V1 prototype on `jlongster/simulation-rebase`, rewritten + * for the V2 platform node shape without the `just-bash` dependency. + */ + +export interface Options { + readonly root: string + readonly files?: Record +} + +interface FileEntry { + readonly type: "File" + content: Uint8Array + mode: number + mtime: Date +} + +interface DirectoryEntry { + readonly type: "Directory" + mode: number + mtime: Date +} + +type Entry = FileEntry | DirectoryEntry + +export function make(options: Options): FileSystem.FileSystem { + const root = path.resolve(options.root) + const store = new Map() + const temp = { value: 0 } + const encoder = new TextEncoder() + store.set(root, makeDirectoryEntry()) + + const within = (resolved: string) => resolved === root || resolved.startsWith(withSep(root)) + + const childrenOf = (resolved: string) => [...store.keys()].filter((key) => key.startsWith(withSep(resolved))) + + const fail = ( + tag: SystemErrorTag, + method: string, + file: string, + description?: string, + ): Effect.Effect => + Effect.fail( + systemError({ _tag: tag, module: "SimulationFileSystem", method, description, pathOrDescriptor: file }), + ) + + const locate = (method: string, file: string): Effect.Effect => { + const resolved = path.resolve(root, file) + if (within(resolved)) return Effect.succeed(resolved) + return fail("PermissionDenied", method, file, "path escapes the simulated filesystem root") + } + + const requireEntry = (method: string, file: string): Effect.Effect => + locate(method, file).pipe( + Effect.flatMap((resolved) => { + const entry = store.get(resolved) + if (!entry) return fail("NotFound", method, file) + return Effect.succeed([resolved, entry] as const) + }), + ) + + const requireParentDirectory = ( + method: string, + resolved: string, + file: string, + ): Effect.Effect => { + const parent = store.get(path.dirname(resolved)) + if (parent?.type === "Directory") return Effect.void + return fail("NotFound", method, file, "parent directory does not exist") + } + + // Creates every missing directory between root and resolved (inclusive). + const ensureDirectories = (method: string, file: string, resolved: string): Effect.Effect => + Effect.suspend(() => { + const segments = path.relative(root, resolved).split(path.sep).filter(Boolean) + const conflict = segments.reduce>((current, segment) => { + if (typeof current !== "string") return current + const next = path.join(current, segment) + const entry = store.get(next) + if (entry && entry.type !== "Directory") + return fail("AlreadyExists", method, file, "path component is not a directory") + if (!entry) store.set(next, makeDirectoryEntry()) + return next + }, root) + return typeof conflict === "string" ? Effect.void : conflict + }) + + // Seed initial files, creating parents as needed. Entries outside the root are ignored. + for (const [file, content] of Object.entries(options.files ?? {})) { + const resolved = path.resolve(root, file) + if (!within(resolved)) continue + Effect.runSync(ensureDirectories("seed", file, path.dirname(resolved))) + store.set(resolved, { + type: "File", + content: typeof content === "string" ? encoder.encode(content) : content.slice(), + mode: 0o644, + mtime: new Date(), + }) + } + + // Probe operations report NotFound outside the root instead of + // PermissionDenied: walk-up loops (project discovery, findUp, globUp) + // legitimately probe ancestor directories of the anchor and must observe + // "nothing there". Content access and mutation outside the root stay loud. + const probe = (method: string, file: string): Effect.Effect => + Effect.suspend(() => { + const resolved = path.resolve(root, file) + const entry = within(resolved) ? store.get(resolved) : undefined + if (!entry) return fail("NotFound", method, file) + return Effect.succeed(entry) + }) + + const stat: FileSystem.FileSystem["stat"] = (file) => probe("stat", file).pipe(Effect.map(toInfo)) + + const access: FileSystem.FileSystem["access"] = (file) => probe("access", file).pipe(Effect.asVoid) + + const chmod: FileSystem.FileSystem["chmod"] = (file, mode) => + requireEntry("chmod", file).pipe( + Effect.map(([, entry]) => { + entry.mode = mode + }), + ) + + const realPath: FileSystem.FileSystem["realPath"] = (file) => + requireEntry("realPath", file).pipe(Effect.map(([resolved]) => resolved)) + + const readFile: FileSystem.FileSystem["readFile"] = (file) => + requireEntry("readFile", file).pipe( + Effect.flatMap(([, entry]) => { + if (entry.type !== "File") return fail("BadResource", "readFile", file, "path is a directory") + return Effect.succeed(entry.content.slice()) + }), + ) + + const writeFile: FileSystem.FileSystem["writeFile"] = (file, data, writeOptions) => + locate("writeFile", file).pipe( + Effect.flatMap((resolved) => { + const existing = store.get(resolved) + if (existing?.type === "Directory") return fail("BadResource", "writeFile", file, "path is a directory") + return requireParentDirectory("writeFile", resolved, file).pipe( + Effect.map(() => { + store.set(resolved, { + type: "File", + content: data.slice(), + mode: writeOptions?.mode ?? existing?.mode ?? 0o644, + mtime: new Date(), + }) + }), + ) + }), + ) + + const makeDirectory: FileSystem.FileSystem["makeDirectory"] = (file, dirOptions) => + locate("makeDirectory", file).pipe( + Effect.flatMap((resolved) => { + if (dirOptions?.recursive) return ensureDirectories("makeDirectory", file, resolved) + if (store.has(resolved)) return fail("AlreadyExists", "makeDirectory", file) + return requireParentDirectory("makeDirectory", resolved, file).pipe( + Effect.map(() => { + store.set(resolved, { type: "Directory", mode: dirOptions?.mode ?? 0o755, mtime: new Date() }) + }), + ) + }), + ) + + const readDirectory: FileSystem.FileSystem["readDirectory"] = (file, readOptions) => + requireEntry("readDirectory", file).pipe( + Effect.flatMap(([resolved, entry]) => { + if (entry.type !== "Directory") return fail("BadResource", "readDirectory", file, "path is not a directory") + const children = childrenOf(resolved) + const names = readOptions?.recursive + ? children.map((key) => path.relative(resolved, key)) + : children.filter((key) => path.dirname(key) === resolved).map((key) => path.basename(key)) + return Effect.succeed(names.sort((a, b) => a.localeCompare(b))) + }), + ) + + const remove: FileSystem.FileSystem["remove"] = (file, removeOptions) => + locate("remove", file).pipe( + Effect.flatMap((resolved) => { + const entry = store.get(resolved) + if (!entry) return removeOptions?.force ? Effect.void : fail("NotFound", "remove", file) + const children = childrenOf(resolved) + if (entry.type === "Directory" && children.length > 0 && !removeOptions?.recursive) + return fail("Unknown", "remove", file, "directory is not empty") + for (const key of children) store.delete(key) + store.delete(resolved) + // The root itself must always exist. + if (resolved === root) store.set(root, makeDirectoryEntry()) + return Effect.void + }), + ) + + const rename: FileSystem.FileSystem["rename"] = (oldPath, newPath) => + Effect.all([locate("rename", oldPath), locate("rename", newPath)]).pipe( + Effect.flatMap(([from, to]) => { + const entry = store.get(from) + if (!entry) return fail("NotFound", "rename", oldPath) + return requireParentDirectory("rename", to, newPath).pipe( + Effect.map(() => { + const moved = [from, ...childrenOf(from)].map((key) => [key, store.get(key)!] as const) + for (const [key] of moved) store.delete(key) + for (const key of [to, ...childrenOf(to)]) store.delete(key) + for (const [key, value] of moved) store.set(key === from ? to : to + key.slice(from.length), value) + }), + ) + }), + ) + + const copy: FileSystem.FileSystem["copy"] = (fromPath, toPath) => + Effect.all([locate("copy", fromPath), locate("copy", toPath)]).pipe( + Effect.flatMap(([from, to]) => { + const entry = store.get(from) + if (!entry) return fail("NotFound", "copy", fromPath) + return requireParentDirectory("copy", to, toPath).pipe( + Effect.map(() => { + for (const key of [from, ...childrenOf(from)]) { + const source = store.get(key)! + const target = key === from ? to : to + key.slice(from.length) + store.set( + target, + source.type === "File" + ? { ...source, content: source.content.slice(), mtime: new Date() } + : { ...source, mtime: new Date() }, + ) + } + }), + ) + }), + ) + + const copyFile: FileSystem.FileSystem["copyFile"] = (fromPath, toPath) => + readFile(fromPath).pipe(Effect.flatMap((content) => writeFile(toPath, content))) + + const makeTempDirectory: FileSystem.FileSystem["makeTempDirectory"] = (tempOptions) => + Effect.suspend(() => { + const directory = tempOptions?.directory ?? path.join(root, ".simulation-tmp") + const file = path.join(directory, `${tempOptions?.prefix ?? "tmp-"}${++temp.value}`) + return makeDirectory(file, { recursive: true }).pipe(Effect.map(() => file)) + }) + + const makeTempDirectoryScoped: FileSystem.FileSystem["makeTempDirectoryScoped"] = (tempOptions) => + Effect.acquireRelease(makeTempDirectory(tempOptions), (directory) => + remove(directory, { recursive: true, force: true }).pipe(Effect.ignore), + ) + + // Read-only file handle: enough for the read tool's stat/seek/readAlloc use. + const open: FileSystem.FileSystem["open"] = (file) => + requireEntry("open", file).pipe( + Effect.map(([resolved]) => { + const position = { value: 0 } + const contentOf = () => { + const current = store.get(resolved) + return current?.type === "File" ? current.content : new Uint8Array() + } + return { + [FileSystem.FileTypeId]: FileSystem.FileTypeId, + fd: FileSystem.FileDescriptor(0), + stat: Effect.suspend(() => stat(resolved)), + seek: (offset, from) => + Effect.sync(() => { + position.value = from === "start" ? Number(offset) : position.value + Number(offset) + }), + sync: Effect.void, + read: (buffer) => + Effect.sync(() => { + const chunk = contentOf().subarray(position.value, position.value + buffer.length) + buffer.set(chunk) + position.value += chunk.length + return FileSystem.Size(chunk.length) + }), + readAlloc: (size) => + Effect.sync(() => { + const chunk = contentOf().slice(position.value, position.value + Number(size)) + position.value += chunk.length + return chunk.length === 0 ? Option.none() : Option.some(chunk) + }), + truncate: () => unimplemented("File.truncate"), + write: () => unimplemented("File.write"), + writeAll: () => unimplemented("File.writeAll"), + } satisfies FileSystem.File + }), + ) + + return FileSystem.make({ + access, + chmod, + chown: () => unimplemented("chown"), + copy, + copyFile, + link: () => unimplemented("link"), + makeDirectory, + makeTempDirectory, + makeTempDirectoryScoped, + makeTempFile: () => unimplemented("makeTempFile"), + makeTempFileScoped: () => unimplemented("makeTempFileScoped"), + open, + readDirectory, + readFile, + readLink: () => unimplemented("readLink"), + realPath, + remove, + rename, + stat, + symlink: () => unimplemented("symlink"), + truncate: () => unimplemented("truncate"), + utimes: () => unimplemented("utimes"), + watch: () => Stream.die(new Error("SimulationFileSystem.watch is not implemented in simulation")), + writeFile, + }) +} + +/** + * Lazily constructed layer so the root defaults to `process.cwd()` at + * layer-build time (the simulation anchor directory), not at import time. + * + * When `OPENCODE_SIMULATION_STATE` points at a snapshot directory, its + * `project/` contents are read from the host once at build time and seeded + * into the in-memory tree, joined onto the anchor root. + */ +export const layer = (options?: Partial) => + Layer.sync(FileSystem.FileSystem)(() => + make({ + root: options?.root ?? process.cwd(), + files: { ...loadSnapshotFiles(process.env.OPENCODE_SIMULATION_STATE), ...options?.files }, + }), + ) + +function loadSnapshotFiles(stateDirectory: string | undefined) { + if (!stateDirectory) return {} + const project = path.join(stateDirectory, "project") + if (!nodeFs.existsSync(project)) return {} + const files: Record = {} + const walk = (dir: string) => { + for (const entry of nodeFs.readdirSync(dir, { withFileTypes: true })) { + const file = path.join(dir, entry.name) + if (entry.isDirectory()) walk(file) + if (entry.isFile()) files[path.relative(project, file)] = new Uint8Array(nodeFs.readFileSync(file)) + } + } + walk(project) + return files +} + +function makeDirectoryEntry(): Entry { + return { type: "Directory", mode: 0o755, mtime: new Date() } +} + +function withSep(dir: string) { + return dir.endsWith(path.sep) ? dir : dir + path.sep +} + +function toInfo(entry: Entry): FileSystem.File.Info { + return { + type: entry.type, + mtime: Option.some(entry.mtime), + atime: Option.some(entry.mtime), + birthtime: Option.some(entry.mtime), + dev: 0, + ino: Option.none(), + mode: entry.mode, + nlink: Option.none(), + uid: Option.none(), + gid: Option.none(), + rdev: Option.none(), + size: FileSystem.Size(entry.type === "File" ? entry.content.length : 0), + blksize: Option.none(), + blocks: Option.none(), + } +} + +function unimplemented(method: string) { + return Effect.die(new Error(`SimulationFileSystem.${method} is not implemented in simulation`)) +} + +export * as SimulationFileSystem from "./filesystem" diff --git a/packages/simulation/src/backend/fs-util.ts b/packages/simulation/src/backend/fs-util.ts new file mode 100644 index 0000000000..e24d0c9c4b --- /dev/null +++ b/packages/simulation/src/backend/fs-util.ts @@ -0,0 +1,89 @@ +import { Effect, FileSystem, Layer } from "effect" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Glob } from "@opencode-ai/core/util/glob" +import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" +import { filesystem } from "@opencode-ai/core/effect/app-node-platform" +import path from "path" + +/** + * Simulation replacement for `FSUtil`. + * + * The real `FSUtil` layer builds most helpers on the injected + * `FileSystem.FileSystem`, but `readDirectoryEntries`, `glob`, and `globUp` + * reach for node `fs/promises` and the `glob` package directly, and `resolve` + * canonicalizes through the host filesystem. This wraps the real layer and + * reroutes those through the injected `FileSystem`/lexical path resolution so + * every read observes the in-memory tree. + */ + +const layer = Layer.effect( + FSUtil.Service, + Effect.gen(function* () { + const base = yield* FSUtil.Service + const fs = yield* FileSystem.FileSystem + + const resolve = Effect.fn("SimulationFSUtil.resolve")(function* (input: string) { + return input + }) + + const readDirectoryEntries = Effect.fn("SimulationFSUtil.readDirectoryEntries")(function* (dirPath: string) { + const names = yield* fs.readDirectory(dirPath) + return yield* Effect.forEach(names, (name) => + fs.stat(path.join(dirPath, name)).pipe( + Effect.map( + (info): FSUtil.DirEntry => ({ + name, + type: + info.type === "Directory" + ? "directory" + : info.type === "File" + ? "file" + : info.type === "SymbolicLink" + ? "symlink" + : "other", + }), + ), + Effect.orElseSucceed((): FSUtil.DirEntry => ({ name, type: "other" })), + ), + ) + }) + + const glob = Effect.fn("SimulationFSUtil.glob")(function* (pattern: string, options?: Glob.Options) { + const cwd = path.resolve(options?.cwd ?? process.cwd()) + const entries = yield* fs + .readDirectory(cwd, { recursive: true }) + .pipe(Effect.orElseSucceed(() => [] as string[])) + const matches = yield* Effect.forEach(entries, (entry) => + fs.stat(path.join(cwd, entry)).pipe( + Effect.map((info) => ({ entry, type: info.type })), + Effect.orElseSucceed(() => undefined), + ), + ) + return matches + .filter((item) => item !== undefined) + .filter((item) => options?.include === "all" || item.type === "File") + .filter((item) => Glob.match(pattern, item.entry)) + .map((item) => (options?.absolute ? path.join(cwd, item.entry) : item.entry)) + .sort((a, b) => a.localeCompare(b)) + }) + + const globUp = Effect.fn("SimulationFSUtil.globUp")(function* (pattern: string, start: string, stop?: string) { + const result: string[] = [] + let current = path.resolve(start) + while (true) { + result.push(...(yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }))) + if (stop === current) break + const parent = path.dirname(current) + if (parent === current) break + current = parent + } + return result + }) + + return FSUtil.Service.of({ ...base, readDirectoryEntries, resolve, glob, globUp }) + }), +).pipe(Layer.provide(FSUtil.layer)) + +export const node = makeGlobalNode({ service: FSUtil.Service, layer, deps: [filesystem] }) + +export * as SimulationFSUtil from "./fs-util" diff --git a/packages/simulation/src/backend/index.ts b/packages/simulation/src/backend/index.ts new file mode 100644 index 0000000000..dd735d40dd --- /dev/null +++ b/packages/simulation/src/backend/index.ts @@ -0,0 +1,41 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { filesystem, httpClient } from "@opencode-ai/core/effect/app-node-platform" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { SimulationControl } from "./control" +import { SimulationFileSystem } from "./filesystem" +import { SimulationFSUtil } from "./fs-util" +import { SimulationNetwork } from "./network" +import { SimulationOpenAI } from "./openai" + +/** + * Layer replacements applied when the server is built in simulation mode. + * + * The server merges these into the app node build when `OPENCODE_SIMULATION` + * is enabled, via a dynamic import so this module is never loaded eagerly. + * + * - Filesystem: in-memory tree rooted at `OPENCODE_SIMULATION_ROOT` (the real, + * empty anchor directory the runner created and chdir'd into). Everything + * under the root lives in memory; paths outside it fail loudly. + * - Network: all outbound HTTP resolves against the simulated route table; + * unknown destinations are denied. The driver-answered OpenAI endpoint is + * registered here as the first route. + * + * Loading this module also starts the backend simulation control WebSocket, + * which drivers connect to directly for LLM exchange control and network + * inspection (standalone topology; also the headless-simulation interface). + */ + +SimulationNetwork.register(SimulationOpenAI.route) +// ModelsDev dies when its catalog fetch fails, so simulation answers it with +// an empty catalog; providers come from seeded config instead. +SimulationNetwork.register(SimulationNetwork.json("GET", "https://models.dev/api.json", {})) + +SimulationControl.start() + +export const simulationReplacements: LayerNode.Replacements = [ + [filesystem, SimulationFileSystem.layer({ root: process.env.OPENCODE_SIMULATION_ROOT })], + [FSUtil.node, SimulationFSUtil.node], + [httpClient, SimulationNetwork.layer], +] + +export * as Simulation from "./index" diff --git a/packages/simulation/src/backend/llm-exchange.ts b/packages/simulation/src/backend/llm-exchange.ts new file mode 100644 index 0000000000..759f52866c --- /dev/null +++ b/packages/simulation/src/backend/llm-exchange.ts @@ -0,0 +1,105 @@ +import { Effect, Queue } from "effect" + +/** + * Pending driver-answered LLM exchanges. + * + * When the simulated network receives a provider request it opens an + * exchange: the parsed request body plus a queue of response chunks. The + * simulation control WebSocket notifies the external driver, and the driver + * pushes chunks back until it finishes the exchange. The driver is the + * model; nothing is scripted or enqueued server-side. + * + * Process-global by design (plain module state, like the network route + * table): the simulated network and the control server must observe the same + * exchanges regardless of which layer instance touched them. + */ + +/** One response item the driver sends back. Compiled to provider wire chunks by the endpoint. */ +export type Item = + | { readonly type: "textDelta"; readonly text: string } + | { readonly type: "reasoningDelta"; readonly text: string } + | { readonly type: "toolCall"; readonly id: string; readonly name: string; readonly input: unknown } + | { readonly type: "raw"; readonly chunk: unknown } + +export type FinishReason = "stop" | "tool-calls" | "length" | "content-filter" + +export type Chunk = + | { readonly type: "item"; readonly item: Item } + | { readonly type: "finish"; readonly reason: FinishReason } + +export interface Exchange { + readonly id: string + readonly url: string + readonly body: unknown + readonly queue: Queue.Queue +} + +export interface OpenedExchange { + readonly id: string + readonly url: string + readonly body: unknown +} + +const state = { + counter: 0, + exchanges: new Map(), + listeners: new Set<(exchange: OpenedExchange) => void>(), +} + +export class ExchangeNotFoundError extends Error { + constructor(id: string) { + super(`Simulation LLM exchange not found or already finished: ${id}`) + } +} + +/** Opens an exchange and notifies listeners. Called by the simulated provider endpoint. */ +export const open = (input: { readonly url: string; readonly body: unknown }) => + Effect.gen(function* () { + const id = `ex_${++state.counter}` + const queue = yield* Queue.unbounded() + const exchange: Exchange = { id, url: input.url, body: input.body, queue } + state.exchanges.set(id, exchange) + for (const listener of state.listeners) listener({ id, url: input.url, body: input.body }) + return exchange + }) + +/** Closes an exchange without consuming remaining chunks (response interrupted or finished). */ +export const close = (id: string) => + Effect.suspend(() => { + const exchange = state.exchanges.get(id) + state.exchanges.delete(id) + if (!exchange) return Effect.void + return Queue.shutdown(exchange.queue).pipe(Effect.asVoid) + }) + +/** Appends response chunks to an open exchange. Driver-facing. */ +export const push = (id: string, chunks: readonly Chunk[]) => + Effect.gen(function* () { + const exchange = state.exchanges.get(id) + if (!exchange) return yield* Effect.fail(new ExchangeNotFoundError(id)) + yield* Queue.offerAll(exchange.queue, chunks) + }) + +/** + * Registers a listener for newly opened exchanges and immediately replays + * currently-pending ones, so a late-attaching driver observes requests that + * arrived before it connected. Returns an unsubscribe function. + */ +export function subscribe(listener: (exchange: OpenedExchange) => void) { + state.listeners.add(listener) + for (const exchange of pending()) listener(exchange) + return () => { + state.listeners.delete(listener) + } +} + +/** Snapshot of currently open exchanges, for control-surface inspection. */ +export function pending(): OpenedExchange[] { + return [...state.exchanges.values()].map((exchange) => ({ + id: exchange.id, + url: exchange.url, + body: exchange.body, + })) +} + +export * as SimulationLLMExchange from "./llm-exchange" diff --git a/packages/simulation/src/backend/network.ts b/packages/simulation/src/backend/network.ts new file mode 100644 index 0000000000..7b002fcb4e --- /dev/null +++ b/packages/simulation/src/backend/network.ts @@ -0,0 +1,94 @@ +import { Effect, Layer } from "effect" +import { HttpClient, HttpClientResponse } from "effect/unstable/http" +import { HttpClientError, TransportError } from "effect/unstable/http/HttpClientError" +import type { HttpClientRequest } from "effect/unstable/http" + +/** + * Simulated network. + * + * Replaces the `HttpClient.HttpClient` platform node in simulation mode. All + * outbound HTTP resolves against an in-memory route table; unknown + * destinations fail loudly with a transport error so no simulation run can + * silently reach the real network. The scripted LLM is one registered route, + * not a separate mechanism. + * + * The route table is process-global module state so the control surface and + * the client layer observe the same registrations. + */ + +export interface Route { + /** Return a response effect to claim the request, undefined to pass. */ + readonly match: ( + request: HttpClientRequest.HttpClientRequest, + url: URL, + ) => Effect.Effect | undefined +} + +interface LogEntry { + readonly time: number + readonly method: string + readonly url: string + readonly matched: boolean +} + +const state = { + routes: [] as Route[], + log: [] as LogEntry[], +} + +const LOG_LIMIT = 1000 + +export function register(route: Route) { + state.routes.push(route) + return () => { + const index = state.routes.indexOf(route) + if (index >= 0) state.routes.splice(index, 1) + } +} + +/** Static JSON route: exact method + origin/path match answered with a fixed body. */ +export function json(method: string, url: string, body: unknown): Route { + return { + match: (request, requestUrl) => { + if (request.method !== method) return undefined + if (requestUrl.origin + requestUrl.pathname !== url) return undefined + return Effect.sync(() => + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }), + ), + ) + }, + } +} + +export function log(): readonly LogEntry[] { + return state.log +} + +function record(entry: LogEntry) { + state.log.push(entry) + if (state.log.length > LOG_LIMIT) state.log.splice(0, state.log.length - LOG_LIMIT) +} + +export const layer = Layer.sync(HttpClient.HttpClient)(() => + HttpClient.make((request, url) => + Effect.suspend(() => { + const matched = state.routes + .map((route) => route.match(request, url)) + .find((response) => response !== undefined) + record({ time: Date.now(), method: request.method, url: url.toString(), matched: matched !== undefined }) + if (matched) return matched + return Effect.fail( + new HttpClientError({ + reason: new TransportError({ + request, + description: `Simulation denied unregistered network destination: ${request.method} ${url}`, + }), + }), + ) + }), + ), +) + +export * as SimulationNetwork from "./network" diff --git a/packages/simulation/src/backend/openai.ts b/packages/simulation/src/backend/openai.ts new file mode 100644 index 0000000000..c13e7772ef --- /dev/null +++ b/packages/simulation/src/backend/openai.ts @@ -0,0 +1,89 @@ +import { Effect, Schema, Stream } from "effect" +import { HttpClientResponse } from "effect/unstable/http" +import { OpenAIChatEvent, DEFAULT_BASE_URL, PATH } from "@opencode-ai/llm/protocols/openai-chat" +import { SimulationLLMExchange } from "./llm-exchange" +import { SimulationNetwork } from "./network" + +/** + * Driver-answered OpenAI endpoint for the simulated network. + * + * Claims `POST {DEFAULT_BASE_URL}{PATH}` (the real openai-chat route + * endpoint), opens an LLM exchange, and streams the driver's chunks back as + * an OpenAI Chat SSE response terminated by `[DONE]`. Everything downstream + * of the response bytes is the real pipeline: SSE framing, the OpenAIChat + * event schema, the protocol state machine, and Lifecycle grammar. + */ + +const encodeChunk = Schema.encodeUnknownSync(OpenAIChatEvent) + +const encoder = new TextEncoder() + +// The simulated model id is echoed back only in non-schema fields; the +// protocol event schema ignores unknown fields, so id/object/model are +// decorative wire realism. +function chunkOf(item: SimulationLLMExchange.Item): OpenAIChatEvent | unknown { + if (item.type === "textDelta") return { choices: [{ delta: { content: item.text } }] } + if (item.type === "reasoningDelta") return { choices: [{ delta: { reasoning_content: item.text } }] } + if (item.type === "toolCall") + return { + choices: [ + { + delta: { + tool_calls: [ + { index: 0, id: item.id, function: { name: item.name, arguments: JSON.stringify(item.input) } }, + ], + }, + }, + ], + } + return item.chunk +} + +const finishReasonWire: Record = { + stop: "stop", + "tool-calls": "tool_calls", + length: "length", + "content-filter": "content_filter", +} + +function frame(payload: unknown): Uint8Array { + return encoder.encode(`data: ${JSON.stringify(payload)}\n\n`) +} + +function sseBody(exchange: SimulationLLMExchange.Exchange): Stream.Stream { + const chunks = Stream.fromQueue(exchange.queue).pipe( + Stream.takeUntil((chunk) => chunk.type === "finish"), + Stream.map((chunk) => { + if (chunk.type === "finish") + return frame(encodeChunk({ choices: [{ delta: {}, finish_reason: finishReasonWire[chunk.reason] }] })) + if (chunk.item.type === "raw") return frame(chunk.item.chunk) + return frame(encodeChunk(chunkOf(chunk.item))) + }), + ) + return chunks.pipe( + Stream.concat(Stream.make(encoder.encode("data: [DONE]\n\n"))), + // Close the exchange when the response body ends or is interrupted, so + // late driver pushes fail with ExchangeNotFoundError instead of leaking. + Stream.ensuring(SimulationLLMExchange.close(exchange.id)), + ) +} + +export const route: SimulationNetwork.Route = { + match: (request, url) => { + if (request.method !== "POST") return undefined + if (url.origin + url.pathname !== DEFAULT_BASE_URL + PATH) return undefined + return Effect.gen(function* () { + const body = request.body._tag === "Uint8Array" ? JSON.parse(new TextDecoder().decode(request.body.body)) : {} + const exchange = yield* SimulationLLMExchange.open({ url: url.toString(), body }) + return HttpClientResponse.fromWeb( + request, + new Response(Stream.toReadableStream(sseBody(exchange)), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + ) + }) + }, +} + +export * as SimulationOpenAI from "./openai" diff --git a/packages/tui/src/simulation/actions.ts b/packages/simulation/src/frontend/actions.ts similarity index 84% rename from packages/tui/src/simulation/actions.ts rename to packages/simulation/src/frontend/actions.ts index 68725ec252..d03e4a5f75 100644 --- a/packages/tui/src/simulation/actions.ts +++ b/packages/simulation/src/frontend/actions.ts @@ -1,36 +1,11 @@ import type { CliRenderer, Renderable } from "@opentui/core" import { createMockKeys, createMockMouse, type MockInput, type MockMouse } from "@opentui/core/testing" +import type { SimulationProtocol } from "../protocol" import { SimulationRenderer } from "./renderer" import { SimulationTrace } from "./trace" -export interface KeyModifiers { - readonly ctrl?: boolean - readonly shift?: boolean - readonly meta?: boolean - readonly super?: boolean - readonly hyper?: boolean -} - -export type Action = - | { readonly type: "typeText"; readonly text: string } - | { readonly type: "pressKey"; readonly key: string; readonly modifiers?: KeyModifiers } - | { readonly type: "pressEnter" } - | { readonly type: "pressArrow"; readonly direction: "up" | "down" | "left" | "right" } - | { readonly type: "focus"; readonly target: number } - | { readonly type: "click"; readonly target: number; readonly x: number; readonly y: number } - -export interface Element { - readonly id: string - readonly num: number - readonly x: number - readonly y: number - readonly width: number - readonly height: number - readonly focusable: boolean - readonly focused: boolean - readonly clickable: boolean - readonly editor: boolean -} +export type Action = SimulationProtocol.Frontend.Action +export type Element = SimulationProtocol.Frontend.Element export interface Harness { readonly renderer: CliRenderer diff --git a/packages/tui/src/simulation/renderer.ts b/packages/simulation/src/frontend/renderer.ts similarity index 100% rename from packages/tui/src/simulation/renderer.ts rename to packages/simulation/src/frontend/renderer.ts diff --git a/packages/tui/src/simulation/server.ts b/packages/simulation/src/frontend/server.ts similarity index 52% rename from packages/tui/src/simulation/server.ts rename to packages/simulation/src/frontend/server.ts index 65d814d2f5..c6a6c8784c 100644 --- a/packages/tui/src/simulation/server.ts +++ b/packages/simulation/src/frontend/server.ts @@ -1,27 +1,10 @@ -import { SimulationActions, type Action, type Harness } from "./actions" +import { SimulationProtocol } from "../protocol" +import { SimulationActions, type Harness } from "./actions" import { SimulationTrace } from "./trace" const DefaultPort = 40900 const MaxPortAttempts = 100 -type JsonRpcRequest = { - readonly jsonrpc: "2.0" - readonly id?: string | number | null - readonly method: string - readonly params?: unknown -} - -type JsonRpcResponse = { - readonly jsonrpc: "2.0" - readonly id: string | number | null - readonly result?: unknown - readonly error?: { - readonly code: number - readonly message: string - readonly data?: unknown - } -} - export interface Server { readonly url: string readonly stop: () => void @@ -36,63 +19,15 @@ function isPortUnavailable(error: unknown) { return message.includes("eaddrinuse") || message.includes("address already in use") || message.includes(" in use") } -function parseRequest(input: string | Buffer): JsonRpcRequest { - const value = JSON.parse(typeof input === "string" ? input : input.toString()) as unknown - if (typeof value !== "object" || value === null) throw new Error("Invalid JSON-RPC request") - if (!("jsonrpc" in value) || value.jsonrpc !== "2.0") throw new Error("Invalid JSON-RPC version") - if (!("method" in value) || typeof value.method !== "string") throw new Error("Invalid JSON-RPC method") - return value as JsonRpcRequest -} - -function isAction(input: unknown): input is Action { - if (typeof input !== "object" || input === null || !("type" in input)) return false - switch (input.type) { - case "typeText": - return "text" in input && typeof input.text === "string" - case "pressKey": - return "key" in input && typeof input.key === "string" - case "pressEnter": - return true - case "pressArrow": - return "direction" in input && ["up", "down", "left", "right"].includes(String(input.direction)) - case "focus": - return "target" in input && typeof input.target === "number" - case "click": - return ( - "target" in input && - typeof input.target === "number" && - "x" in input && - typeof input.x === "number" && - "y" in input && - typeof input.y === "number" - ) - } - return false -} - function actionParam(params: unknown) { - if (typeof params !== "object" || params === null || !("action" in params)) throw new Error("Missing action") - if (!isAction(params.action)) throw new Error("Invalid action") - return params.action + return SimulationProtocol.Frontend.decodeActionParams(params).action } -function response(id: JsonRpcRequest["id"], result: unknown): JsonRpcResponse | undefined { - if (id === undefined) return undefined - return { jsonrpc: "2.0", id, result } +function parseRequest(input: string | Buffer) { + return SimulationProtocol.JsonRpc.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString())) } -function errorResponse(id: JsonRpcRequest["id"], error: unknown): JsonRpcResponse { - return { - jsonrpc: "2.0", - id: id ?? null, - error: { - code: -32000, - message: error instanceof Error ? error.message : String(error), - }, - } -} - -async function handle(harness: Harness, request: JsonRpcRequest) { +async function handle(harness: Harness, request: SimulationProtocol.JsonRpc.Request) { switch (request.method) { case "ui.state": { const result = SimulationActions.state(harness) @@ -139,14 +74,14 @@ function serve( SimulationTrace.add("control.disconnect") }, async message(socket, message) { - let request: JsonRpcRequest | undefined + let request: SimulationProtocol.JsonRpc.Request | undefined try { request = parseRequest(message) const result = await handle(harness, request) - const next = response(request.id, result) + const next = SimulationProtocol.JsonRpc.success(request.id, result) if (next) socket.send(JSON.stringify(next)) } catch (error) { - socket.send(JSON.stringify(errorResponse(request?.id, error))) + socket.send(JSON.stringify(SimulationProtocol.JsonRpc.failure(request?.id, error))) } }, }, diff --git a/packages/tui/src/simulation/simulation.ts b/packages/simulation/src/frontend/simulation.ts similarity index 100% rename from packages/tui/src/simulation/simulation.ts rename to packages/simulation/src/frontend/simulation.ts diff --git a/packages/tui/src/simulation/trace.ts b/packages/simulation/src/frontend/trace.ts similarity index 100% rename from packages/tui/src/simulation/trace.ts rename to packages/simulation/src/frontend/trace.ts diff --git a/packages/simulation/src/protocol/index.ts b/packages/simulation/src/protocol/index.ts new file mode 100644 index 0000000000..6ff63c9bb3 --- /dev/null +++ b/packages/simulation/src/protocol/index.ts @@ -0,0 +1,145 @@ +import { Effect, Schema } from "effect" + +const JsonRpcID = Schema.Union([Schema.String, Schema.Number, Schema.Null]) +type Json = Schema.Schema.Type + +export namespace JsonRpc { + export const Request = Schema.Struct({ + jsonrpc: Schema.Literal("2.0"), + id: Schema.optional(JsonRpcID), + method: Schema.String, + params: Schema.optional(Schema.Json), + }) + export interface Request extends Schema.Schema.Type {} + + export const ErrorObject = Schema.Struct({ + code: Schema.Number, + message: Schema.String, + data: Schema.optional(Schema.Json), + }) + + export const Response = Schema.Struct({ + jsonrpc: Schema.Literal("2.0"), + id: JsonRpcID, + result: Schema.optional(Schema.Json), + error: Schema.optional(ErrorObject), + }) + export interface Response extends Schema.Schema.Type {} + + export const decodeRequest = Schema.decodeUnknownSync(Request) + + export function success(id: Request["id"], result: unknown): Response | undefined { + if (id === undefined) return undefined + return { jsonrpc: "2.0", id, result: result as Json } + } + + export function failure(id: Request["id"], error: unknown): Response { + return { + jsonrpc: "2.0", + id: id ?? null, + error: { + code: -32000, + message: error instanceof Error ? error.message : String(error), + }, + } + } +} + +export namespace Frontend { + export const KeyModifiers = Schema.Struct({ + ctrl: Schema.optional(Schema.Boolean), + shift: Schema.optional(Schema.Boolean), + meta: Schema.optional(Schema.Boolean), + super: Schema.optional(Schema.Boolean), + hyper: Schema.optional(Schema.Boolean), + }) + export interface KeyModifiers extends Schema.Schema.Type {} + + export const Action = Schema.Union([ + Schema.Struct({ type: Schema.Literal("typeText"), text: Schema.String }), + Schema.Struct({ type: Schema.Literal("pressKey"), key: Schema.String, modifiers: Schema.optional(KeyModifiers) }), + Schema.Struct({ type: Schema.Literal("pressEnter") }), + Schema.Struct({ type: Schema.Literal("pressArrow"), direction: Schema.Literals(["up", "down", "left", "right"]) }), + Schema.Struct({ type: Schema.Literal("focus"), target: Schema.Number }), + Schema.Struct({ type: Schema.Literal("click"), target: Schema.Number, x: Schema.Number, y: Schema.Number }), + ]) + export type Action = Schema.Schema.Type + + export const Element = Schema.Struct({ + id: Schema.String, + num: Schema.Number, + x: Schema.Number, + y: Schema.Number, + width: Schema.Number, + height: Schema.Number, + focusable: Schema.Boolean, + focused: Schema.Boolean, + clickable: Schema.Boolean, + editor: Schema.Boolean, + }) + export interface Element extends Schema.Schema.Type {} + + export const State = Schema.Struct({ + screen: Schema.String, + focused: Schema.Struct({ + renderable: Schema.optional(Schema.Number), + editor: Schema.Boolean, + }), + elements: Schema.Array(Element), + actions: Schema.Array(Action), + }) + export interface State extends Schema.Schema.Type {} + + export const ActionParams = Schema.Struct({ action: Action }) + export interface ActionParams extends Schema.Schema.Type {} + export const decodeActionParams = Schema.decodeUnknownSync(ActionParams) + + export const TraceRecord = Schema.Struct({ + id: Schema.Number, + time: Schema.String, + type: Schema.String, + data: Schema.optional(Schema.Json), + }) + export interface TraceRecord extends Schema.Schema.Type {} + + export const TraceList = Schema.Struct({ records: Schema.Array(TraceRecord) }) + export interface TraceList extends Schema.Schema.Type {} +} + +export namespace Backend { + export const Item = Schema.Union([ + Schema.Struct({ type: Schema.Literal("textDelta"), text: Schema.String }), + Schema.Struct({ type: Schema.Literal("reasoningDelta"), text: Schema.String }), + Schema.Struct({ type: Schema.Literal("toolCall"), id: Schema.String, name: Schema.String, input: Schema.Json }), + Schema.Struct({ type: Schema.Literal("raw"), chunk: Schema.Json }), + ]) + export type Item = Schema.Schema.Type + + export const FinishReason = Schema.Literals(["stop", "tool-calls", "length", "content-filter"]) + export type FinishReason = Schema.Schema.Type + + export const ChunkParams = Schema.Struct({ id: Schema.String, items: Schema.Array(Item) }) + export interface ChunkParams extends Schema.Schema.Type {} + + export const FinishParams = Schema.Struct({ + id: Schema.String, + reason: FinishReason.pipe(Schema.withDecodingDefault(Effect.succeed("stop" as const))), + }) + export interface FinishParams extends Schema.Schema.Type {} + + export const OpenedExchange = Schema.Struct({ id: Schema.String, url: Schema.String, body: Schema.Json }) + export interface OpenedExchange extends Schema.Schema.Type {} + + export const NetworkLogEntry = Schema.Struct({ + time: Schema.Number, + method: Schema.String, + url: Schema.String, + matched: Schema.Boolean, + }) + export interface NetworkLogEntry extends Schema.Schema.Type {} + + export const decodeChunkParams = Schema.decodeUnknownPromise(ChunkParams) + export const decodeFinishParams = Schema.decodeUnknownPromise(FinishParams) +} + +export * as SimulationProtocol from "./index" diff --git a/packages/simulation/tsconfig.json b/packages/simulation/tsconfig.json new file mode 100644 index 0000000000..00ef125468 --- /dev/null +++ b/packages/simulation/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false + } +} diff --git a/packages/tui/package.json b/packages/tui/package.json index 8bf349c2da..fc1199dedc 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -31,15 +31,18 @@ "./terminal-win32": "./src/terminal-win32.ts", "./config/keybind": "./src/config/keybind.ts", "./keymap": "./src/keymap.tsx", + "./prompt/content": "./src/prompt/content.ts", "./prompt/display": "./src/prompt/display.ts", "./plugin/runtime": "./src/plugin/runtime.tsx", "./plugin/slots": "./src/plugin/slots.tsx", "./plugin/command-shim": "./src/plugin/command-shim.ts", "./parsers-config": "./src/parsers-config.ts", "./util/error": "./src/util/error.ts", + "./util/filetype": "./src/util/filetype.ts", "./util/locale": "./src/util/locale.ts", "./util/persistence": "./src/util/persistence.ts", "./util/record": "./src/util/record.ts", + "./util/session": "./src/util/session.ts", "./logo": "./src/logo.ts", "./ui/dialog": "./src/ui/dialog.tsx", "./ui/spinner": "./src/ui/spinner.ts", @@ -51,6 +54,7 @@ "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/simulation": "workspace:*", "@opencode-ai/ui": "workspace:*", "@opentui/core": "catalog:", "@opentui/keymap": "catalog:", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index ccde2fa5e6..b769a436a0 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -139,6 +139,7 @@ export type TuiInput = { client: OpencodeClient api: OpenCodeClient discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }> + reload?: () => Promise args: Args config: TuiConfig.Resolved onSnapshot?: () => Promise @@ -199,8 +200,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { }, } satisfies CliRendererConfig - if (process.env.OPENCODE_SIMULATION === "1" || process.env.OPENCODE_SIMULATION === "true") { - const { Simulation } = await import("./simulation/simulation") + if (!!process.env.OPENCODE_SIMULATION) { + const { Simulation } = await import("@opencode-ai/simulation/frontend") return Simulation.createSimulation(options) } @@ -301,7 +302,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { > - + @@ -408,8 +409,8 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi else toast.show({ variant: "error", - title: "MCP server failed to connect", - message: `${server.name}: ${status.error}`, + title: `MCP server failed: ${server.name}`, + message: "Open MCPs to view details.", }) } }) @@ -800,6 +801,26 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }, category: "System", }, + ...(sdk.reload + ? [ + { + name: "server.reload", + title: "Reload server", + slashName: "reload", + run: async () => { + dialog.clear() + toast.show({ variant: "info", message: "Reloading server...", duration: 30000 }) + // reload resolves once the replacement service is healthy; the + // event stream reattaches through the reconnect loop. + await sdk + .reload!() + .then(() => toast.show({ variant: "success", message: "Server reloaded" })) + .catch(toast.error) + }, + category: "System", + }, + ] + : []), { name: "theme.switch", title: "Switch theme", @@ -1019,6 +1040,12 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }) }) + event.on("plugin.updated", (_evt, { directory, workspace }) => { + if (directory !== project.instance.directory()) return + if (workspace !== project.workspace.current()) return + toast.show({ variant: "success", message: "Plugins reloaded" }) + }) + event.on("tui.session.select", (evt, { workspace }) => { if (workspace !== project.workspace.current()) return route.navigate({ diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index cd8780ddfb..52aba6d0e3 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -81,7 +81,11 @@ export function DialogIntegration(props: { onConnected?: OnIntegrationConnected No integrations available} + emptyView={ + + No integrations available + + } /> ) } diff --git a/packages/tui/src/component/dialog-mcp.tsx b/packages/tui/src/component/dialog-mcp.tsx index f5f82f2202..003c207be0 100644 --- a/packages/tui/src/component/dialog-mcp.tsx +++ b/packages/tui/src/component/dialog-mcp.tsx @@ -1,54 +1,182 @@ -import { createMemo, createSignal } from "solid-js" +import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js" import { useData } from "../context/data" -import { map, pipe, sortBy } from "remeda" -import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select" -import { useTheme } from "../context/theme" -import { TextAttributes } from "@opentui/core" +import { pipe, sortBy } from "remeda" +import { DialogSelect } from "../ui/dialog-select" +import { useDialog } from "../ui/dialog" +import { useTheme, type Theme } from "../context/theme" +import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core" import type { McpServer } from "@opencode-ai/sdk/v2" +import { useClipboard } from "../context/clipboard" +import { useToast } from "../ui/toast" +import { useKeyboard, useTerminalDimensions } from "@opentui/solid" +import { useTuiConfig } from "../config" +import { getScrollAcceleration } from "../util/scroll" +import { useBindings } from "../keymap" -function Status(props: { status: McpServer["status"] }) { - const { theme } = useTheme() - switch (props.status.status) { - case "connected": - return ✓ Connected - case "failed": - return ✗ {props.status.error} +// Sort by how much attention a server needs: auth prompts first, then failures, +// then healthy servers, and intentionally-off servers last. +function statusMeta(status: McpServer["status"], theme: Theme) { + switch (status.status) { case "needs_auth": - return ! Needs authentication + return { rank: 0, icon: "!", label: "Needs authentication", color: theme.warning, error: undefined, bold: false } case "needs_client_registration": - return ✗ {props.status.error} - case "disabled": - return ○ Disabled + return { rank: 1, icon: "✗", label: "Needs registration", color: theme.error, error: status.error, bold: false } + case "failed": + return { rank: 2, icon: "✗", label: "Failed", color: theme.error, error: status.error, bold: false } + case "connected": + return { rank: 3, icon: "✓", label: "Connected", color: theme.success, error: undefined, bold: true } + case "pending": + return { rank: 4, icon: "◌", label: "Pending", color: theme.textMuted, error: undefined, bold: false } default: - return ○ Disconnected + return { rank: 5, icon: "○", label: "Disabled", color: theme.textMuted, error: undefined, bold: false } } } export function DialogMcp() { const data = useData() - const [, setRef] = createSignal>() + const dialog = useDialog() + const { theme } = useTheme() + const [focused, setFocused] = createSignal() + const [detail, setDetail] = createSignal() - const options = createMemo(() => + onMount(() => { + dialog.setSize("large") + }) + + const servers = createMemo(() => pipe( data.location.mcp.list() ?? [], - sortBy((server) => server.name), - map((server) => ({ - value: server.name, - title: server.name, - footer: , - category: undefined, - })), + sortBy( + (server) => statusMeta(server.status, theme).rank, + (server) => server.name, + ), ), ) + createEffect(() => { + if (focused()) return + const first = servers()[0] + if (first) setFocused(first.name) + }) + + const options = createMemo(() => + servers().map((server) => { + const meta = statusMeta(server.status, theme) + return { + value: server.name, + title: server.name, + footer: ( + + {meta.icon} {meta.label} + + ), + } + }), + ) + + const focusedError = createMemo(() => { + const name = focused() + const server = servers().find((entry) => entry.name === name) + return server ? statusMeta(server.status, theme).error : undefined + }) + + const open = (name: string | undefined) => { + const server = servers().find((entry) => entry.name === name) + if (!server || !statusMeta(server.status, theme).error) return + setDetail(server) + } + return ( - { - // Read-only view: selection does nothing, the dialog closes on escape. - }} - /> + + setFocused(option.value as string)} + onSelect={(option) => open(option.value as string)} + footer={ + + enter to view error + + } + /> + } + > + {(server) => setDetail()} />} + + + ) +} + +function DialogMcpError(props: { server: McpServer; onBack: () => void }) { + const dialog = useDialog() + const clipboard = useClipboard() + const toast = useToast() + const { theme } = useTheme() + const dimensions = useTerminalDimensions() + const tuiConfig = useTuiConfig() + const [copied, setCopied] = createSignal(false) + const error = () => statusMeta(props.server.status, theme).error ?? "Unknown MCP connection error" + const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5)) + let scroll: ScrollBoxRenderable | undefined + + onMount(() => dialog.setSize("large")) + + const copy = () => { + if (!clipboard.write) return + void clipboard + .write(error()) + .then(() => setCopied(true)) + .catch(toast.error) + } + + useBindings(() => ({ + bindings: [{ key: "escape", desc: "Back to MCP servers", group: "Dialog", cmd: props.onBack }], + })) + + useKeyboard((event) => { + if (event.name === "c") return copy() + if (event.name === "up") return scroll?.scrollBy(-1) + if (event.name === "down") return scroll?.scrollBy(1) + if (event.name === "pageup") return scroll?.scrollBy(-height()) + if (event.name === "pagedown") return scroll?.scrollBy(height()) + if (event.name === "home") return scroll?.scrollTo(0) + if (event.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight) + }) + + return ( + + + + MCP / {props.server.name} + + + esc back + + + ✗ Failed + + (scroll = element)} + height={height()} + scrollbarOptions={{ visible: false }} + scrollAcceleration={getScrollAcceleration(tuiConfig)} + > + + {error()} + + + + + ↑↓ scroll + + {copied() ? "✓ copied" : "c copy details"} + + + ) } diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 7b0724e5dc..9fad2eb21c 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -1101,13 +1101,8 @@ export function Prompt(props: PromptProps) { if (store.mode === "shell") { move.startSubmit() - void sdk.client.session.shell({ + void sdk.client.v2.session.shell({ sessionID, - agent: agent.id, - model: { - providerID: selectedModel.providerID, - modelID: selectedModel.modelID, - }, command: inputText, }) setStore("mode", "normal") diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index d70a71be5c..d9b29c1fdd 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -1,6 +1,8 @@ import type { AgentV2Info, CommandV2Info, + FormFormInfo, + FormUrlInfo, IntegrationInfo, LocationRef, McpServer, @@ -8,7 +10,6 @@ import type { PermissionSavedInfo, PermissionV2Request, ProviderV2Info, - QuestionV2Request, ReferenceInfo, SessionMessage, SessionMessageAssistant, @@ -20,13 +21,17 @@ import type { SkillV2Info, V2Event, } from "@opencode-ai/sdk/v2" -import { createStore, produce } from "solid-js/store" +import { createStore, produce, reconcile } from "solid-js/store" import { createSimpleContext } from "./helper" import { useSDK } from "./sdk" import { createSignal, onCleanup } from "solid-js" export type DataSessionStatus = "idle" | "running" +const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_") + +export type FormInfo = FormFormInfo | FormUrlInfo + type LocationData = { agent?: AgentV2Info[] command?: CommandV2Info[] @@ -51,7 +56,8 @@ type Data = { status: Record message: Record permission: Record - question: Record + // Pending forms keyed by session ID. + form: Record } project: { permission: Record @@ -85,7 +91,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ status: {}, message: {}, permission: {}, - question: {}, + form: {}, }, project: { permission: {}, @@ -98,8 +104,15 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ directory: process.cwd(), }) const messageIndex = new Map>() + let connectionGeneration = 0 + let statusChanges: Set | undefined let bootstrapping: Promise | undefined + function setSessionStatus(sessionID: string, status: DataSessionStatus) { + statusChanges?.add(sessionID) + setStore("session", "status", sessionID, status) + } + const message = { update(sessionID: string, fn: (messages: SessionMessage[], index: Map) => void) { setStore( @@ -124,8 +137,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ const item = position === undefined ? undefined : messages[position] return item?.type === "assistant" ? item : undefined }, - activeShell(messages: SessionMessage[], callID: string) { - const item = messages.findLast((item) => item.type === "shell" && item.callID === callID) + shell(messages: SessionMessage[], shellID: string) { + const item = messages.findLast((item) => item.type === "shell" && item.shell.id === shellID) return item?.type === "shell" ? item : undefined }, latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) { @@ -181,17 +194,21 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ const info = store.session.info[sessionID] if (!info) return const rootID = resolveRoot(sessionID) - setStore("session", "family", produce((draft) => { - if (sessionID !== rootID && draft[sessionID]) { - const members = draft[rootID] ??= [] - for (const id of draft[sessionID]) { - if (!members.includes(id)) members.push(id) + setStore( + "session", + "family", + produce((draft) => { + if (sessionID !== rootID && draft[sessionID]) { + const members = (draft[rootID] ??= []) + for (const id of draft[sessionID]) { + if (!members.includes(id)) members.push(id) + } + delete draft[sessionID] } - delete draft[sessionID] - } - const family = draft[rootID] ??= [] - if (!family.includes(sessionID)) family.push(sessionID) - })) + const family = (draft[rootID] ??= []) + if (!family.includes(sessionID)) family.push(sessionID) + }), + ) } function handleEvent(event: V2Event) { @@ -214,124 +231,126 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "skill.updated": void result.location.skill.refresh(event.location) break - case "session.next.agent.switched": + case "session.agent.selected": if (store.session.info[event.data.sessionID]) setStore("session", "info", event.data.sessionID, "agent", event.data.agent) message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { - id: event.data.messageID, + id: messageIDFromEvent(event.id), type: "agent-switched", agent: event.data.agent, - time: { created: event.data.timestamp }, + time: { created: event.created }, }) }) break - case "session.next.model.switched": + case "session.model.selected": if (store.session.info[event.data.sessionID]) setStore("session", "info", event.data.sessionID, "model", event.data.model) + if (!store.session.message[event.data.sessionID]) break message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { - id: event.data.messageID, + id: messageIDFromEvent(event.id), type: "model-switched", model: event.data.model, - time: { created: event.data.timestamp }, + time: { created: event.created }, }) }) + void sdk.api.session + .message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) }) + .then((item) => { + message.update(event.data.sessionID, (draft, index) => { + const position = index.get(item.id) + if (position === undefined) return message.append(draft, index, mutable(item)) + draft[position] = mutable(item) + }) + }) + .catch((error) => console.error("Failed to load projected model switch message", error)) break - case "session.next.renamed": + case "session.renamed": if (store.session.info[event.data.sessionID]) setStore("session", "info", event.data.sessionID, "title", event.data.title) break - case "session.next.prompted": { - setStore("session", "status", event.data.sessionID, "running") + case "session.prompt.promoted": { + setSessionStatus(event.data.sessionID, "running") message.update(event.data.sessionID, (draft, index) => { - const position = index.get(event.data.messageID) - const existing = position === undefined ? undefined : draft[position] - if (existing?.type === "user") { - existing.text = event.data.prompt.text - existing.files = event.data.prompt.files - existing.agents = event.data.prompt.agents - existing.time.created = event.data.timestamp - if (existing.metadata?.queued === true) { - delete existing.metadata.queued - if (Object.keys(existing.metadata).length === 0) existing.metadata = undefined - } + const position = index.get(event.data.inputID) + if (position === undefined) return + const existing = draft[position] + if (existing?.type === "user" && existing.metadata?.queued === true) { + existing.time.created = event.created + delete existing.metadata.queued + if (Object.keys(existing.metadata).length === 0) existing.metadata = undefined + draft.splice(position, 1) + draft.push(existing) + index.clear() + draft.forEach((message, indexValue) => index.set(message.id, indexValue)) return } - message.append(draft, index, { - id: event.data.messageID, - type: "user", - text: event.data.prompt.text, - files: event.data.prompt.files, - agents: event.data.prompt.agents, - time: { created: event.data.timestamp }, - }) }) break } - case "session.next.prompt.admitted": + case "session.prompt.admitted": message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { - id: event.data.messageID, + id: event.data.inputID, type: "user", text: event.data.prompt.text, files: event.data.prompt.files, agents: event.data.prompt.agents, metadata: { queued: true }, - time: { created: event.data.timestamp }, + time: { created: event.created }, }) }) break - case "session.next.context.updated": + case "session.context.updated": message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { - id: event.data.messageID, + id: messageIDFromEvent(event.id), type: "system", text: event.data.text, - time: { created: event.data.timestamp }, + time: { created: event.created }, }) }) break - case "session.next.synthetic": + case "session.synthetic": message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { - id: event.data.messageID, + id: messageIDFromEvent(event.id), type: "synthetic", sessionID: event.data.sessionID, text: event.data.text, description: event.data.description, - time: { created: event.data.timestamp }, + time: { created: event.created }, }) }) break - case "session.next.shell.started": - setStore("session", "status", event.data.sessionID, "running") + case "session.shell.started": + setSessionStatus(event.data.sessionID, "running") message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { - id: event.data.messageID, + id: messageIDFromEvent(event.id), type: "shell", - callID: event.data.callID, - command: event.data.command, - output: "", - time: { created: event.data.timestamp }, + shell: event.data.shell, + time: { created: event.created }, }) }) break - case "session.next.shell.ended": - setStore("session", "status", event.data.sessionID, "idle") - message.update(event.data.sessionID, (draft, index) => { - const match = message.activeShell(draft, event.data.callID) + case "session.shell.ended": + setSessionStatus(event.data.sessionID, "idle") + message.update(event.data.sessionID, (draft) => { + const match = message.shell(draft, event.data.shell.id) if (!match) return + match.shell = event.data.shell match.output = event.data.output - match.time.completed = event.data.timestamp + match.time.completed = event.created }) break - case "session.next.step.started": - setStore("session", "status", event.data.sessionID, "running") + case "session.step.started": + setSessionStatus(event.data.sessionID, "running") message.update(event.data.sessionID, (draft, index) => { if (index.has(event.data.assistantMessageID)) return const currentAssistant = message.activeAssistant(draft) - if (currentAssistant) currentAssistant.time.completed = event.data.timestamp + if (currentAssistant) currentAssistant.time.completed = event.created message.append(draft, index, { id: event.data.assistantMessageID, type: "assistant", @@ -339,16 +358,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ model: event.data.model, content: [], snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, - time: { created: event.data.timestamp }, + time: { created: event.created }, }) }) break - case "session.next.step.ended": - setStore("session", "status", event.data.sessionID, "running") + case "session.step.ended": + setSessionStatus(event.data.sessionID, "running") message.update(event.data.sessionID, (draft, index) => { const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID) if (!currentAssistant) return - currentAssistant.time.completed = event.data.timestamp + currentAssistant.time.completed = event.created currentAssistant.finish = event.data.finish currentAssistant.cost = event.data.cost currentAssistant.tokens = event.data.tokens @@ -356,16 +375,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.data.snapshot } }) break - case "session.next.step.failed": + case "session.step.failed": message.update(event.data.sessionID, (draft, index) => { const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID) if (!currentAssistant) return - currentAssistant.time.completed = event.data.timestamp + currentAssistant.time.completed = event.created currentAssistant.finish = "error" currentAssistant.error = event.data.error }) break - case "session.next.text.started": + case "session.text.started": message.update(event.data.sessionID, (draft, index) => { message.assistant(draft, index, event.data.assistantMessageID)?.content.push({ type: "text", @@ -374,7 +393,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) }) break - case "session.next.text.delta": + case "session.text.delta": message.update(event.data.sessionID, (draft, index) => { const match = message.latestText( message.assistant(draft, index, event.data.assistantMessageID), @@ -383,7 +402,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ if (match) match.text += event.data.delta }) break - case "session.next.text.ended": + case "session.text.ended": message.update(event.data.sessionID, (draft, index) => { const match = message.latestText( message.assistant(draft, index, event.data.assistantMessageID), @@ -392,18 +411,18 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ if (match) match.text = event.data.text }) break - case "session.next.tool.input.started": + case "session.tool.input.started": message.update(event.data.sessionID, (draft, index) => { message.assistant(draft, index, event.data.assistantMessageID)?.content.push({ type: "tool", id: event.data.callID, name: event.data.name, - time: { created: event.data.timestamp }, + time: { created: event.created }, state: { status: "pending", input: "" }, }) }) break - case "session.next.tool.input.delta": + case "session.tool.input.delta": message.update(event.data.sessionID, (draft, index) => { const match = message.latestTool( message.assistant(draft, index, event.data.assistantMessageID), @@ -412,7 +431,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ if (match?.state.status === "pending") match.state.input += event.data.delta }) break - case "session.next.tool.input.ended": + case "session.tool.input.ended": message.update(event.data.sessionID, (draft, index) => { const match = message.latestTool( message.assistant(draft, index, event.data.assistantMessageID), @@ -421,19 +440,19 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ if (match?.state.status === "pending") match.state.input = event.data.text }) break - case "session.next.tool.called": + case "session.tool.called": message.update(event.data.sessionID, (draft, index) => { const match = message.latestTool( message.assistant(draft, index, event.data.assistantMessageID), event.data.callID, ) if (!match) return - match.time.ran = event.data.timestamp + match.time.ran = event.created match.provider = event.data.provider match.state = { status: "running", input: event.data.input, structured: {}, content: [] } }) break - case "session.next.tool.progress": + case "session.tool.progress": message.update(event.data.sessionID, (draft, index) => { const match = message.latestTool( message.assistant(draft, index, event.data.assistantMessageID), @@ -444,7 +463,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ match.state.content = [...event.data.content] }) break - case "session.next.tool.success": + case "session.tool.success": message.update(event.data.sessionID, (draft, index) => { const match = message.latestTool( message.assistant(draft, index, event.data.assistantMessageID), @@ -463,10 +482,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ metadata: match.provider?.metadata, resultMetadata: event.data.provider.metadata, } - match.time.completed = event.data.timestamp + match.time.completed = event.created }) break - case "session.next.tool.failed": + case "session.tool.failed": message.update(event.data.sessionID, (draft, index) => { const match = message.latestTool( message.assistant(draft, index, event.data.assistantMessageID), @@ -486,21 +505,21 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ metadata: match.provider?.metadata, resultMetadata: event.data.provider.metadata, } - match.time.completed = event.data.timestamp + match.time.completed = event.created }) break - case "session.next.reasoning.started": + case "session.reasoning.started": message.update(event.data.sessionID, (draft, index) => { message.assistant(draft, index, event.data.assistantMessageID)?.content.push({ type: "reasoning", id: event.data.reasoningID, text: "", providerMetadata: event.data.providerMetadata, - time: { created: event.data.timestamp }, + time: { created: event.created }, }) }) break - case "session.next.reasoning.delta": + case "session.reasoning.delta": message.update(event.data.sessionID, (draft, index) => { const match = message.latestReasoning( message.assistant(draft, index, event.data.assistantMessageID), @@ -509,7 +528,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ if (match) match.text += event.data.delta }) break - case "session.next.reasoning.ended": + case "session.reasoning.ended": message.update(event.data.sessionID, (draft, index) => { const match = message.latestReasoning( message.assistant(draft, index, event.data.assistantMessageID), @@ -517,38 +536,38 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) if (match) { match.text = event.data.text - match.time = { created: match.time?.created ?? event.data.timestamp, completed: event.data.timestamp } + match.time = { created: match.time?.created ?? event.created, completed: event.created } if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata } }) break - case "session.next.retried": - case "session.next.compaction.started": - setStore("session", "status", event.data.sessionID, "running") + case "session.retried": + case "session.compaction.started": + setSessionStatus(event.data.sessionID, "running") break - case "session.next.execution.settled": - setStore("session", "status", event.data.sessionID, "idle") + case "session.execution.settled": + setSessionStatus(event.data.sessionID, "idle") break - case "session.next.revert.staged": + case "session.revert.staged": if (store.session.info[event.data.sessionID]) setStore("session", "info", event.data.sessionID, "revert", event.data.revert) break - case "session.next.revert.cleared": - case "session.next.revert.committed": + case "session.revert.cleared": + case "session.revert.committed": if (store.session.info[event.data.sessionID]) setStore("session", "info", event.data.sessionID, "revert", undefined) break - case "session.next.compaction.delta": + case "session.compaction.delta": break - case "session.next.compaction.ended": + case "session.compaction.ended": message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { - id: event.data.messageID, + id: messageIDFromEvent(event.id), type: "compaction", reason: event.data.reason, summary: event.data.text, recent: event.data.recent, - time: { created: event.data.timestamp }, + time: { created: event.created }, }) }) break @@ -569,22 +588,20 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ), ) break - case "question.v2.asked": - if (store.session.question[event.data.sessionID]?.some((request) => request.id === event.data.id)) break - setStore("session", "question", event.data.sessionID, [ - ...(store.session.question[event.data.sessionID] ?? []), - event.data, + case "form.created": + if (store.session.form[event.data.form.sessionID]?.some((form) => form.id === event.data.form.id)) break + setStore("session", "form", event.data.form.sessionID, [ + ...(store.session.form[event.data.form.sessionID] ?? []), + mutable(event.data.form), ]) break - case "question.v2.replied": - case "question.v2.rejected": + case "form.replied": + case "form.cancelled": setStore( "session", - "question", + "form", event.data.sessionID, - (store.session.question[event.data.sessionID] ?? []).filter( - (request) => request.id !== event.data.requestID, - ), + (store.session.form[event.data.sessionID] ?? []).filter((form) => form.id !== event.data.id), ) break case "shell.created": @@ -692,8 +709,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return liveByID.get(message.id) ?? message }), ...live.filter((message) => !loadedIDs.has(message.id)), - ] - .toSorted((a, b) => a.time.created - b.time.created) + ].toSorted((a, b) => a.time.created - b.time.created) messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index]))) setStore("session", "message", sessionID, messages) }, @@ -706,12 +722,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setStore("session", "permission", sessionID, mutable(await sdk.api.permission.list({ sessionID }))) }, }, - question: { + form: { list(sessionID: string) { - return store.session.question[sessionID] + return store.session.form[sessionID] }, async refresh(sessionID: string) { - setStore("session", "question", sessionID, mutable(await sdk.api.question.list({ sessionID }))) + setStore("session", "form", sessionID, mutable(await sdk.api.form.list({ sessionID }))) }, }, }, @@ -865,15 +881,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) for (const session of response.data) registerSession(session.id) }), - sdk.api.session - .active() - .then((active) => - setStore( - "session", - "status", - Object.fromEntries(Object.keys(active.data).map((sessionID) => [sessionID, "running" as const])), - ), - ), result.location.refresh(), result.location.agent.refresh(), result.location.integration.refresh(), @@ -895,9 +902,30 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return bootstrapping } + function refreshActive() { + const generation = ++connectionGeneration + const changed = new Set() + statusChanges = changed + void sdk.api.session + .active() + .then((active) => { + if (generation !== connectionGeneration) return + const status: Record = Object.fromEntries( + Object.keys(active.data).map((sessionID) => [sessionID, "running" as const]), + ) + for (const sessionID of changed) status[sessionID] = store.session.status[sessionID] + setStore("session", "status", reconcile(status)) + }) + .catch(() => undefined) + .finally(() => { + if (statusChanges === changed) statusChanges = undefined + }) + } + onCleanup( sdk.event.listen(({ details }) => { if (details.type === "server.connected") { + refreshActive() void bootstrap() return } diff --git a/packages/tui/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index 7686ed6e94..0ad516ceac 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -16,6 +16,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ client: OpencodeClient api: OpenCodeClient discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }> + // Stops and starts the managed service; present only in service mode. + reload?: () => Promise }) => { const abort = new AbortController() let client = props.client @@ -138,6 +140,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ return connection.connectedOnce }, }, + reload: props.reload, } }, }) diff --git a/packages/tui/src/editor.ts b/packages/tui/src/editor.ts index 68afba6751..f5bbbbc3bb 100644 --- a/packages/tui/src/editor.ts +++ b/packages/tui/src/editor.ts @@ -7,22 +7,10 @@ import { spawn } from "node:child_process" import type { Stream } from "node:stream" import { resolveZedDbPath, resolveZedSelection } from "./editor-zed" +export { normalizePromptContent } from "./prompt/content" + type EditorStdio = "inherit" | "pipe" | "ignore" | number | Stream -export function normalizePromptContent(content: string) { - if (content.endsWith("\r\n")) { - const body = content.slice(0, -2) - return !body.includes("\n") && !body.includes("\r") ? body : content - } - - if (content.endsWith("\n")) { - const body = content.slice(0, -1) - return !body.includes("\n") && !body.includes("\r") ? body : content - } - - return content -} - export async function openEditor(input: { value: string; renderer: CliRenderer; cwd?: string; stdin?: EditorStdio }) { const editor = process.env.VISUAL || process.env.EDITOR if (!editor) return diff --git a/packages/tui/src/feature-plugins/system/notifications.ts b/packages/tui/src/feature-plugins/system/notifications.ts index 81c696ef13..a5033b62e0 100644 --- a/packages/tui/src/feature-plugins/system/notifications.ts +++ b/packages/tui/src/feature-plugins/system/notifications.ts @@ -29,9 +29,25 @@ function sessionErrorMessage(error: SessionError) { const tui: TuiPlugin = async (api) => { const active = new Set() const errored = new Set() + const forms = new Set() const questions = new Set() const permissions = new Set() + api.event.on("form.created", (event) => { + if (event.data.form.sessionID === "global") return + if (forms.has(event.data.form.id)) return + forms.add(event.data.form.id) + notify(api, event.data.form.sessionID, "Input needs response", "question") + }) + + api.event.on("form.replied", (event) => { + forms.delete(event.data.id) + }) + + api.event.on("form.cancelled", (event) => { + forms.delete(event.data.id) + }) + api.event.on("question.asked", (event) => { if (questions.has(event.data.id)) return questions.add(event.data.id) @@ -74,17 +90,17 @@ const tui: TuiPlugin = async (api) => { notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done") } - api.event.on("session.next.prompted", (event) => started(event.data.sessionID)) - api.event.on("session.next.shell.started", (event) => started(event.data.sessionID)) - api.event.on("session.next.step.started", (event) => started(event.data.sessionID)) - api.event.on("session.next.retried", (event) => started(event.data.sessionID)) - api.event.on("session.next.compaction.started", (event) => started(event.data.sessionID)) - api.event.on("session.next.shell.ended", (event) => ended(event.data.sessionID)) - api.event.on("session.next.step.ended", (event) => { + api.event.on("session.prompt.promoted", (event) => started(event.data.sessionID)) + api.event.on("session.shell.started", (event) => started(event.data.sessionID)) + api.event.on("session.step.started", (event) => started(event.data.sessionID)) + api.event.on("session.retried", (event) => started(event.data.sessionID)) + api.event.on("session.compaction.started", (event) => started(event.data.sessionID)) + api.event.on("session.shell.ended", (event) => ended(event.data.sessionID)) + api.event.on("session.step.ended", (event) => { if (event.data.finish === "tool-calls") return ended(event.data.sessionID) }) - api.event.on("session.next.step.failed", (event) => { + api.event.on("session.step.failed", (event) => { const sessionID = event.data.sessionID if (!active.has(sessionID)) return errored.add(sessionID) diff --git a/packages/tui/src/prompt/content.ts b/packages/tui/src/prompt/content.ts new file mode 100644 index 0000000000..99ed030868 --- /dev/null +++ b/packages/tui/src/prompt/content.ts @@ -0,0 +1,13 @@ +export function normalizePromptContent(content: string) { + if (content.endsWith("\r\n")) { + const body = content.slice(0, -2) + return !body.includes("\n") && !body.includes("\r") ? body : content + } + + if (content.endsWith("\n")) { + const body = content.slice(0, -1) + return !body.includes("\n") && !body.includes("\r") ? body : content + } + + return content +} diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx new file mode 100644 index 0000000000..e6f7a4818c --- /dev/null +++ b/packages/tui/src/routes/session/form.tsx @@ -0,0 +1,1009 @@ +import { createStore } from "solid-js/store" +import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js" +import { useRenderer, useTerminalDimensions } from "@opentui/solid" +import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core" +import open from "open" +import { selectedForeground, tint, useTheme } from "../../context/theme" +import type { FormFormInfo, FormValue } from "@opencode-ai/sdk/v2" +import type { FormInfo } from "../../context/data" +import { useSDK } from "../../context/sdk" +import { SplitBorder } from "../../ui/border" +import { useTuiConfig } from "../../config" +import { useBindings, useOpencodeModeStack } from "../../keymap" + +const FORM_MODE = "form" + +type Field = FormFormInfo["fields"][number] + +function fieldLabel(field: Field) { + return field.title ?? field.key +} + +function truncate(label: string, max: number) { + return label.length > max ? label.slice(0, max - 1).trimEnd() + "…" : label +} + +function validateText(field: Field, text: string): string | undefined { + if (field.type !== "string") return + if (field.minLength !== undefined && text.length < field.minLength) + return `Must be at least ${field.minLength} characters` + if (field.maxLength !== undefined && text.length > field.maxLength) + return `Must be at most ${field.maxLength} characters` + if (field.pattern !== undefined) { + try { + if (!new RegExp(field.pattern).test(text)) return `Must match pattern: ${field.pattern}` + } catch { + return `Invalid pattern: ${field.pattern}` + } + } + if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text)) return "Expected an email address" + if (field.format === "uri") { + try { + new URL(text) + } catch { + return "Expected a URL" + } + } + if (field.format === "date") { + const date = new Date(`${text}T00:00:00.000Z`) + if (!/^\d{4}-\d{2}-\d{2}$/.test(text) || Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== text) + return "Expected a date (YYYY-MM-DD)" + } + if (field.format === "date-time" && Number.isNaN(new Date(text).getTime())) return "Expected a date and time" +} + +function validateSelection(field: Field, value: FormValue | undefined) { + if (field.type !== "multiselect" || value === undefined) return + if (!Array.isArray(value)) return "Expected selections" + if (field.required && value.length === 0) return "Select at least one option" + if (field.minItems !== undefined && value.length < field.minItems) return `Select at least ${field.minItems}` + if (field.maxItems !== undefined && value.length > field.maxItems) return `Select at most ${field.maxItems}` +} + +function validateValue(field: Field, value: FormValue | undefined) { + if (value === undefined) return field.required ? "Answer required" : undefined + if (field.required && (value === "" || (Array.isArray(value) && value.length === 0))) { + return field.type === "multiselect" ? "Select at least one option" : "Answer required" + } + if (field.type === "string") { + if (typeof value !== "string") return "Expected text" + const invalid = validateText(field, value) + if (invalid) return invalid + if (field.options && !field.custom && !field.options.some((option) => option.value === value)) { + return "Select an available option" + } + return + } + if (field.type === "number" || field.type === "integer") { + if (typeof value !== "number" || !Number.isFinite(value)) return "Expected a number" + if (field.type === "integer" && !Number.isInteger(value)) return "Expected an integer" + if (typeof field.minimum === "number" && value < field.minimum) return `Must be at least ${field.minimum}` + if (typeof field.maximum === "number" && value > field.maximum) return `Must be at most ${field.maximum}` + return + } + if (field.type === "boolean") return typeof value === "boolean" ? undefined : "Expected yes or no" + const invalid = validateSelection(field, value) + if (invalid) return invalid + if ( + Array.isArray(value) && + !field.custom && + value.some((item) => !field.options.some((option) => option.value === item)) + ) { + return "Select only available options" + } +} + +function fieldRows(field: Field): { value: FormValue; label: string; description?: string }[] { + if (field.type === "boolean") + return [ + { value: true, label: "Yes" }, + { value: false, label: "No" }, + ] + if (field.type === "multiselect" || (field.type === "string" && field.options)) + return (field.options ?? []).map((option) => ({ + value: option.value, + label: option.label, + description: option.description, + })) + return [] +} + +function selectedRow(field: Field | undefined, value: FormValue | undefined) { + if (!field || value === undefined || Array.isArray(value)) return 0 + const rows = fieldRows(field) + const index = rows.findIndex((row) => row.value === value) + if (index !== -1) return index + if (typeof value === "string" && field.type === "string" && field.options && field.custom) return rows.length + return 0 +} + +function customDefault(field: Field) { + if (field.type !== "string" || !field.options || !field.custom || typeof field.default !== "string") return + if (!field.options.some((option) => option.value === field.default)) return field.default +} + +function display(field: Field, value: FormValue | undefined) { + if (value === undefined) return "" + const label = (item: string | number | boolean) => + fieldRows(field).find((row) => row.value === item)?.label ?? String(item) + if (Array.isArray(value)) return value.length === 0 ? "(none)" : value.map(label).join(", ") + return label(value) +} + +export function FormPrompt(props: { form: FormInfo }) { + return props.form.mode === "url" ? : +} + +function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) { + const sdk = useSDK() + const { theme } = useTheme() + const modeStack = useOpencodeModeStack() + const message = createMemo(() => { + const value = props.form.metadata?.["message"] + return typeof value === "string" ? value : undefined + }) + + onMount(() => onCleanup(modeStack.push(FORM_MODE))) + + useBindings(() => ({ + mode: FORM_MODE, + enabled: true, + commands: [ + { + name: "app.exit", + title: "Dismiss form", + category: "Form", + run() { + void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }) + }, + }, + ], + bindings: [ + { + key: "return", + desc: "Open link", + group: "Form", + cmd: () => { + void open(props.form.url) + }, + }, + { + key: "escape", + desc: "Dismiss form", + group: "Form", + cmd: () => { + void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }) + }, + }, + ], + })) + + return ( + + + {props.form.title ?? "Input requested"} + + {message()} + + {props.form.url} + + + + enter open link + + + esc dismiss + + + + ) +} + +function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { + const sdk = useSDK() + const { theme } = useTheme() + const renderer = useRenderer() + const dimensions = useTerminalDimensions() + const tuiConfig = useTuiConfig() + const modeStack = useOpencodeModeStack() + + const [tabHover, setTabHover] = createSignal(null) + const [store, setStore] = createStore({ + tab: 0, + answers: Object.fromEntries( + props.form.fields.flatMap((field) => (field.default === undefined ? [] : [[field.key, field.default]])), + ) as Record, + custom: Object.fromEntries( + props.form.fields.flatMap((field) => { + const value = customDefault(field) + return value === undefined ? [] : [[field.key, value]] + }), + ) as Record, + selected: selectedRow(props.form.fields[0], props.form.fields[0]?.default), + editing: false, + error: "", + }) + + let textarea: TextareaRenderable | undefined + let review: ScrollBoxRenderable | undefined + + const fields = createMemo(() => { + const answers: Record = {} + return props.form.fields.filter((field) => { + const active = (field.when ?? []).every((when) => { + const value = answers[when.key] + if (value === undefined) return false + const hit = Array.isArray(value) ? value.some((item) => item === when.value) : value === when.value + return when.op === "eq" ? hit : !hit + }) + if (active) answers[field.key] = store.answers[field.key] + return active + }) + }) + const single = createMemo(() => { + const list = fields() + if (props.form.fields.length !== 1) return false + if (list.length !== 1) return false + const field = list[0]! + return field.type === "boolean" || (field.type === "string" && field.options !== undefined) + }) + const tabs = createMemo(() => (single() ? 1 : fields().length + 1)) + const tabbed = createMemo(() => { + const width = fields().reduce((sum, item) => sum + truncate(fieldLabel(item), 24).length + 3, "Confirm".length + 3) + return width <= dimensions().width - 8 + }) + const answered = createMemo( + () => + fields().filter((item) => { + const value = store.answers[item.key] + return value !== undefined + }).length, + ) + const field = createMemo(() => fields()[Math.min(store.tab, fields().length - 1)]) + const confirm = createMemo(() => !single() && store.tab >= fields().length) + const rows = createMemo(() => { + const current = field() + if (!current) return [] + const configured = fieldRows(current) + const value = store.answers[current.key] + if (current.type !== "multiselect" || !Array.isArray(value)) return configured + const known = new Set(configured.map((row) => row.value)) + return [ + ...configured, + ...value.filter((item) => !known.has(item)).map((item) => ({ value: item, label: item, description: undefined })), + ] + }) + const textual = createMemo(() => { + if (confirm()) return false + const current = field() + if (!current) return false + if (current.type === "number" || current.type === "integer") return true + return current.type === "string" && current.options === undefined + }) + const custom = createMemo(() => { + const current = field() + if (!current) return false + if (current.type === "string" && current.options !== undefined) return current.custom === true + if (current.type === "multiselect") return current.custom === true + return false + }) + const multi = createMemo(() => field()?.type === "multiselect") + const placeholder = createMemo(() => { + const current = field() + if (current?.type === "string") { + if (current.placeholder) return current.placeholder + if (current.format === "email") return "name@example.com" + if (current.format === "uri") return "https://example.com" + if (current.format === "date") return "YYYY-MM-DD" + if (current.format === "date-time") return "YYYY-MM-DDTHH:MM:SSZ" + } + if (current?.type === "number" || current?.type === "integer") { + const minimum = typeof current.minimum === "number" ? current.minimum : undefined + const maximum = typeof current.maximum === "number" ? current.maximum : undefined + if (minimum !== undefined && maximum !== undefined) return `${minimum}-${maximum}` + if (minimum !== undefined) return `at least ${minimum}` + if (maximum !== undefined) return `at most ${maximum}` + } + return "Type your answer" + }) + const other = createMemo(() => custom() && store.selected === rows().length) + const input = createMemo(() => store.custom[field()?.key ?? ""] ?? "") + const customPicked = createMemo(() => { + const value = input() + if (!value) return false + const answer = store.answers[field()?.key ?? ""] + if (Array.isArray(answer)) return answer.includes(value) + return answer === value + }) + + function answer(key: string, value: FormValue | undefined) { + setStore("answers", { ...store.answers, [key]: value }) + setStore("error", "") + } + + function replySingle(field: Field, value: FormValue) { + sdk.api.form + .reply({ + sessionID: props.form.sessionID, + formID: props.form.id, + answer: { [field.key]: value }, + }) + .catch((error: unknown) => { + setStore( + "error", + typeof error === "object" && error !== null && "message" in error && typeof error.message === "string" + ? error.message + : "Invalid answer", + ) + }) + } + + function pick(value: FormValue, customValue?: string) { + const current = field() + if (!current) return + const invalid = validateValue(current, value) + if (invalid) { + setStore("error", invalid) + return + } + answer(current.key, value) + if (customValue !== undefined) setStore("custom", { ...store.custom, [current.key]: customValue }) + if (single()) { + replySingle(current, value) + return + } + selectTab(store.tab + 1) + } + + function toggle(value: string) { + const current = field() + if (!current) return + const existing = store.answers[current.key] + const list = Array.isArray(existing) ? [...existing] : [] + const index = list.indexOf(value) + if (index === -1) list.push(value) + if (index !== -1) list.splice(index, 1) + answer(current.key, list) + } + + function validateCurrent() { + if (confirm()) return true + const current = field() + if (!current) return true + const invalid = validateValue(current, store.answers[current.key]) + if (!invalid) return true + setStore("error", invalid) + return false + } + + function selectTab(index: number) { + if (!confirm() && index > store.tab && !validateCurrent()) return + const next = fields()[index] + setStore("tab", index) + setStore("selected", selectedRow(next, next ? store.answers[next.key] : undefined)) + setStore("editing", false) + setStore("error", "") + } + + function selectOption() { + if (other()) { + if (!multi()) { + setStore("editing", true) + return + } + const value = input() + if (value && customPicked()) { + toggle(value) + return + } + setStore("editing", true) + return + } + const row = rows()[store.selected] + if (!row) return + if (multi()) { + toggle(String(row.value)) + return + } + pick(row.value) + } + + function commitInput(text: string) { + const current = field() + if (!current) return false + const isTextual = textual() + const isMulti = multi() + if (!text) { + const previous = store.custom[current.key] + const existing = store.answers[current.key] + const values = Array.isArray(existing) ? existing.filter((value) => value !== previous) : [] + const value = !isTextual && isMulti && Array.isArray(existing) ? values : undefined + const invalid = validateValue(current, value) + if (invalid) { + setStore("error", invalid) + return false + } + answer(current.key, value) + setStore("custom", { ...store.custom, [current.key]: "" }) + setStore("editing", false) + return true + } + + if (isTextual && (current.type === "number" || current.type === "integer")) { + const value = Number(text) + const invalid = validateValue(current, value) + if (invalid) { + setStore("error", invalid) + return false + } + answer(current.key, value) + } + + if (isTextual && current.type === "string") { + const invalid = validateValue(current, text) + if (invalid) { + setStore("error", invalid) + return false + } + answer(current.key, text) + } + + if (!isTextual && isMulti) { + const previous = store.custom[current.key] + const existing = store.answers[current.key] + const values = Array.isArray(existing) ? [...existing] : [] + if (previous) { + const index = values.indexOf(previous) + if (index !== -1) values.splice(index, 1) + } + if (!values.includes(text)) values.push(text) + answer(current.key, values) + } + + if (!isTextual && !isMulti) { + const invalid = validateValue(current, text) + if (invalid) { + setStore("error", invalid) + return false + } + answer(current.key, text) + } + + const configured = current.type === "string" && current.options?.some((option) => option.value === text) + setStore("custom", { ...store.custom, [current.key]: isMulti || configured ? "" : text }) + setStore("editing", false) + return true + } + + function submitInput(text: string, direction: 1 | -1 = 1) { + if (!commitInput(text)) { + if (direction === -1) selectTab((store.tab + direction + tabs()) % tabs()) + return + } + if (!single()) selectTab((store.tab + direction + tabs()) % tabs()) + } + + function selectTabFromMouse(target?: Field) { + const targetIndex = () => { + const index = target ? fields().findIndex((field) => field.key === target.key) : fields().length + return index === -1 ? fields().length : index + } + const move = () => selectTab(targetIndex()) + if (!textual() && !store.editing) { + move() + return + } + if (!commitInput(textarea?.plainText?.trim() ?? "")) { + if (targetIndex() < store.tab) move() + return + } + move() + } + + onMount(() => onCleanup(modeStack.push(FORM_MODE))) + + useBindings(() => ({ + mode: FORM_MODE, + enabled: (store.editing || textual()) && !confirm(), + commands: [ + { + name: "prompt.clear", + title: "Clear answer edit", + category: "Form", + run() { + const text = textarea?.plainText ?? "" + if (!text) { + setStore("editing", false) + return + } + textarea?.setText("") + }, + }, + ], + bindings: [ + { + key: "escape", + desc: "Cancel answer edit", + group: "Form", + cmd: () => { + if (textual()) { + void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }) + return + } + setStore("editing", false) + }, + }, + ...tuiConfig.keybinds.get("prompt.clear"), + { + key: "tab", + desc: "Next field", + group: "Form", + cmd: () => { + const text = textarea?.plainText?.trim() ?? "" + submitInput(text) + }, + }, + { + key: "shift+tab", + desc: "Previous field", + group: "Form", + cmd: () => { + const text = textarea?.plainText?.trim() ?? "" + submitInput(text, -1) + }, + }, + { + key: "return", + desc: "Submit answer edit", + group: "Form", + cmd: () => { + const text = textarea?.plainText?.trim() ?? "" + const current = field() + if (!current) return + if (textual()) { + submitInput(text) + return + } + const wasMulti = multi() + if (!commitInput(text) || wasMulti || !text) return + if (single()) { + replySingle(current, text) + return + } + selectTab(store.tab + 1) + }, + }, + ], + })) + + useBindings(() => { + const total = rows().length + (custom() ? 1 : 0) + const max = Math.min(total, 9) + + return { + mode: FORM_MODE, + enabled: !store.editing && !textual(), + commands: [ + { + name: "app.exit", + title: "Dismiss form", + category: "Form", + run() { + void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }) + }, + }, + ], + bindings: [ + { + key: "left", + desc: "Previous field", + group: "Form", + cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()), + }, + { + key: "h", + desc: "Previous field", + group: "Form", + cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()), + }, + { key: "right", desc: "Next field", group: "Form", cmd: () => selectTab((store.tab + 1) % tabs()) }, + { key: "l", desc: "Next field", group: "Form", cmd: () => selectTab((store.tab + 1) % tabs()) }, + { + key: "tab", + desc: "Next field", + group: "Form", + cmd: () => selectTab((store.tab + 1) % tabs()), + }, + { + key: "shift+tab", + desc: "Previous field", + group: "Form", + cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()), + }, + ...(confirm() + ? [ + { + key: "return", + desc: "Submit form", + group: "Form", + cmd: () => { + const invalid = fields().find((field) => validateValue(field, store.answers[field.key])) + if (invalid) { + setStore("error", validateValue(invalid, store.answers[invalid.key]) ?? "Invalid answer") + return + } + sdk.api.form + .reply({ + sessionID: props.form.sessionID, + formID: props.form.id, + answer: Object.fromEntries( + fields().flatMap((field) => { + const value = store.answers[field.key] + return value === undefined ? [] : [[field.key, value] as const] + }), + ), + }) + .catch((error: unknown) => { + setStore( + "error", + typeof error === "object" && + error !== null && + "message" in error && + typeof error.message === "string" + ? error.message + : "Invalid answer", + ) + }) + }, + }, + { + key: "escape", + desc: "Dismiss form", + group: "Form", + cmd: () => { + void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }) + }, + }, + { key: "up", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(-1) }, + { key: "k", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(-1) }, + { key: "down", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(1) }, + { key: "j", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(1) }, + ...tuiConfig.keybinds.get("app.exit"), + ] + : [ + ...Array.from({ length: max }, (_, index) => ({ + key: String(index + 1), + desc: `Select answer ${index + 1}`, + group: "Form", + cmd: () => { + setStore("selected", index) + selectOption() + }, + })), + { + key: "up", + desc: "Previous answer", + group: "Form", + cmd: () => setStore("selected", (store.selected - 1 + total) % total), + }, + { + key: "k", + desc: "Previous answer", + group: "Form", + cmd: () => setStore("selected", (store.selected - 1 + total) % total), + }, + { + key: "down", + desc: "Next answer", + group: "Form", + cmd: () => setStore("selected", (store.selected + 1) % total), + }, + { + key: "j", + desc: "Next answer", + group: "Form", + cmd: () => setStore("selected", (store.selected + 1) % total), + }, + { key: "return", desc: "Select answer", group: "Form", cmd: () => selectOption() }, + { + key: "escape", + desc: "Dismiss form", + group: "Form", + cmd: () => { + void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }) + }, + }, + ...tuiConfig.keybinds.get("app.exit"), + ]), + ], + } + }) + + return ( + + + + + {props.form.title} + + + + + + {confirm() ? "Review" : `Field ${Math.min(store.tab, fields().length - 1) + 1} of ${fields().length}`} + + + · {answered()}/{fields().length} answered + + + + + + + {(item, index) => { + const isTab = () => index() === store.tab + const isAnswered = () => store.answers[item.key] !== undefined + return ( + setTabHover(index())} + onMouseOut={() => setTabHover(null)} + onMouseUp={() => { + if (renderer.getSelection()?.getSelectedText()) return + selectTabFromMouse(item) + }} + > + + {truncate(fieldLabel(item), 24)} + + + ) + }} + + setTabHover("confirm")} + onMouseOut={() => setTabHover(null)} + onMouseUp={() => { + if (renderer.getSelection()?.getSelectedText()) return + selectTabFromMouse() + }} + > + Confirm + + + + + + + + + {field()!.description ?? fieldLabel(field()!)} + {field()!.required ? " (required)" : ""} + {multi() ? " (select all that apply)" : ""} + + + + +