chore(tui): merge v2 into form branch

This commit is contained in:
Aiden Cline 2026-07-03 15:01:22 -05:00
commit be0cd93c8f
235 changed files with 5462 additions and 3983 deletions

View file

@ -155,9 +155,10 @@ const table = sqliteTable("session", {
- 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.

View file

@ -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`.

View file

@ -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:",
@ -126,6 +127,7 @@
"@opencode-ai/schema": "workspace:*",
},
"devDependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@opencode-ai/server": "workspace:*",
@ -597,7 +599,6 @@
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
@ -795,6 +796,7 @@
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:",
},
@ -848,6 +850,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",
@ -962,6 +979,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:",
@ -1236,6 +1254,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=="],
@ -2002,6 +2036,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"],
@ -3490,7 +3526,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=="],
@ -6048,6 +6084,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=="],
@ -6114,8 +6152,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=="],
@ -6328,8 +6364,6 @@
"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=="],
@ -6360,8 +6394,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=="],
@ -6428,8 +6460,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=="],
@ -7078,8 +7108,6 @@
"opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
"openid-client/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],

View file

@ -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:",

View file

@ -4,7 +4,6 @@ import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Service } from "@opencode-ai/client/effect"
import { ServiceConfig } from "../../services/service-config"
import type { Transport } from "@opencode-ai/client/effect"
const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"])
@ -61,7 +60,7 @@ export function rawRequest(input: readonly string[]) {
}
function resolveRequest(
transport: Transport,
transport: Service.Transport,
input: readonly string[],
params: Record<string, string>,
) {

View file

@ -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"

View file

@ -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,
)
}),
)

View file

@ -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"

View file

@ -1,4 +1,4 @@
import * as Effect from "effect/Effect"
import { Effect } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"

View file

@ -3,9 +3,9 @@ import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Project } from "@opencode-ai/core/project"
import { Global } from "@opencode-ai/core/global"
import { Context, FileSystem, Layer, Option, Schedule, Schema } from "effect"
import * as Effect from "effect/Effect"
import { Context, Effect, FileSystem, Layer, Option, Redacted, Schedule, Schema } from "effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { createServer } from "node:http"
import { createRoutes } from "@opencode-ai/server/routes"
@ -14,6 +14,8 @@ import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Service } from "@opencode-ai/client/effect"
import { Env } from "../../env"
import { ServiceConfig } from "../../services/service-config"
import { Updater } from "../../services/updater"
import { randomBytes, randomUUID } from "crypto"
@ -25,12 +27,19 @@ export default Runtime.handler(
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 standalonePassword = yield* Env.password
// Keep the lease credential out of the environment inherited by any
// process this server spawns.
if (input.stdio) {
delete process.env.OPENCODE_PASSWORD
delete process.env.OPENCODE_SERVER_PASSWORD
}
const config = input.service ? yield* ServiceConfig.read() : {}
const password = input.service
? yield* ServiceConfig.password()
: standalonePassword || randomBytes(32).toString("base64url")
: standalonePassword
? Redacted.value(standalonePassword)
: randomBytes(32).toString("base64url")
if (!password) return yield* Effect.fail(new Error("Missing server password"))
const hostname = Option.getOrUndefined(input.hostname) ?? config.hostname ?? "127.0.0.1"
const port = Option.isSome(input.port)
@ -51,7 +60,7 @@ export default Runtime.handler(
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)
return yield* input.stdio ? waitForStdinClose() : Effect.never
}).pipe(Effect.annotateLogs({ role: "server" })),
)
}),
@ -63,8 +72,11 @@ export default Runtime.handler(
// 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))
// Written and read through Service.Info so the file the server registers is
// provably the contract clients discover with.
const infoJson = Schema.fromJsonString(Service.Info)
const encodeInfo = Schema.encodeEffect(infoJson)
const decodeInfo = Schema.decodeUnknownEffect(infoJson)
const register = Effect.fnUntraced(function* (address: HttpServer.Address) {
const fs = yield* FileSystem.FileSystem
@ -73,20 +85,17 @@ const register = Effect.fnUntraced(function* (address: HttpServer.Address) {
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 },
)
const encoded = yield* encodeInfo({
id,
version: InstallationVersion,
url: HttpServer.formatAddress(address),
pid: process.pid,
password: secret,
})
yield* fs.writeFileString(temp, encoded, { mode: 0o600 })
yield* fs.rename(temp, file)
const currentID = fs.readFileString(file).pipe(
Effect.flatMap(decodeRegistrationId),
Effect.flatMap(decodeInfo),
Effect.map((info) => info.id),
Effect.orElseSucceed(() => undefined),
)
@ -134,9 +143,9 @@ function listen(hostname: string, port: Option.Option<number>, password: string)
function bind(hostname: string, port: number, password: string) {
const server = createServer()
return Layer.build(
HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe(
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]))),
Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node, Project.node]))),
),
).pipe(
Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))),

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

15
packages/cli/src/env.ts Normal file
View file

@ -0,0 +1,15 @@
import { Config } from "effect"
// Every environment variable the CLI reads, in one place. Consumers yield
// these instead of touching process.env so the full surface stays visible,
// typed, and redacted where secret.
// The opencode server password: sent by clients connecting to an explicit
// --server, and adopted by a manually run or standalone server. The legacy
// name is still honored.
export const password = Config.redacted("OPENCODE_PASSWORD").pipe(
Config.orElse(() => Config.redacted("OPENCODE_SERVER_PASSWORD")),
Config.withDefault(undefined),
)
export * as Env from "./env"

View file

@ -1,9 +1,8 @@
import * as Effect from "effect/Effect"
import * as Command from "effect/unstable/cli/Command"
import { Effect, FileSystem, Scope } from "effect"
import { Command } from "effect/unstable/cli"
import { Spec } from "./spec"
import { Global } from "@opencode-ai/core/global"
import { Updater } from "../services/updater"
import { FileSystem, Scope } from "effect"
export type Input<Value> =
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
@ -12,11 +11,21 @@ export type Input<Value> =
? Input
: never
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
type RuntimeHandler = (
input: unknown,
) => Effect.Effect<void, unknown, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
type Loader<Node extends Spec.Any> = () => Promise<{
default: (input: Input<Node>) => Effect.Effect<void, any, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
default: (
input: Input<Node>,
) => Effect.Effect<void, any, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
}>
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
type ProvidedCommand = Command.Command<
string,
unknown,
unknown,
unknown,
FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope
>
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
? Loader<Node>

View file

@ -1,4 +1,4 @@
import * as Command from "effect/unstable/cli/Command"
import { Command } from "effect/unstable/cli"
type Options<Config extends Command.Command.Config, Commands extends ReadonlyArray<Any>> = {
readonly description?: string

View file

@ -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"
@ -46,7 +43,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),

View file

@ -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.

View file

@ -15,7 +15,9 @@ function command(password: string) {
if (!compiled && entrypoint.length === 0) throw new Error("Failed to resolve CLI entrypoint")
return ChildProcess.make(process.execPath, [...entrypoint, "serve", "--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.

View file

@ -5,11 +5,16 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/core/global"
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
import { OpenCode } from "@opencode-ai/client/promise"
import type { Transport } from "@opencode-ai/client/effect"
import type { Service } from "@opencode-ai/client/effect"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { Args } from "@opencode-ai/tui/context/args"
export function runTui(transport: Transport, args: Args, discover?: () => Promise<Transport>) {
export function runTui(
transport: Service.Transport,
args: Args,
discover?: () => Promise<Service.Transport>,
reload?: () => Promise<void>,
) {
const config = TuiConfig.resolve({}, { terminalSuspend: false })
let disposeSlots: (() => void) | undefined
return Effect.gen(function* () {
@ -33,6 +38,7 @@ export function runTui(transport: Transport, args: Args, discover?: () => Promis
}
}
: undefined,
reload,
args,
config,
pluginHost: {

View file

@ -208,23 +208,35 @@ const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11I
payload: { text: input["text"], description: input["description"], metadata: input["metadata"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] }
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.shell"]>[0]
type Endpoint4_12Input = {
readonly sessionID: Endpoint4_12Request["params"]["sessionID"]
readonly id?: Endpoint4_12Request["payload"]["id"]
readonly command: Endpoint4_12Request["payload"]["command"]
}
const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) =>
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
raw["session.shell"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], command: input["command"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] }
const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) =>
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] }
const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) =>
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
type Endpoint4_14Input = {
readonly sessionID: Endpoint4_14Request["params"]["sessionID"]
readonly messageID: Endpoint4_14Request["payload"]["messageID"]
readonly files?: Endpoint4_14Request["payload"]["files"]
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
type Endpoint4_15Input = {
readonly sessionID: Endpoint4_15Request["params"]["sessionID"]
readonly messageID: Endpoint4_15Request["payload"]["messageID"]
readonly files?: Endpoint4_15Request["payload"]["files"]
}
const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) =>
const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) =>
raw["session.revert.stage"]({
params: { sessionID: input["sessionID"] },
payload: { messageID: input["messageID"], files: input["files"] },
@ -233,61 +245,61 @@ const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14I
Effect.map((value) => value.data),
)
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] }
const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) =>
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] }
const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) =>
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] }
const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) =>
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] }
const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) =>
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.context.entry.list"]>[0]
type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] }
const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) =>
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.context.entry.list"]>[0]
type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) =>
raw["session.context.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.context.entry.put"]>[0]
type Endpoint4_19Input = {
readonly sessionID: Endpoint4_19Request["params"]["sessionID"]
readonly key: Endpoint4_19Request["params"]["key"]
readonly value: Endpoint4_19Request["payload"]["value"]
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.context.entry.put"]>[0]
type Endpoint4_20Input = {
readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
readonly key: Endpoint4_20Request["params"]["key"]
readonly value: Endpoint4_20Request["payload"]["value"]
}
const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) =>
const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) =>
raw["session.context.entry.put"]({
params: { sessionID: input["sessionID"], key: input["key"] },
payload: { value: input["value"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.context.entry.remove"]>[0]
type Endpoint4_20Input = {
readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
readonly key: Endpoint4_20Request["params"]["key"]
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.context.entry.remove"]>[0]
type Endpoint4_21Input = {
readonly sessionID: Endpoint4_21Request["params"]["sessionID"]
readonly key: Endpoint4_21Request["params"]["key"]
}
const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) =>
const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) =>
raw["session.context.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.log"]>[0]
type Endpoint4_21Input = {
readonly sessionID: Endpoint4_21Request["params"]["sessionID"]
readonly after?: Endpoint4_21Request["query"]["after"]
readonly follow?: Endpoint4_21Request["query"]["follow"]
type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.log"]>[0]
type Endpoint4_22Input = {
readonly sessionID: Endpoint4_22Request["params"]["sessionID"]
readonly after?: Endpoint4_22Request["query"]["after"]
readonly follow?: Endpoint4_22Request["query"]["follow"]
}
const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) =>
const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) =>
Stream.unwrap(
raw["session.log"]({
params: { sessionID: input["sessionID"] },
@ -298,22 +310,22 @@ const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21I
),
)
type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint4_22Input = { readonly sessionID: Endpoint4_22Request["params"]["sessionID"] }
const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) =>
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.background"]>[0]
type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] }
const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23Input) =>
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.background"]>[0]
type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] }
const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) =>
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint4_24Input = {
readonly sessionID: Endpoint4_24Request["params"]["sessionID"]
readonly messageID: Endpoint4_24Request["params"]["messageID"]
type Endpoint4_25Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint4_25Input = {
readonly sessionID: Endpoint4_25Request["params"]["sessionID"]
readonly messageID: Endpoint4_25Request["params"]["messageID"]
}
const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) =>
const Endpoint4_25 = (raw: RawClient["server.session"]) => (input: Endpoint4_25Input) =>
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@ -332,19 +344,20 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({
command: Endpoint4_9(raw),
skill: Endpoint4_10(raw),
synthetic: Endpoint4_11(raw),
compact: Endpoint4_12(raw),
wait: Endpoint4_13(raw),
revertStage: Endpoint4_14(raw),
revertClear: Endpoint4_15(raw),
revertCommit: Endpoint4_16(raw),
context: Endpoint4_17(raw),
listContextEntries: Endpoint4_18(raw),
putContextEntry: Endpoint4_19(raw),
removeContextEntry: Endpoint4_20(raw),
log: Endpoint4_21(raw),
interrupt: Endpoint4_22(raw),
background: Endpoint4_23(raw),
message: Endpoint4_24(raw),
shell: Endpoint4_12(raw),
compact: Endpoint4_13(raw),
wait: Endpoint4_14(raw),
revertStage: Endpoint4_15(raw),
revertClear: Endpoint4_16(raw),
revertCommit: Endpoint4_17(raw),
context: Endpoint4_18(raw),
listContextEntries: Endpoint4_19(raw),
putContextEntry: Endpoint4_20(raw),
removeContextEntry: Endpoint4_21(raw),
log: Endpoint4_22(raw),
interrupt: Endpoint4_23(raw),
background: Endpoint4_24(raw),
message: Endpoint4_25(raw),
})
type Endpoint5_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]

View file

@ -2,7 +2,6 @@
// Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations.
export * from "./generated/index"
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"

View file

@ -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,21 +30,21 @@ 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)
export const discover = Effect.fn("service.discover")(function* (options: Options = {}) {
const info = yield* read(options.file)
if (info === undefined) return undefined
if (options.version !== undefined && info.version !== options.version) return undefined
const found = yield* probe(info)
return found?.transport
})
// 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"))
@ -64,10 +64,10 @@ export const start = Effect.fn("service.start")(function* (options: ServiceOptio
)
})
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 +80,18 @@ 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))
// 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,14 +101,14 @@ 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 probe = Effect.fnUntraced(function* (info: Info) {
const headers = info.password === undefined ? undefined : auth(info.password)
const healthy = yield* Effect.tryPromise(() =>
fetch(new URL("/api/health", registration.url), {
fetch(new URL("/api/health", info.url), {
headers,
signal: AbortSignal.timeout(2_000),
}),
@ -117,15 +117,15 @@ const probe = Effect.fnUntraced(function* (registration: Registration) {
Effect.orElseSucceed(() => false),
)
if (!healthy) return undefined
return { registration, transport: { url: registration.url, headers } } satisfies LocalService
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)
})
// 50ms cadence bounded at ~5s, shared by stop escalation and start readiness.
@ -142,22 +142,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))
})

View file

@ -29,6 +29,8 @@ import type {
SessionSkillOutput,
SessionSyntheticInput,
SessionSyntheticOutput,
SessionShellInput,
SessionShellOutput,
SessionCompactInput,
SessionCompactOutput,
SessionWaitInput,
@ -525,6 +527,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
shell: (input: SessionShellInput, requestOptions?: RequestOptions) =>
request<SessionShellOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/shell`,
body: { id: input["id"], command: input["command"] },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
compact: (input: SessionCompactInput, requestOptions?: RequestOptions) =>
request<SessionCompactOutput>(
{

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,3 @@
export * from "./generated/index"
export type { Transport } from "../effect/service.js"
export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types"
export type OpenCodeClient = ReturnType<typeof import("./generated/client").make>

View file

@ -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", "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 !== "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(["model.selected", "log.synced"])
expect(logged[0]?.type === "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: "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" },
},
}

View file

@ -151,7 +151,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 +159,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 === "model.selected" && events[1].created).toBe(1_717_171_717_000)
})
test("event.subscribe terminates on malformed Promise SSE data", async () => {
@ -328,12 +328,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: "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" },
},
}

View file

@ -458,8 +458,8 @@ export const discoveryPlan = <R>(
// Section order is deliberate: workflow first (the top is the least likely part of a long
// description to be truncated or skimmed away), then rules, then syntax, with the budgeted
// catalog at the bottom. Example call forms use explicit `<namespace>.<tool>` placeholders -
// never a real or fabricated tool name.
// catalog at the bottom. Example call forms use placeholders - never a real or fabricated
// tool name - and show both dot and bracket notation so non-identifier names are not normalized.
const intro = [
"Write a CodeMode program to answer the request. Return code only.",
empty
@ -467,6 +467,7 @@ export const discoveryPlan = <R>(
: complete
? "Execute JavaScript in a confined runtime. Inside this program, `tools` contains only the host-provided tools listed below; surrounding agent tools are not available unless listed here."
: "Execute JavaScript in a confined runtime. Inside this program, `tools` contains only the host-provided tools listed or searchable below; surrounding agent tools are not available unless listed here.",
...(empty ? [] : ["Do not infer or normalize tool names; use only exact signatures shown below or returned by search."]),
]
// The search step exists only when search is advertised (PARTIAL catalog); a COMPLETE
@ -480,14 +481,14 @@ export const discoveryPlan = <R>(
...(complete
? [
"1. Pick a tool from the list under `## Available tools` - each line is the exact call signature; use it as-is rather than guessing segments.",
"2. Call it using the exact signature shown: `const res = await tools.<namespace>.<tool>(input)` - bracket notation may appear for names that are not JavaScript identifiers.",
'2. Call it using the exact signature shown; bracket notation and quotes are part of the path.',
'3. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string.',
"4. Return only the fields you need: `return { <field>: data.<field> }` - raw payloads get truncated and waste context.",
]
: [
'1. Find a tool (skip when it is already listed below): `const { items } = await tools.$codemode.search({ query: "<intent + key nouns>" })` - short phrases like "list issues" work best.',
'1. If the exact signature is not listed below, first search: `const { items } = await tools.$codemode.search({ query: "<intent + key nouns>" })`.',
"2. Read the matches: each item is `{ path, description, signature }` - read the description before using an unfamiliar tool.",
"3. Call it with the result's `path` as-is (never guess segments): `const res = await tools.<namespace>.<tool>(input)` - bracket notation may appear for names that are not JavaScript identifiers.",
"3. Call the result's `path` as-is; bracket notation and quotes are part of the path.",
'4. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string.',
"5. Return only the fields you need: `return { <field>: data.<field> }` - raw payloads get truncated and waste context.",
]),
@ -504,7 +505,7 @@ export const discoveryPlan = <R>(
: "- Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`; tools from the surrounding agent/runtime are not implicitly exposed.",
"- Filter, aggregate, and transform collections in code - never return them raw or call a tool per item across messages.",
"- A result typed `Promise<unknown>` has no guaranteed shape - verify what actually came back before relying on its fields.",
"- Run independent calls in parallel: `await Promise.all(items.map((item) => tools.<namespace>.<tool>(item)))`.",
'- Run independent calls in parallel: `await Promise.all(items.map((item) => tools.<namespace>.<tool>(item)))`, or use `tools.<namespace>["tool-name"](item)` when the listed signature uses bracket notation.',
"- `Object.keys(tools)` lists namespaces; `Object.keys(tools.<namespace>)` lists its tools; `for...in` works on both.",
...(complete
? []

View file

@ -622,12 +622,12 @@ describe("CodeMode public contract", () => {
)
expect(instructions).toContain("Return only the fields you need")
expect(instructions).toContain("raw payloads get truncated and waste context")
expect(instructions).toContain("`const res = await tools.<namespace>.<tool>(input)`")
expect(instructions).toContain("Do not infer or normalize tool names")
expect(instructions).toContain("bracket notation and quotes are part of the path")
expect(instructions).toContain("surrounding agent tools are not available unless listed here")
expect(instructions).toContain("Only tools listed here are available inside `tools`")
expect(instructions).toContain("bracket notation may appear for names that are not JavaScript identifiers")
// Placeholders use the <namespace>.<tool>/<field> style ONLY - no fabricated tool
// names, and no real catalog tools cherry-picked into example lines.
// Placeholders use generic namespace/tool/field names only - no fabricated real tools
// and no real catalog tools cherry-picked into example lines.
expect(instructions).toContain("`return { <field>: data.<field> }`")
expect(instructions).not.toContain("total_count")
expect(instructions).not.toContain("list_issues")
@ -640,7 +640,7 @@ describe("CodeMode public contract", () => {
// PARTIAL: the workflow starts with search (with query-style guidance that is clearly
// a query string, never a tool name) and the browse-namespace rule appears.
expect(partial).toContain(
'1. Find a tool (skip when it is already listed below): `const { items } = await tools.$codemode.search({ query: "<intent + key nouns>" })` - short phrases like "list issues" work best.',
'1. If the exact signature is not listed below, first search: `const { items } = await tools.$codemode.search({ query: "<intent + key nouns>" })`.',
)
expect(partial).toContain(
"Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`",

View file

@ -339,3 +339,43 @@ describe("pretty signatures in search results", () => {
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<unknown>',
)
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: {')
})
})

View file

@ -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,

View file

@ -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<typeof ID>

View file

@ -2,17 +2,19 @@ export * as ConfigExperimental from "./experimental"
import { Schema } from "effect"
import { Catalog } from "../catalog"
import { Policy as PolicyV2 } from "../policy"
import { Policy } 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<Policy>("ConfigV2.Experimental.Policy")({
...PolicyV2.Info.fields,
class PolicyConfig extends Schema.Class<PolicyConfig>("ConfigV2.Experimental.Policy")({
...Policy.Info.fields,
action: PolicyAction,
}) {}
export { PolicyConfig as Policy }
export class Experimental extends Schema.Class<Experimental>("ConfigV2.Experimental")({
policies: Policy.pipe(Schema.Array, Schema.optional),
policies: PolicyConfig.pipe(Schema.Array, Schema.optional),
}) {}

View file

@ -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) {

View file

@ -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<ChildProcess.StandardCommand>,
commands: [head, ...tail] as NonEmptyReadonlyArray<ChildProcess.StandardCommand>,
opts,
}
}
@ -96,7 +87,7 @@ const toPlatformError = (
type ExitSignal = Deferred.Deferred<readonly [code: number | null, signal: NodeJS.Signals | null]>
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<ChildProcessSpawner, never, FileSystem.FileSystem | Path.Path> = Layer.effect(
ChildProcessSpawner,
make,
makeCrossSpawnSpawner,
)
export const node = makeGlobalNode({ service: ChildProcessSpawner, layer, deps: [filesystem, path] })

View file

@ -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<Service, Interface>()("@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() {

View file

@ -41,5 +41,7 @@ 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"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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))))
},
})

View file

@ -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

View file

@ -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<Uint8Array, SqlError>
@ -57,7 +50,7 @@ const make = (options: Config) =>
Effect.withFiber<Array<Record<string, unknown>>, 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<Record<string, unknown>>)
} catch (cause) {
@ -73,7 +66,7 @@ const make = (options: Config) =>
Effect.withFiber<Array<unknown[]>, 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<unknown[]>)
} 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,

View file

@ -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<void, SqlError>
@ -56,7 +49,7 @@ const make = (options: Config) =>
const run = (query: string, params: ReadonlyArray<unknown> = []) =>
Effect.withFiber<Array<Record<string, unknown>>, 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<Record<string, unknown>>)
} catch (cause) {
@ -71,7 +64,7 @@ const make = (options: Config) =>
const runValues = (query: string, params: ReadonlyArray<unknown> = []) =>
Effect.withFiber<ReadonlyArray<ReadonlyArray<unknown>>, 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,

View file

@ -227,7 +227,7 @@ export function hoist<A, E, T extends Tag, const Items extends Replacements = re
}
if (node.tag === tag) {
const existing = hoisted.get(node.name)
if (existing && existing !== node) {
if (existing && existing.implementation !== node.implementation) {
throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`)
}
hoisted.set(node.name, rewriteReplacementDependencies(node, replacementMap))

View file

@ -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<string, unknown>
@ -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<SubscriberO
{ capacity: Schema.Int },
) {}
export const define = Event.define
export const versionedType = Event.versionedType
export const durable = Event.durable
export const ephemeral = Event.ephemeral
export interface PublishOptions {
readonly id?: ID
@ -294,6 +297,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 +369,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 +475,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 +499,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
@ -609,6 +616,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,

View file

@ -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<Record<string, unknown>>().notNull(),
},

View file

@ -113,7 +113,8 @@ const layer = Layer.effect(
)
}
const config = (yield* (yield* Config.Service).entries())
const configService = yield* Config.Service
const config = (yield* configService.entries())
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
yield* Effect.forkScoped(

View file

@ -98,10 +98,14 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
const forms = yield* Cache.makeWith<ID, Entry>(() => 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<ID, Entry>(
() => 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,7 +135,9 @@ 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" },
@ -149,7 +155,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))),
)
}),
),
)
@ -301,7 +309,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 +333,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}`
}

View file

@ -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<void, Error>
readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error>
readonly readDirectoryEntries: (path: string) => Effect.Effect<DirEntry[], Error>
readonly resolve: (path: string) => Effect.Effect<string>
readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
@ -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) {

View file

@ -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]

View file

@ -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) =>

View file

@ -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

View file

@ -22,14 +22,13 @@ import { PermissionV2 } from "./permission"
import { PluginV2 } from "./plugin"
import { PluginInternal } from "./plugin/internal"
import { Policy } from "./policy"
import { Project } from "./project"
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 { SessionRunnerLLM } from "./session/runner/llm"
import { SessionRunnerModel } from "./session/runner/model"
import { SessionCompaction } from "./session/compaction"
import { SessionTitle } from "./session/title"
@ -49,8 +48,7 @@ import { Vcs } from "./vcs"
export { LocationServiceMap } from "./location-service-map"
export const locationServices = LayerNode.group([
Project.node,
const locationServiceNodes = [
Location.node,
Policy.node,
Config.node,
@ -96,7 +94,9 @@ export const locationServices = LayerNode.group([
Snapshot.node,
SessionRunnerLLM.node,
Vcs.node,
])
] as const satisfies readonly Node.LocationNode<unknown, unknown>[]
export const locationServices = LayerNode.group<typeof locationServiceNodes>(locationServiceNodes)
export type LocationServices = LayerNode.Output<typeof locationServices>
export type LocationError = LayerNode.Error<typeof locationServices>

View file

@ -46,7 +46,11 @@ const TolerantListPromptsResult = ListPromptsResultSchema.extend({
export class NeedsAuthError extends Schema.TaggedErrorClass<NeedsAuthError>()("MCP.NeedsAuthError", {
server: Schema.String,
}) {}
}) {
override get message() {
return `MCP server requires authentication: ${this.server}`
}
}
export class ConnectError extends Schema.TaggedErrorClass<ConnectError>()("MCP.ConnectError", {
server: Schema.String,

View file

@ -127,7 +127,11 @@ export class ResourceContent extends Schema.Class<ResourceContent>("MCP.Resource
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("MCP.NotFoundError", {
server: ServerName,
}) {}
}) {
override get message() {
return `MCP server not found: ${this.server}`
}
}
export class ToolCallError extends Schema.TaggedErrorClass<ToolCallError>()("MCP.ToolCallError", {
server: ServerName,

View file

@ -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<typeof Provider>
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<string, Provider> | 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<string, Provider> | 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<string, Provider>
return yield* decodeProviders(text)
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)
const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)

View file

@ -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<AskResult, SessionV2.NotFoundError>
readonly assert: (input: AssertInput) => EffectRuntime.Effect<void, Error | SessionV2.NotFoundError>
readonly reply: (input: ReplyInput) => EffectRuntime.Effect<void, NotFoundError>
readonly get: (id: ID) => EffectRuntime.Effect<Request | undefined>
readonly forSession: (sessionID: SessionV2.ID) => EffectRuntime.Effect<ReadonlyArray<Request>>
readonly list: () => EffectRuntime.Effect<ReadonlyArray<Request>>
readonly ask: (input: AssertInput) => Effect.Effect<AskResult, SessionV2.NotFoundError>
readonly assert: (input: AssertInput) => Effect.Effect<void, Error | SessionV2.NotFoundError>
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
readonly get: (id: ID) => Effect.Effect<Request | undefined>
readonly forSession: (sessionID: SessionV2.ID) => Effect.Effect<ReadonlyArray<Request>>
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
}
export class Service extends Context.Service<Service, Interface>()("@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<ID, Pending>()
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<void, RejectedError | CorrectedError>()
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)
})

View file

@ -48,7 +48,7 @@ const layer = Layer.effect(
let host: Parameters<PluginDefinition["effect"]>[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}`)
if (loading.has(id)) return yield* Effect.die(new Error(`Plugin load cycle detected for ${id}`))
yield* locks.withLock(id)(
Effect.sync(() => {
@ -90,7 +90,7 @@ const layer = Layer.effect(
})
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`)
if (loading.has(id)) return yield* Effect.die(new Error(`Cannot remove plugin ${id} while it is loading`))
yield* locks.withLock(id)(
State.batch(

View file

@ -1,6 +1,6 @@
export * as PluginHost from "./host"
import type { PluginContext as Interface } from "@opencode-ai/plugin/v2/effect"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { Effect, Schema } from "effect"
import { AgentV2 } from "../agent"
import { AISDK } from "../aisdk"
@ -40,7 +40,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
workspaceID: location.workspaceID,
project: location.project,
})
const locationRef = (input?: Parameters<Interface["agent"]["list"]>[0]) =>
const locationRef = (input?: Parameters<PluginContext["agent"]["list"]>[0]) =>
input?.location === undefined
? undefined
: Location.Ref.make({
@ -305,5 +305,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
})

View file

@ -1,7 +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 { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
import type { Scope } from "effect"
import { Credential } from "../../credential"
import { EventV2 } from "../../event"
@ -32,11 +32,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"),
@ -315,14 +320,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
)
}

View file

@ -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<string, (options: any) => any>
}).pipe(Effect.orDie)
const mod: Record<string, unknown> = 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 }
: {},

View file

@ -31,7 +31,7 @@ export interface Cell {
export const makeCell = (): Cell => ({})
const unavailable = <A, E, R>() => Effect.die("Plugin runtime is unavailable") as Effect.Effect<A, E, R>
const unavailable = <A, E, R>() => Effect.die(new Error("Plugin runtime is unavailable")) as Effect.Effect<A, E, R>
const require = <A, E, R>(cell: Cell, f: (runtime: Interface) => Effect.Effect<A, E, R>) =>
Effect.suspend(() => {
const runtime = cell.runtime

View file

@ -1,22 +1,23 @@
export * as Policy from "./policy"
import { makeLocationNode } from "./effect/app-node"
import { Context, Effect as EffectRuntime, Layer, Schema } from "effect"
import { Context, Effect, 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
const PolicyEffect = Schema.Literals(["allow", "deny"]).annotate({ identifier: "Policy.Effect" })
export { PolicyEffect as Effect }
export type Effect = typeof PolicyEffect.Type
export class Info extends Schema.Class<Info>("Policy.Info")({
action: Schema.String,
effect: Effect,
effect: PolicyEffect,
resource: Schema.String,
}) {}
export interface Interface {
readonly load: (statements: Info[]) => EffectRuntime.Effect<void>
readonly evaluate: (action: string, resource: string, fallback: Effect) => EffectRuntime.Effect<Effect>
readonly load: (statements: Info[]) => Effect.Effect<void>
readonly evaluate: (action: string, resource: string, fallback: Effect) => Effect.Effect<Effect>
readonly hasStatements: () => boolean
}
@ -24,16 +25,16 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
const layer = Layer.effect(
Service,
EffectRuntime.gen(function* () {
Effect.gen(function* () {
let statements: Info[] = []
yield* Location.Service
return Service.of({
load: EffectRuntime.fn("Policy.load")(function* (input) {
load: Effect.fn("Policy.load")(function* (input) {
statements = input
}),
hasStatements: () => statements.length > 0,
evaluate: EffectRuntime.fn("Policy.evaluate")(function* (action, resource, fallback) {
evaluate: Effect.fn("Policy.evaluate")(function* (action, resource, fallback) {
return (
statements.findLast(
(statement) => Wildcard.match(action, statement.action) && Wildcard.match(resource, statement.resource),

View file

@ -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,
})

View file

@ -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<ProjectSchema.ID>().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<ProjectSchema.ID>()
.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()

View file

@ -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 }

View file

@ -1,3 +1,4 @@
// ast-grep-ignore: no-star-import
import * as pty from "@lydell/node-pty"
import type { Opts, Proc } from "./pty"

View file

@ -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) =>

View file

@ -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")

View file

@ -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 { Identifier } from "./util/identifier"
import { Shell } from "./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<NotFoundError>()("Ses
export class OperationUnavailableError extends Schema.TaggedErrorClass<OperationUnavailableError>()(
"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<void, OperationUnavailableError>
}) => Effect.Effect<void, NotFoundError>
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<SessionSchema.ID>()
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
@ -268,6 +272,19 @@ const layer = Layer.effect(
),
)
// Session shell is user-initiated and synchronous at the API boundary, while
// the Location shell service owns process lifecycle and file-backed output.
const runShellCommand = (command: string, cwd: string) =>
Effect.gen(function* () {
const shell = yield* Shell.Service
const info = yield* shell.create({ command, cwd })
yield* shell.wait(info.id)
const output = yield* shell.output(info.id, { limit: SHELL_MAX_CAPTURE_BYTES })
return output.output || "(no output)"
}).pipe(
Effect.catchTag("Shell.NotFoundError", () => Effect.succeed("Shell command output is no longer available.")),
)
const result = Service.of({
create: Effect.fn("V2Session.create")(function* (input) {
const sessionID = input.id ?? SessionSchema.ID.create()
@ -346,8 +363,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 +505,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,8 +544,39 @@ 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 callID = Identifier.ascending()
yield* events.publish(
SessionEvent.Shell.Started,
{
sessionID: input.sessionID,
callID,
command: input.command,
},
{ id: input.id },
)
const output = yield* runShellCommand(input.command, session.location.directory).pipe(
Effect.provide(locations.get(session.location)),
)
yield* events.publish(SessionEvent.Shell.Ended, {
sessionID: input.sessionID,
callID,
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)
@ -535,8 +585,6 @@ const layer = Layer.effect(
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,
})
@ -547,10 +595,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 +608,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 +617,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 +664,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,
@ -679,6 +720,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),

View file

@ -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"
@ -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],
})

View file

@ -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"))
})

View file

@ -19,8 +19,8 @@ const layer = Layer.effect(
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({
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

View file

@ -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(

View file

@ -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) =>

View file

@ -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<Map<SessionSchema.ID, Set<string>>>(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) } },

View file

@ -19,7 +19,7 @@ export interface Adapter {
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.
// 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)
@ -100,112 +100,100 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return Effect.gen(function* () {
yield* SessionEvent.All.match(event, {
"session.next.agent.switched": (event) => {
"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) => {
"model.selected": (event) => {
return adapter.appendMessage(
SessionMessage.ModelSwitched.make({
id: event.data.messageID,
SessionMessage.ModelSelected.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "model-switched",
metadata: event.metadata,
model: event.data.model,
time: { created: event.data.timestamp },
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,
renamed: () => Effect.void,
forked: () => Effect.void,
"prompt.promoted": () => Effect.void,
"prompt.admitted": () => Effect.void,
"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) => {
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) => {
"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) => {
"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 },
time: { created: event.created },
}),
)
},
"session.next.shell.ended": (event) => {
"shell.ended": (event) => {
return Effect.gen(function* () {
const currentShell = yield* adapter.getCurrentShell(event.data.callID)
if (currentShell) {
yield* adapter.updateShell(
produce(currentShell, (draft) => {
draft.output = event.data.output
draft.time.completed = event.data.timestamp
draft.time.completed = event.created
}),
)
}
})
},
"session.next.step.started": (event) => {
"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 +203,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) => {
"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 +224,33 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}
})
},
"session.next.step.failed": (event) => {
"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) => {
"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) => {
"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) => {
"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) => {
"tool.input.started": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.content.push(
castDraft(
@ -270,26 +258,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) => {
"tool.input.delta": () => Effect.void,
"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) => {
"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 +289,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}
})
},
"session.next.tool.progress": (event) => {
"tool.progress": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "running") {
@ -310,7 +298,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}
})
},
"session.next.tool.success": (event) => {
"tool.success": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "running") {
@ -319,7 +307,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 +321,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}
})
},
"session.next.tool.failed": (event) => {
"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 +330,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 +344,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}
})
},
"session.next.reasoning.started": (event) => {
"reasoning.started": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.content.push(
castDraft(
@ -365,47 +353,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) => {
"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) => {
"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) => {
retried: () => Effect.void,
"compaction.started": () => Effect.void,
"compaction.delta": () => Effect.void,
"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,
"revert.staged": () => Effect.void,
"revert.cleared": () => Effect.void,
"revert.committed": () => Effect.void,
})
})
}

View file

@ -25,7 +25,7 @@ import type { DeepMutable } from "../schema"
import { Slug } from "../util/slug"
type DatabaseService = Database.Interface["db"]
type MessageEvent = Exclude<SessionEvent.Event, typeof SessionEvent.Forked.Type>
type MessageEvent = Exclude<SessionEvent.DurableEvent, typeof SessionEvent.Forked.Type>
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
@ -157,22 +157,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 +205,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 +338,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
@ -360,7 +358,7 @@ function run(db: DatabaseService, event: MessageEvent) {
const adapter: SessionMessageUpdater.Adapter = {
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)
@ -417,8 +415,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 +436,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 +471,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,19 +553,19 @@ 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* 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)
@ -577,36 +575,43 @@ const layer = Layer.effectDiscard(
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 +619,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 +648,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 +657,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 +675,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 +695,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)

View file

@ -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,
})
})

View file

@ -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<void, RunError>

View file

@ -1,3 +1,5 @@
export * as SessionRunnerLLM from "./llm"
import {
LLM,
LLMClient,
@ -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<SessionSchema.ID>()
const forkTitle = yield* FiberSet.makeRuntime<never, void, never>()
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" },
@ -165,7 +166,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 +177,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 +231,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 = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
@ -281,7 +282,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<ReturnType<typeof publisher.stepSettlement>>) =>
Effect.gen(function* () {
@ -295,7 +296,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 +316,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 +325,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)
@ -346,12 +346,12 @@ const layer = Layer.effect(
if (questionDismissed || streamInterrupted || toolsInterrupted) {
yield* FiberSet.clear(toolFibers)
yield* serialized(publisher.failUnsettledTools("Tool execution interrupted"))
yield* serialized(publisher.failAssistant("Provider turn interrupted"))
yield* serialized(publisher.failAssistant("Step interrupted"))
// Match V1: dismissing a question halts the loop like an interruption.
if (questionDismissed) 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 +387,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 +399,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 +410,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 +423,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 +445,7 @@ const layer = Layer.effect(
}
})
return Service.of({ run })
return Service.of({ drain })
}),
)

View file

@ -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"

View file

@ -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<string, string[]>()
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":

View file

@ -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({

View file

@ -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<WorkspaceV2.ID>(),
parent_id: text().$type<SessionSchema.ID>(),
slug: text().notNull(),
directory: DatabasePath.directoryColumn().notNull(),
path: DatabasePath.pathColumn(),
directory: directoryColumn().notNull(),
path: pathColumn(),
title: text().notNull(),
version: text().notNull(),
share_url: text(),

View file

@ -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),
})
})

View file

@ -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")
}

View file

@ -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

View file

@ -97,8 +97,8 @@ export const Plugin = {
// 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({
@ -106,7 +106,7 @@ export const Plugin = {
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(

View file

@ -80,17 +80,21 @@ 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<string>()
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",
@ -168,7 +172,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.`,
)

View file

@ -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)`,

View file

@ -22,14 +22,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 +41,7 @@ const SyncMessage = EventV2.define({
},
})
const SyncSent = EventV2.define({
const SyncSent = EventV2.durable({
type: "test.sent",
durable: {
version: 1,
@ -53,14 +53,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 +129,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 },
@ -554,6 +554,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 +574,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 +611,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 +645,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 +654,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 +679,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 +696,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 +715,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 +723,7 @@ describe("EventV2", () => {
},
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
@ -735,6 +744,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 +752,7 @@ describe("EventV2", () => {
},
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
@ -751,6 +762,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 +770,7 @@ describe("EventV2", () => {
},
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 3,
aggregateID,
@ -793,6 +806,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 +826,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 +848,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 +883,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 +912,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 +923,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 +956,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 +969,7 @@ describe("EventV2", () => {
.replay(
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
@ -970,6 +991,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 +1012,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 +1037,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 +1047,7 @@ describe("EventV2", () => {
const exit = yield* events
.replay({
id,
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
@ -1045,6 +1070,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 +1081,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 +1143,7 @@ describe("EventV2", () => {
yield* events.replay({
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,

View file

@ -92,6 +92,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

View file

@ -0,0 +1,14 @@
import { describe, expect, test } from "bun:test"
import { MCP } from "@opencode-ai/core/mcp/index"
import { MCPClient } from "@opencode-ai/core/mcp/client"
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")
})
})

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