Merge remote-tracking branch 'origin/v2' into subagent-command

# Conflicts:
#	packages/core/src/config/plugin/command.ts
This commit is contained in:
Aiden Cline 2026-07-03 23:04:15 -05:00
commit 958e7f77f6
190 changed files with 4891 additions and 2758 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. - 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 `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. - 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 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 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. - 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. - 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 _Avoid_: System prompt
**Session History**: **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 _Avoid_: Session Context
**Context Source**: **Context Source**:
@ -31,13 +31,13 @@ The full **System Context** rendered at the start of a **Context Epoch**.
_Avoid_: Live system prompt _Avoid_: Live system prompt
**Context Snapshot**: **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**: **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. 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**: **Safe Step Boundary**:
The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically. 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**: **Admitted Prompt**:
A durable user input accepted into the Session inbox but not yet included in **Session History**. 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**: **Prompt Promotion**:
The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**. The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**.
**Provider Turn**: **Step**:
One request to a model provider and the response projected from that request. 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**: **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**: **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. 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**. - 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. - **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 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. - 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**. - 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. - 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**. - 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. - Context changes are sampled and admitted lazily at a **Safe Step 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**. - 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**. - 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. - **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 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. - 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.
- 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. - 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. - 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. - 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. - **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. - 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**. - `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. - `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. - **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**. - 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. - 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. - 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. - 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. - 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. - 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. - 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. - 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 Provider-Turn Boundary** loads and compares current values lazily. - 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 provider attempt fails and is replayed unchanged on retry. - 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. - **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. - 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 **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** 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. - 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. - 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 provider turn. - 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. 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. - **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. - **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. - **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`. - 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

@ -122,15 +122,14 @@
}, },
"packages/client": { "packages/client": {
"name": "@opencode-ai/client", "name": "@opencode-ai/client",
"version": "1.17.13",
"dependencies": { "dependencies": {
"@opencode-ai/protocol": "workspace:*", "@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
}, },
"devDependencies": { "devDependencies": {
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/httpapi-codegen": "workspace:*", "@opencode-ai/httpapi-codegen": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
@ -695,6 +694,7 @@
"version": "1.17.13", "version": "1.17.13",
"dependencies": { "dependencies": {
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/llm": "workspace:*", "@opencode-ai/llm": "workspace:*",
"@opencode-ai/protocol": "workspace:*", "@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",

View file

@ -177,7 +177,7 @@ export function DialogCustomProvider(props: Props) {
> >
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]"> <div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
<div class="px-2.5 flex gap-4 items-center"> <div class="px-2.5 flex gap-4 items-center">
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" /> <ProviderIcon id="session.synthetic" class="size-5 shrink-0 icon-strong-base" />
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div> <div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
</div> </div>

View file

@ -226,7 +226,7 @@ const SettingsProvidersContent: Component = () => {
> >
<div class="flex flex-col min-w-0"> <div class="flex flex-col min-w-0">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1"> <div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" /> <ProviderIcon id="session.synthetic" class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span> <span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
<Tag>{language.t("settings.providers.tag.custom")}</Tag> <Tag>{language.t("settings.providers.tag.custom")}</Tag>
</div> </div>

View file

@ -223,7 +223,7 @@ export const SettingsProvidersV2: Component = () => {
<div class="settings-v2-provider-row" data-component="custom-provider-section"> <div class="settings-v2-provider-row" data-component="custom-provider-section">
<div class="settings-v2-provider-lead"> <div class="settings-v2-provider-lead">
<ProviderIcon <ProviderIcon
id="synthetic" id="session.synthetic"
width={PROVIDER_ICON_SIZE} width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE} height={PROVIDER_ICON_SIZE}
class="settings-v2-provider-icon shrink-0" class="settings-v2-provider-icon shrink-0"

View file

@ -7,7 +7,7 @@ describe("file watcher invalidation", () => {
const refresh: string[] = [] const refresh: string[] = []
invalidateFromWatcher( invalidateFromWatcher(
{ {
type: "file.watcher.updated", type: "filesystem.changed",
properties: { properties: {
file: "src/new.ts", file: "src/new.ts",
event: "add", event: "add",
@ -32,7 +32,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher( invalidateFromWatcher(
{ {
type: "file.watcher.updated", type: "filesystem.changed",
properties: { properties: {
file: "src/open.ts", file: "src/open.ts",
event: "change", event: "change",
@ -63,7 +63,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher( invalidateFromWatcher(
{ {
type: "file.watcher.updated", type: "filesystem.changed",
properties: { properties: {
file: "src", file: "src",
event: "change", event: "change",
@ -81,7 +81,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher( invalidateFromWatcher(
{ {
type: "file.watcher.updated", type: "filesystem.changed",
properties: { properties: {
file: "src/file.ts", file: "src/file.ts",
event: "change", event: "change",
@ -111,7 +111,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher( invalidateFromWatcher(
{ {
type: "file.watcher.updated", type: "filesystem.changed",
properties: { properties: {
file: ".git/index.lock", file: ".git/index.lock",
event: "change", event: "change",

View file

@ -16,7 +16,7 @@ type WatcherOps = {
} }
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) { export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
if (event.type !== "file.watcher.updated") return if (event.type !== "filesystem.changed") return
const props = const props =
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
const rawPath = typeof props?.file === "string" ? props.file : undefined const rawPath = typeof props?.file === "string" ? props.file : undefined

View file

@ -820,7 +820,7 @@ export default function Page() {
) )
const stopVcs = sdk().event.listen((evt) => { const stopVcs = sdk().event.listen((evt) => {
if (evt.details.type !== "file.watcher.updated") return if (evt.details.type !== "filesystem.changed") return
const props = const props =
typeof evt.details.properties === "object" && evt.details.properties typeof evt.details.properties === "object" && evt.details.properties
? (evt.details.properties as Record<string, unknown>) ? (evt.details.properties as Record<string, unknown>)

View file

@ -3,6 +3,7 @@ import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Project } from "@opencode-ai/core/project"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Context, Effect, FileSystem, Layer, Option, Redacted, Schedule, Schema } from "effect" import { Context, Effect, FileSystem, Layer, Option, Redacted, Schedule, Schema } from "effect"
import { HttpRouter, HttpServer } from "effect/unstable/http" import { HttpRouter, HttpServer } from "effect/unstable/http"
@ -142,9 +143,9 @@ function listen(hostname: string, port: Option.Option<number>, password: string)
function bind(hostname: string, port: number, password: string) { function bind(hostname: string, port: number, password: string) {
const server = createServer() const server = createServer()
return Layer.build( 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.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( ).pipe(
Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))), Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))),

View file

@ -47,6 +47,7 @@ Effect.logInfo("cli starting", {
version: InstallationVersion, version: InstallationVersion,
channel: InstallationChannel, channel: InstallationChannel,
local: InstallationLocal, local: InstallationLocal,
args: process.argv.slice(2),
}).pipe( }).pipe(
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })), Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
Effect.annotateLogs({ role: "cli" }), Effect.annotateLogs({ role: "cli" }),

View file

@ -1,16 +1,30 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/client", "name": "@opencode-ai/client",
"private": true, "version": "1.17.13",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/anomalyco/opencode.git",
"directory": "packages/client"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"exports": { "exports": {
"./promise": "./src/promise/index.ts", "./promise": "./src/promise/index.ts",
"./effect": "./src/effect/index.ts" "./promise/api": "./src/promise/api.ts",
"./effect": "./src/effect/index.ts",
"./effect/api": "./src/effect/api.ts"
}, },
"scripts": { "scripts": {
"build": "bun run script/build-package.ts",
"generate": "bun run script/build.ts", "generate": "bun run script/build.ts",
"check:generated": "bun run generate && git diff --exit-code -- src/promise/generated src/effect/generated", "check:generated": "bun run generate && git diff --exit-code -- src/promise/generated src/effect/generated src/effect/api",
"test": "bun test --timeout 5000", "test": "bun test --timeout 5000",
"typecheck": "tsgo --noEmit" "typecheck": "tsgo --noEmit"
}, },
@ -28,9 +42,7 @@
}, },
"devDependencies": { "devDependencies": {
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/httpapi-codegen": "workspace:*", "@opencode-ai/httpapi-codegen": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",

View file

@ -0,0 +1,9 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
await $`rm -rf dist`
await $`bun tsc -p tsconfig.build.json`

View file

@ -32,8 +32,8 @@ await Effect.runPromise(
fileURLToPath(new URL("../src/effect/generated", import.meta.url)), fileURLToPath(new URL("../src/effect/generated", import.meta.url)),
), ),
write( write(
emitEffectShape(effectContract, { module: "@opencode-ai/protocol/client", api: "ClientApi" }), emitEffectShape(effectContract, { module: "../../contract", api: "ClientApi" }),
fileURLToPath(new URL("../../plugin/src/v2/effect/generated", import.meta.url)), fileURLToPath(new URL("../src/effect/api", import.meta.url)),
), ),
], ],
{ concurrency: 3, discard: true }, { concurrency: 3, discard: true },

View file

@ -0,0 +1,45 @@
#!/usr/bin/env bun
import { Script } from "@opencode-ai/script"
import { $ } from "bun"
import { rm } from "node:fs/promises"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
const originalText = await Bun.file("package.json").text()
const pkg = JSON.parse(originalText) as {
name: string
version: string
exports: Record<string, string | { import: string; types: string }>
}
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
console.log(`already published ${pkg.name}@${pkg.version}`)
process.exit(0)
}
try {
await $`bun run typecheck`
await $`bun run build`
pkg.exports = Object.fromEntries(
Object.entries(pkg.exports).map(([key, value]) => {
if (typeof value !== "string") return [key, value]
return [
key,
{
import: value.replace("./src/", "./dist/").replace(/\.ts$/, ".js"),
types: value.replace("./src/", "./dist/").replace(/\.ts$/, ".d.ts"),
},
]
}),
)
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
await rm(tarball, { force: true })
await $`bun pm pack`
await $`npm publish ${tarball} --tag ${Script.channel} --access public`
} finally {
await Bun.write("package.json", originalText)
await rm(tarball, { force: true })
}

View file

@ -0,0 +1,8 @@
import type { ModelApi, ProviderApi } from "./api/api.js"
export type * from "./api/api.js"
export interface CatalogApi<E = never> {
readonly provider: ProviderApi<E>
readonly model: ModelApi<E>
}

View file

@ -1,7 +1,7 @@
// Generated by @opencode-ai/httpapi-codegen. Do not edit. // Generated by @opencode-ai/httpapi-codegen. Do not edit.
import type { Effect, Stream } from "effect" import type { Effect, Stream } from "effect"
import type { HttpApiClient } from "effect/unstable/httpapi" import type { HttpApiClient } from "effect/unstable/httpapi"
import type { ClientApi } from "@opencode-ai/protocol/client" import type { ClientApi } from "../../contract"
type RawClient = HttpApiClient.ForApi<typeof ClientApi> type RawClient = HttpApiClient.ForApi<typeof ClientApi>
type EffectValue<A> = A extends Effect.Effect<infer Success, any, any> ? Success : never type EffectValue<A> = A extends Effect.Effect<infer Success, any, any> ? Success : never

View file

@ -1,6 +1,22 @@
// TODO: Keep additional network capabilities inside Schema and Protocol as the client grows; /effect must never import // TODO: Keep additional network capabilities inside Schema and Protocol as the client grows; /effect must never import
// Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations. // Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations.
import type { Effect } from "effect"
export * from "./generated/index" export * from "./generated/index"
export type {
AgentApi,
AppApi,
CatalogApi,
CommandApi,
EventApi,
IntegrationApi,
ModelApi,
PluginApi,
ProviderApi,
ReferenceApi,
SessionApi,
SkillApi,
} from "./api.js"
export { Service } from "./service.js" export { Service } from "./service.js"
export { Agent } from "@opencode-ai/schema/agent" export { Agent } from "@opencode-ai/schema/agent"
export { Command } from "@opencode-ai/schema/command" export { Command } from "@opencode-ai/schema/command"
@ -27,3 +43,4 @@ export { SessionMessage } from "@opencode-ai/schema/session-message"
export { Skill } from "@opencode-ai/schema/skill" export { Skill } from "@opencode-ai/schema/skill"
export { Prompt } from "@opencode-ai/schema/prompt" export { Prompt } from "@opencode-ai/schema/prompt"
export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event" export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
export type OpenCodeClient = Effect.Success<ReturnType<typeof import("./generated/client").make>>

View file

@ -0,0 +1,41 @@
import type {
AgentApi as EffectAgentApi,
CommandApi as EffectCommandApi,
EventApi as EffectEventApi,
IntegrationApi as EffectIntegrationApi,
ModelApi as EffectModelApi,
PluginApi as EffectPluginApi,
ProviderApi as EffectProviderApi,
ReferenceApi as EffectReferenceApi,
SessionApi as EffectSessionApi,
SkillApi as EffectSkillApi,
} from "../effect/api/api.js"
import type { Effect, Stream } from "effect"
type PromisifyOperation<Operation> = Operation extends (
...args: infer Args
) => Effect.Effect<infer Success, unknown, unknown>
? (...args: Args) => Promise<Success>
: Operation extends (...args: infer Args) => Stream.Stream<infer Success, unknown, unknown>
? (...args: Args) => AsyncIterable<Success>
: Operation
type PromisifyApi<Api> = {
readonly [Name in keyof Api]: PromisifyOperation<Api[Name]>
}
export type AgentApi = PromisifyApi<EffectAgentApi<unknown>>
export type CommandApi = PromisifyApi<EffectCommandApi<unknown>>
export type EventApi = PromisifyApi<EffectEventApi<unknown>>
export type IntegrationApi = PromisifyApi<EffectIntegrationApi<unknown>>
export type ModelApi = PromisifyApi<EffectModelApi<unknown>>
export type PluginApi = PromisifyApi<EffectPluginApi<unknown>>
export type ProviderApi = PromisifyApi<EffectProviderApi<unknown>>
export type ReferenceApi = PromisifyApi<EffectReferenceApi<unknown>>
export type SessionApi = PromisifyApi<EffectSessionApi<unknown>>
export type SkillApi = PromisifyApi<EffectSkillApi<unknown>>
export interface CatalogApi {
readonly provider: ProviderApi
readonly model: ModelApi
}

View file

@ -976,9 +976,27 @@ export type SessionContextOutput = {
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number; readonly completed?: number } readonly time: { readonly created: number; readonly completed?: number }
readonly type: "shell" readonly type: "shell"
readonly callID: string readonly shell: {
readonly command: string readonly id: string
readonly output: string readonly status: "running" | "exited" | "timeout" | "killed"
readonly command: string
readonly cwd: string
readonly shell: string
readonly file: string
readonly pid?: number
readonly exit?: number | "Infinity" | "-Infinity" | "NaN"
readonly metadata: { readonly [x: string]: JsonValue }
readonly time: {
readonly started: number | "Infinity" | "-Infinity" | "NaN"
readonly completed?: number | "Infinity" | "-Infinity" | "NaN"
}
}
readonly output?: {
readonly output: string
readonly cursor: number
readonly size: number
readonly truncated: boolean
}
} }
| { | {
readonly id: string readonly id: string
@ -1109,7 +1127,7 @@ export type SessionLogOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "agent.selected" readonly type: "session.agent.selected"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly agent: string } readonly data: { readonly sessionID: string; readonly agent: string }
@ -1118,7 +1136,7 @@ export type SessionLogOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "model.selected" readonly type: "session.model.selected"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -1143,7 +1161,7 @@ export type SessionLogOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "renamed" readonly type: "session.renamed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly title: string } readonly data: { readonly sessionID: string; readonly title: string }
@ -1152,7 +1170,7 @@ export type SessionLogOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "forked" readonly type: "session.forked"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly parentID: string; readonly from?: string } readonly data: { readonly sessionID: string; readonly parentID: string; readonly from?: string }
@ -1161,7 +1179,7 @@ export type SessionLogOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "prompt.promoted" readonly type: "session.prompt.promoted"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly inputID: string } readonly data: { readonly sessionID: string; readonly inputID: string }
@ -1170,7 +1188,7 @@ export type SessionLogOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "prompt.admitted" readonly type: "session.prompt.admitted"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -1206,7 +1224,7 @@ export type SessionLogOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "synthetic" readonly type: "session.synthetic"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -1220,7 +1238,7 @@ export type SessionLogOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "skill.activated" readonly type: "session.skill.activated"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly name: string; readonly text: string } readonly data: { readonly sessionID: string; readonly name: string; readonly text: string }
@ -1229,25 +1247,59 @@ export type SessionLogOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "shell.started" readonly type: "session.shell.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly callID: string; readonly command: string } readonly data: {
readonly sessionID: string
readonly shell: {
readonly id: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly command: string
readonly cwd: string
readonly shell: string
readonly file: string
readonly pid?: number
readonly exit?: number
readonly metadata: { readonly [x: string]: unknown }
readonly time: { readonly started: number; readonly completed?: number }
}
}
} }
| { | {
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "shell.ended" readonly type: "session.shell.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly callID: string; readonly output: string } readonly data: {
readonly sessionID: string
readonly shell: {
readonly id: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly command: string
readonly cwd: string
readonly shell: string
readonly file: string
readonly pid?: number
readonly exit?: number
readonly metadata: { readonly [x: string]: unknown }
readonly time: { readonly started: number; readonly completed?: number }
}
readonly output: {
readonly output: string
readonly cursor: number
readonly size: number
readonly truncated: boolean
}
}
} }
| { | {
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "step.started" readonly type: "session.step.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -1262,7 +1314,7 @@ export type SessionLogOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "step.ended" readonly type: "session.step.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -1284,7 +1336,7 @@ export type SessionLogOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "step.failed" readonly type: "session.step.failed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -1297,7 +1349,7 @@ export type SessionLogOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "text.started" readonly type: "session.text.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly textID: string } readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly textID: string }
@ -1306,7 +1358,7 @@ export type SessionLogOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "text.ended" readonly type: "session.text.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -1320,7 +1372,7 @@ export type SessionLogOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "reasoning.started" readonly type: "session.reasoning.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -1334,7 +1386,7 @@ export type SessionLogOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "reasoning.ended" readonly type: "session.reasoning.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -1349,7 +1401,7 @@ export type SessionLogOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.input.started" readonly type: "session.tool.input.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -1363,7 +1415,7 @@ export type SessionLogOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.input.ended" readonly type: "session.tool.input.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -1377,7 +1429,7 @@ export type SessionLogOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.called" readonly type: "session.tool.called"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -1396,7 +1448,7 @@ export type SessionLogOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.progress" readonly type: "session.tool.progress"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -1414,7 +1466,7 @@ export type SessionLogOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.success" readonly type: "session.tool.success"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -1438,7 +1490,7 @@ export type SessionLogOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.failed" readonly type: "session.tool.failed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -1457,7 +1509,7 @@ export type SessionLogOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "retried" readonly type: "session.retried"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -1477,7 +1529,7 @@ export type SessionLogOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "compaction.started" readonly type: "session.compaction.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly reason: "auto" | "manual" } readonly data: { readonly sessionID: string; readonly reason: "auto" | "manual" }
@ -1486,7 +1538,7 @@ export type SessionLogOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "compaction.ended" readonly type: "session.compaction.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -1500,7 +1552,7 @@ export type SessionLogOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "revert.staged" readonly type: "session.revert.staged"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -1524,7 +1576,7 @@ export type SessionLogOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "revert.cleared" readonly type: "session.revert.cleared"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string } readonly data: { readonly sessionID: string }
@ -1533,7 +1585,7 @@ export type SessionLogOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "revert.committed" readonly type: "session.revert.committed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly messageID: string } readonly data: { readonly sessionID: string; readonly messageID: string }
@ -1617,9 +1669,27 @@ export type SessionMessageOutput = {
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number; readonly completed?: number } readonly time: { readonly created: number; readonly completed?: number }
readonly type: "shell" readonly type: "shell"
readonly callID: string readonly shell: {
readonly command: string readonly id: string
readonly output: string readonly status: "running" | "exited" | "timeout" | "killed"
readonly command: string
readonly cwd: string
readonly shell: string
readonly file: string
readonly pid?: number
readonly exit?: number | "Infinity" | "-Infinity" | "NaN"
readonly metadata: { readonly [x: string]: JsonValue }
readonly time: {
readonly started: number | "Infinity" | "-Infinity" | "NaN"
readonly completed?: number | "Infinity" | "-Infinity" | "NaN"
}
}
readonly output?: {
readonly output: string
readonly cursor: number
readonly size: number
readonly truncated: boolean
}
} }
| { | {
readonly id: string readonly id: string
@ -1798,9 +1868,27 @@ export type MessageListOutput = {
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number; readonly completed?: number } readonly time: { readonly created: number; readonly completed?: number }
readonly type: "shell" readonly type: "shell"
readonly callID: string readonly shell: {
readonly command: string readonly id: string
readonly output: string readonly status: "running" | "exited" | "timeout" | "killed"
readonly command: string
readonly cwd: string
readonly shell: string
readonly file: string
readonly pid?: number
readonly exit?: number | "Infinity" | "-Infinity" | "NaN"
readonly metadata: { readonly [x: string]: JsonValue }
readonly time: {
readonly started: number | "Infinity" | "-Infinity" | "NaN"
readonly completed?: number | "Infinity" | "-Infinity" | "NaN"
}
}
readonly output?: {
readonly output: string
readonly cursor: number
readonly size: number
readonly truncated: boolean
}
} }
| { | {
readonly id: string readonly id: string
@ -2339,7 +2427,7 @@ export type ServerMcpListOutput = {
readonly name: string readonly name: string
readonly status: readonly status:
| { readonly status: "connected" } | { readonly status: "connected" }
| { readonly status: "disconnected" } | { readonly status: "pending" }
| { readonly status: "disabled" } | { readonly status: "disabled" }
| { readonly status: "failed"; readonly error: string } | { readonly status: "failed"; readonly error: string }
| { readonly status: "needs_auth" } | { readonly status: "needs_auth" }
@ -4309,7 +4397,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "agent.selected" readonly type: "session.agent.selected"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly agent: string } readonly data: { readonly sessionID: string; readonly agent: string }
@ -4318,7 +4406,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "model.selected" readonly type: "session.model.selected"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -4343,7 +4431,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "renamed" readonly type: "session.renamed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly title: string } readonly data: { readonly sessionID: string; readonly title: string }
@ -4352,7 +4440,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "forked" readonly type: "session.forked"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly parentID: string; readonly from?: string } readonly data: { readonly sessionID: string; readonly parentID: string; readonly from?: string }
@ -4361,7 +4449,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "prompt.promoted" readonly type: "session.prompt.promoted"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly inputID: string } readonly data: { readonly sessionID: string; readonly inputID: string }
@ -4370,7 +4458,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "prompt.admitted" readonly type: "session.prompt.admitted"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -4397,7 +4485,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "execution.settled" readonly type: "session.execution.settled"
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
readonly sessionID: string readonly sessionID: string
@ -4418,7 +4506,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "synthetic" readonly type: "session.synthetic"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -4432,7 +4520,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "skill.activated" readonly type: "session.skill.activated"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly name: string; readonly text: string } readonly data: { readonly sessionID: string; readonly name: string; readonly text: string }
@ -4441,25 +4529,59 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "shell.started" readonly type: "session.shell.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly callID: string; readonly command: string } readonly data: {
readonly sessionID: string
readonly shell: {
readonly id: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly command: string
readonly cwd: string
readonly shell: string
readonly file: string
readonly pid?: number
readonly exit?: number
readonly metadata: { readonly [x: string]: unknown }
readonly time: { readonly started: number; readonly completed?: number }
}
}
} }
| { | {
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "shell.ended" readonly type: "session.shell.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly callID: string; readonly output: string } readonly data: {
readonly sessionID: string
readonly shell: {
readonly id: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly command: string
readonly cwd: string
readonly shell: string
readonly file: string
readonly pid?: number
readonly exit?: number
readonly metadata: { readonly [x: string]: unknown }
readonly time: { readonly started: number; readonly completed?: number }
}
readonly output: {
readonly output: string
readonly cursor: number
readonly size: number
readonly truncated: boolean
}
}
} }
| { | {
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "step.started" readonly type: "session.step.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -4474,7 +4596,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "step.ended" readonly type: "session.step.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -4496,7 +4618,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "step.failed" readonly type: "session.step.failed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -4509,7 +4631,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "text.started" readonly type: "session.text.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly textID: string } readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly textID: string }
@ -4518,7 +4640,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "text.delta" readonly type: "session.text.delta"
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
readonly sessionID: string readonly sessionID: string
@ -4531,7 +4653,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "text.ended" readonly type: "session.text.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -4545,7 +4667,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "reasoning.started" readonly type: "session.reasoning.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -4559,7 +4681,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "reasoning.delta" readonly type: "session.reasoning.delta"
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
readonly sessionID: string readonly sessionID: string
@ -4572,7 +4694,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "reasoning.ended" readonly type: "session.reasoning.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -4587,7 +4709,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.input.started" readonly type: "session.tool.input.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -4601,7 +4723,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.input.delta" readonly type: "session.tool.input.delta"
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
readonly sessionID: string readonly sessionID: string
@ -4614,7 +4736,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.input.ended" readonly type: "session.tool.input.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -4628,7 +4750,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.called" readonly type: "session.tool.called"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -4647,7 +4769,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.progress" readonly type: "session.tool.progress"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -4665,7 +4787,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.success" readonly type: "session.tool.success"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -4689,7 +4811,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.failed" readonly type: "session.tool.failed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -4708,7 +4830,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "retried" readonly type: "session.retried"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -4728,7 +4850,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "compaction.started" readonly type: "session.compaction.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly reason: "auto" | "manual" } readonly data: { readonly sessionID: string; readonly reason: "auto" | "manual" }
@ -4737,7 +4859,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "compaction.delta" readonly type: "session.compaction.delta"
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly text: string } readonly data: { readonly sessionID: string; readonly text: string }
} }
@ -4745,7 +4867,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "compaction.ended" readonly type: "session.compaction.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -4759,7 +4881,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "revert.staged" readonly type: "session.revert.staged"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
@ -4783,7 +4905,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "revert.cleared" readonly type: "session.revert.cleared"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string } readonly data: { readonly sessionID: string }
@ -4792,7 +4914,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "revert.committed" readonly type: "session.revert.committed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly messageID: string } readonly data: { readonly sessionID: string; readonly messageID: string }
@ -4801,9 +4923,9 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "file.edited" readonly type: "filesystem.changed"
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly file: string } readonly data: { readonly file: string; readonly event: "add" | "change" | "unlink" }
} }
| { | {
readonly id: string readonly id: string
@ -4869,7 +4991,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "skill.updated" readonly type: "config.updated"
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {} readonly data: {}
} }
@ -4877,9 +4999,9 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "file.watcher.updated" readonly type: "skill.updated"
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly file: string; readonly event: "add" | "change" | "unlink" } readonly data: {}
} }
| { | {
readonly id: string readonly id: string

View file

@ -1,3 +1,16 @@
export * from "./generated/index" export * from "./generated/index"
export type {
AgentApi,
CatalogApi,
CommandApi,
EventApi,
IntegrationApi,
ModelApi,
PluginApi,
ProviderApi,
ReferenceApi,
SessionApi,
SkillApi,
} from "./api.js"
export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types" export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types"
export type OpenCodeClient = ReturnType<typeof import("./generated/client").make> export type OpenCodeClient = ReturnType<typeof import("./generated/client").make>

View file

@ -0,0 +1,10 @@
import { Effect } from "effect"
import { OpenCode as EffectOpenCode, type AppApi as EffectApi } from "../src/effect"
type EffectClient = Effect.Success<ReturnType<typeof EffectOpenCode.make>>
declare const effectClient: EffectClient
const effectApi: EffectApi<unknown> = effectClient
void effectApi

View file

@ -45,9 +45,9 @@ test("event.subscribe exposes and decodes the native Effect event stream", async
return yield* client.event.subscribe().pipe(Stream.runCollect) return yield* client.event.subscribe().pipe(Stream.runCollect)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "model.selected"]) expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.model.selected"])
const durable = events[1] const durable = events[1]
if (durable?.type !== "model.selected") throw new Error("Expected model event") if (durable?.type !== "session.model.selected") throw new Error("Expected model event")
expect(DateTime.toEpochMillis(durable.created)).toBe(1_717_171_717_000) expect(DateTime.toEpochMillis(durable.created)).toBe(1_717_171_717_000)
expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 }) expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 })
}) })
@ -159,8 +159,8 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
expect(result.context).toEqual([]) expect(result.context).toEqual([])
expect(logQueries[0]).toEqual({ after: "0" }) expect(logQueries[0]).toEqual({ after: "0" })
const logged = Array.from(result.log) const logged = Array.from(result.log)
expect(logged.map((item) => item.type)).toEqual(["model.selected", "log.synced"]) expect(logged.map((item) => item.type)).toEqual(["session.model.selected", "log.synced"])
expect(logged[0]?.type === "model.selected" && DateTime.toEpochMillis(logged[0].created)).toBe( expect(logged[0]?.type === "session.model.selected" && DateTime.toEpochMillis(logged[0].created)).toBe(
1_717_171_717_000, 1_717_171_717_000,
) )
expect(logged.at(-1)).toEqual(synced) expect(logged.at(-1)).toEqual(synced)
@ -228,7 +228,7 @@ const modelSwitchedMessage = {
const modelSwitchedEvent = { const modelSwitchedEvent = {
id: "evt_model", id: "evt_model",
created: 1_717_171_717_000, created: 1_717_171_717_000,
type: "model.selected", type: "session.model.selected",
durable: { aggregateID: "ses_test", seq: 1, version: 1 }, durable: { aggregateID: "ses_test", seq: 1, version: 1 },
data: { data: {
sessionID: "ses_test", sessionID: "ses_test",

View file

@ -160,7 +160,7 @@ test("event.subscribe exposes the Promise event stream wire projection", async (
for await (const event of client.event.subscribe()) events.push(event) for await (const event of client.event.subscribe()) events.push(event)
expect(events).toEqual([{ id: "evt_connected", created: 0, type: "server.connected", data: {} }, modelSwitchedEvent]) expect(events).toEqual([{ id: "evt_connected", created: 0, type: "server.connected", data: {} }, modelSwitchedEvent])
expect(events[1]?.type === "model.selected" && events[1].created).toBe(1_717_171_717_000) expect(events[1]?.type === "session.model.selected" && events[1].created).toBe(1_717_171_717_000)
}) })
test("event.subscribe terminates on malformed Promise SSE data", async () => { test("event.subscribe terminates on malformed Promise SSE data", async () => {
@ -329,7 +329,7 @@ const synced = { type: "log.synced", aggregateID: "ses_test", seq: 1 }
const modelSwitchedEvent = { const modelSwitchedEvent = {
id: "evt_model", id: "evt_model",
created: 1_717_171_717_000, created: 1_717_171_717_000,
type: "model.selected", type: "session.model.selected",
durable: { aggregateID: "ses_test", seq: 1, version: 1 }, durable: { aggregateID: "ses_test", seq: 1, version: 1 },
data: { data: {
sessionID: "ses_test", sessionID: "ses_test",

View file

@ -0,0 +1,11 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"noEmit": false,
"declaration": true
},
"include": ["src"]
}

View file

@ -3,6 +3,8 @@
"extends": "@tsconfig/bun/tsconfig.json", "extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"], "lib": ["ESNext", "DOM", "DOM.Iterable"],
"allowImportingTsExtensions": false,
"allowJs": false,
"noUncheckedIndexedAccess": false "noUncheckedIndexedAccess": false
}, },
"include": ["src"] "include": ["src"]

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 // 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 // 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 - // catalog at the bottom. Example call forms use placeholders - never a real or fabricated
// never a real or fabricated tool name. // tool name - and show both dot and bracket notation so non-identifier names are not normalized.
const intro = [ const intro = [
"Write a CodeMode program to answer the request. Return code only.", "Write a CodeMode program to answer the request. Return code only.",
empty empty
@ -467,6 +467,7 @@ export const discoveryPlan = <R>(
: complete : 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 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.", : "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 // The search step exists only when search is advertised (PARTIAL catalog); a COMPLETE
@ -480,14 +481,14 @@ export const discoveryPlan = <R>(
...(complete ...(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.", "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.', '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.", "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.", "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.', '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.", "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.", : "- 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.", "- Filter, aggregate, and transform collections in code - never return them raw or call a tool per item across messages.",
"- A result typed `Promise<unknown>` has no guaranteed shape - verify what actually came back before relying on its fields.", "- A result typed `Promise<unknown>` 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.", "- `Object.keys(tools)` lists namespaces; `Object.keys(tools.<namespace>)` lists its tools; `for...in` works on both.",
...(complete ...(complete
? [] ? []

View file

@ -622,12 +622,12 @@ describe("CodeMode public contract", () => {
) )
expect(instructions).toContain("Return only the fields you need") expect(instructions).toContain("Return only the fields you need")
expect(instructions).toContain("raw payloads get truncated and waste context") 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("surrounding agent tools are not available unless listed here")
expect(instructions).toContain("Only tools listed here are available inside `tools`") 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 generic namespace/tool/field names only - no fabricated real tools
// Placeholders use the <namespace>.<tool>/<field> style ONLY - no fabricated tool // and no real catalog tools cherry-picked into example lines.
// names, and no real catalog tools cherry-picked into example lines.
expect(instructions).toContain("`return { <field>: data.<field> }`") expect(instructions).toContain("`return { <field>: data.<field> }`")
expect(instructions).not.toContain("total_count") expect(instructions).not.toContain("total_count")
expect(instructions).not.toContain("list_issues") 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 // 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. // a query string, never a tool name) and the browse-namespace rule appears.
expect(partial).toContain( 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( expect(partial).toContain(
"Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`", "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("/**") 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,12 +1,11 @@
export * as Catalog from "./catalog" export * as Catalog from "./catalog"
import { makeLocationNode } from "./effect/app-node" import { makeLocationNode } from "./effect/app-node"
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect" import { Array, Context, Effect, Layer, Option, Order, pipe } from "effect"
import { Catalog } from "@opencode-ai/schema/catalog" import { Catalog } from "@opencode-ai/schema/catalog"
import { ModelV2 } from "./model" import { ModelV2 } from "./model"
import { ProviderV2 } from "./provider" import { ProviderV2 } from "./provider"
import { EventV2 } from "./event" import { EventV2 } from "./event"
import { Policy } from "./policy"
import { State } from "./state" import { State } from "./state"
import { Integration } from "./integration" import { Integration } from "./integration"
@ -17,8 +16,6 @@ export type ProviderRecord = {
export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID } export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID }
export const PolicyActions = Schema.Literals(["provider.use"])
export const Event = Catalog.Event export const Event = Catalog.Event
type Data = { type Data = {
@ -65,7 +62,6 @@ const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const events = yield* EventV2.Service const events = yield* EventV2.Service
const policy = yield* Policy.Service
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => { const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => {
@ -159,13 +155,6 @@ const layer = Layer.effect(
return result return result
}, },
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) { finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) {
if (policy.hasStatements()) {
for (const record of [...catalog.provider.list()]) {
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
catalog.provider.remove(record.provider.id)
}
}
}
yield* events.publish(Event.Updated, {}) yield* events.publish(Event.Updated, {})
}), }),
}) })
@ -294,4 +283,4 @@ const layer = Layer.effect(
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/ const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Policy.node, Integration.node] }) export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Integration.node] })

View file

@ -3,18 +3,19 @@ export * as Config from "./config"
import { makeLocationNode } from "./effect/app-node" import { makeLocationNode } from "./effect/app-node"
import path from "path" import path from "path"
import { type ParseError, parse } from "jsonc-parser" import { type ParseError, parse } from "jsonc-parser"
import { Context, Effect, Layer, Option, Schema } from "effect" import { Context, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Permission } from "@opencode-ai/schema/permission" import { Permission } from "@opencode-ai/schema/permission"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { EventV2 } from "./event"
import { Watcher } from "./filesystem/watcher"
import { FSUtil } from "./fs-util" import { FSUtil } from "./fs-util"
import { Global } from "./global" import { Global } from "./global"
import { Location } from "./location" import { Location } from "./location"
import { Policy } from "./policy"
import { AbsolutePath } from "./schema" import { AbsolutePath } from "./schema"
import { ConfigAgent } from "./config/agent" import { ConfigAgent } from "./config/agent"
import { ConfigAttachments } from "./config/attachments" import { ConfigAttachments } from "./config/attachments"
import { ConfigCompaction } from "./config/compaction" import { ConfigCompaction } from "./config/compaction"
import { ConfigCommand } from "./config/command" import { ConfigCommand } from "./config/command"
import { ConfigExperimental } from "./config/experimental"
import { ConfigFormatter } from "./config/formatter" import { ConfigFormatter } from "./config/formatter"
import { ConfigLSP } from "./config/lsp" import { ConfigLSP } from "./config/lsp"
import { ConfigMCP } from "./config/mcp" import { ConfigMCP } from "./config/mcp"
@ -22,6 +23,7 @@ import { ConfigPlugin } from "./config/plugin"
import { ConfigProvider } from "./config/provider" import { ConfigProvider } from "./config/provider"
import { ConfigReference } from "./config/reference" import { ConfigReference } from "./config/reference"
import { ConfigToolOutput } from "./config/tool-output" import { ConfigToolOutput } from "./config/tool-output"
import { ConfigVariable } from "./config/variable"
import { ConfigWatcher } from "./config/watcher" import { ConfigWatcher } from "./config/watcher"
import { ConfigV1 } from "./v1/config/config" import { ConfigV1 } from "./v1/config/config"
import { ConfigMigrateV1 } from "./v1/config/migrate" import { ConfigMigrateV1 } from "./v1/config/migrate"
@ -102,7 +104,6 @@ export class Info extends Schema.Class<Info>("Config.Info")({
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({ plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
description: "Ordered external plugin packages to load", description: "Ordered external plugin packages to load",
}), }),
experimental: ConfigExperimental.Experimental.pipe(Schema.optional),
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional), providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
}) {} }) {}
@ -138,7 +139,8 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const global = yield* Global.Service const global = yield* Global.Service
const location = yield* Location.Service const location = yield* Location.Service
const policy = yield* Policy.Service const watcher = yield* Watcher.Service
const events = yield* EventV2.Service
const names = ["opencode.json", "opencode.jsonc"] const names = ["opencode.json", "opencode.jsonc"]
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions) const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
@ -147,9 +149,10 @@ const layer = Layer.effect(
const loadFile = Effect.fnUntraced(function* (filepath: string) { const loadFile = Effect.fnUntraced(function* (filepath: string) {
const text = yield* fs.readFileStringSafe(filepath) const text = yield* fs.readFileStringSafe(filepath)
if (!text) return if (!text) return
const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
const errors: ParseError[] = [] const errors: ParseError[] = []
const input: unknown = parse(text, errors, { allowTrailingComma: true }) const input: unknown = parse(substituted, errors, { allowTrailingComma: true })
if (errors.length) return if (errors.length) return
const info = Option.getOrUndefined( const info = Option.getOrUndefined(
@ -170,45 +173,78 @@ const layer = Layer.effect(
] ]
}) })
const globalDirectory = AbsolutePath.make(global.config) const discover = Effect.fn("Config.discover")(function* () {
const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config) const globalDirectory = AbsolutePath.make(global.config)
// Read configuration once when this location opens. Later calls reuse these const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config)
// values until the location is reopened. const discovered = locationIsGlobal
const discovered = locationIsGlobal ? []
? [] : yield* fs
: yield* fs .up({
.up({ targets: [".opencode", ...names.toReversed()],
targets: [".opencode", ...names.toReversed()], start: location.directory,
start: location.directory, stop: location.project.directory,
stop: location.project.directory, })
}) .pipe(Effect.orDie)
.pipe(Effect.orDie) const directories = [
const directories = [ globalDirectory,
globalDirectory, ...discovered
...discovered .filter((item) => path.basename(item) === ".opencode")
.filter((item) => path.basename(item) === ".opencode") .toReversed()
.toReversed() .map((directory) => AbsolutePath.make(directory)),
.map((directory) => AbsolutePath.make(directory)), ]
const directPaths = discovered.filter((item) => path.basename(item) !== ".opencode").toReversed()
const direct = yield* Effect.forEach(directPaths, loadFile).pipe(
Effect.orDie,
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
)
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
return {
entries: [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()],
directories,
files: directPaths,
}
})
const initial = yield* discover()
let configs = initial.entries
const updates = yield* PubSub.unbounded<Watcher.Update>()
const subscriptions = new Map<string, Effect.Effect<unknown>>()
const targets = (snapshot: typeof initial) => [
...snapshot.directories.map((path) => ({ path, type: "directory" as const })),
...snapshot.files
.filter((file) => !snapshot.directories.some((directory) => FSUtil.contains(directory, file)))
.map((path) => ({ path, type: "file" as const })),
] ]
// A config closer to the opened directory should win over one higher up. const reconcile = Effect.fn("Config.reconcileWatches")(function* (snapshot: typeof initial) {
// Search starts nearby, so reverse the results before applying them. const next = new Map(targets(snapshot).map((target) => [JSON.stringify(target), target]))
const directPaths = discovered.filter((item) => path.basename(item) !== ".opencode").toReversed() for (const [key, stop] of subscriptions) {
const direct = yield* Effect.forEach(directPaths, loadFile).pipe( if (next.has(key)) continue
Effect.orDie, yield* stop
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)), subscriptions.delete(key)
) }
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie) for (const [key, target] of next) {
// Apply general settings first and more specific settings last: if (subscriptions.has(key)) continue
// global config, project files, then `.opencode` files. const fiber = yield* watcher.subscribe(target).pipe(
const configs = [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()] Stream.runForEach((update) => PubSub.publish(updates, update)),
// Rules use the opposite order so a user-global rule can override a Effect.forkScoped({ startImmediately: true }),
// repository rule. Statement order inside each file stays unchanged. )
yield* policy.load( subscriptions.set(key, Fiber.interrupt(fiber))
configs }
.filter((config): config is Document => config.type === "document") })
.toReversed()
.flatMap((config) => config.info.experimental?.policies ?? []), yield* Stream.fromPubSub(updates).pipe(
Stream.debounce("100 millis"),
Stream.runForEach((update) =>
Effect.gen(function* () {
const next = yield* discover()
configs = next.entries
yield* reconcile(next)
yield* events.publish(ConfigSchema.Event.Updated, {})
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause }))),
),
Effect.forkScoped({ startImmediately: true }),
) )
yield* reconcile(initial)
return Service.of({ return Service.of({
entries: Effect.fn("Config.entries")(function* () { entries: Effect.fn("Config.entries")(function* () {
@ -221,7 +257,7 @@ const layer = Layer.effect(
export const node = makeLocationNode({ export const node = makeLocationNode({
service: Service, service: Service,
layer, layer,
deps: [FSUtil.node, Global.node, Location.node, Policy.node], deps: [Watcher.node, EventV2.node, FSUtil.node, Global.node, Location.node],
}) })
function normalizeCommandAliases(input: unknown) { function normalizeCommandAliases(input: unknown) {

View file

@ -1,20 +0,0 @@
export * as ConfigExperimental from "./experimental"
import { Schema } from "effect"
import { Catalog } from "../catalog"
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])
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: PolicyConfig.pipe(Schema.Array, Schema.optional),
}) {}

View file

@ -2,7 +2,7 @@ export * as ConfigAgentPlugin from "./agent"
import { define } from "../../plugin/internal" import { define } from "../../plugin/internal"
import path from "path" import path from "path"
import { Effect, Option, Schema } from "effect" import { Effect, Option, Schema, Stream } from "effect"
import { AgentV2 } from "../../agent" import { AgentV2 } from "../../agent"
import { Config } from "../../config" import { Config } from "../../config"
import { ConfigAgent } from "../agent" import { ConfigAgent } from "../agent"
@ -38,64 +38,75 @@ export const Plugin = define({
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service const config = yield* Config.Service
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
yield* ctx.agent.transform( const load = Effect.fn("ConfigAgentPlugin.load")(function* () {
Effect.fn(function* (draft) { return yield* Effect.forEach(yield* config.entries(), (entry) => {
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { if (entry.type === "document") return Effect.succeed([entry])
if (entry.type === "document") return Effect.succeed([entry]) return Effect.gen(function* () {
return Effect.gen(function* () { const files = yield* discover(fs, entry.path)
const files = yield* discover(fs, entry.path) return yield* Effect.forEach(files, (file) =>
return yield* Effect.forEach(files, (file) => fs.readFileStringSafe(file.filepath).pipe(
fs.readFileStringSafe(file.filepath).pipe( Effect.map((content) => content && decode(file, content)),
Effect.map((content) => content && decode(file, content)), Effect.catch(() => Effect.succeed(undefined)),
Effect.catch(() => Effect.succeed(undefined)), ),
), ).pipe(
).pipe( Effect.map((documents) =>
Effect.map((documents) => documents.filter((document): document is Config.Document => document !== undefined),
documents.filter((document): document is Config.Document => document !== undefined), ),
), )
) })
}) }).pipe(Effect.map((documents) => documents.flat()))
}).pipe(Effect.map((documents) => documents.flat())) })
const global = documents.flatMap((document) => document.info.permissions ?? []) const loaded = { documents: yield* load() }
const configuredDefault = Config.latest(documents, "default_agent") yield* ctx.agent.transform((draft) => {
if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault)) const global = loaded.documents.flatMap((document) => document.info.permissions ?? [])
for (const current of draft.list()) { const configuredDefault = Config.latest(loaded.documents, "default_agent")
draft.update(current.id, (agent) => agent.permissions.push(...global)) if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault))
} for (const current of draft.list()) {
draft.update(current.id, (agent) => agent.permissions.push(...global))
}
for (const document of documents) { for (const document of loaded.documents) {
for (const [id, item] of Object.entries(document.info.agents ?? {})) { for (const [id, item] of Object.entries(document.info.agents ?? {})) {
const agentID = AgentV2.ID.make(id) const agentID = AgentV2.ID.make(id)
if (item.disabled) { if (item.disabled) {
draft.remove(agentID) draft.remove(agentID)
continue continue
}
const exists = draft.get(agentID) !== undefined
draft.update(agentID, (agent) => {
if (!exists) agent.permissions.push(...global)
if (item.model !== undefined) {
const model = ModelV2.parse(item.model)
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
}
if (item.variant !== undefined && agent.model !== undefined) {
agent.model.variant = ModelV2.VariantID.make(item.variant)
}
if (item.request !== undefined) {
Object.assign(agent.request.headers, item.request.headers ?? {})
Object.assign(agent.request.body, item.request.body ?? {})
}
if (item.system !== undefined) agent.system = item.system
if (item.description !== undefined) agent.description = item.description
if (item.mode !== undefined) agent.mode = item.mode
if (item.hidden !== undefined) agent.hidden = item.hidden
if (item.color !== undefined) agent.color = item.color
if (item.steps !== undefined) agent.steps = item.steps
if (item.permissions !== undefined) agent.permissions.push(...item.permissions)
})
} }
const exists = draft.get(agentID) !== undefined
draft.update(agentID, (agent) => {
if (!exists) agent.permissions.push(...global)
if (item.model !== undefined) {
const model = ModelV2.parse(item.model)
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
}
if (item.variant !== undefined && agent.model !== undefined) {
agent.model.variant = ModelV2.VariantID.make(item.variant)
}
if (item.request !== undefined) {
Object.assign(agent.request.headers, item.request.headers ?? {})
Object.assign(agent.request.body, item.request.body ?? {})
}
if (item.system !== undefined) agent.system = item.system
if (item.description !== undefined) agent.description = item.description
if (item.mode !== undefined) agent.mode = item.mode
if (item.hidden !== undefined) agent.hidden = item.hidden
if (item.color !== undefined) agent.color = item.color
if (item.steps !== undefined) agent.steps = item.steps
if (item.permissions !== undefined) agent.permissions.push(...item.permissions)
})
} }
}), }
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
load().pipe(
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
Effect.andThen(ctx.agent.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
) )
}), }),
}) })

View file

@ -2,7 +2,7 @@ export * as ConfigCommandPlugin from "./command"
import { define } from "../../plugin/internal" import { define } from "../../plugin/internal"
import path from "path" import path from "path"
import { Effect, Option, Schema } from "effect" import { Effect, Option, Schema, Stream } from "effect"
import { CommandV2 } from "../../command" import { CommandV2 } from "../../command"
import { Config } from "../../config" import { Config } from "../../config"
import { FSUtil } from "../../fs-util" import { FSUtil } from "../../fs-util"
@ -17,34 +17,45 @@ export const Plugin = define({
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service const config = yield* Config.Service
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
yield* ctx.command.transform( const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
Effect.fn(function* (draft) { return yield* Effect.forEach(yield* config.entries(), (entry) => {
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }]) return loadDirectory(fs, entry.path).pipe(
return loadDirectory(fs, entry.path).pipe( Effect.map((commands) => [
Effect.map((commands) => [ { commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }, ]),
]), )
) }).pipe(Effect.map((documents) => documents.flat()))
}).pipe(Effect.map((documents) => documents.flat())) })
for (const document of documents) { const loaded = { documents: yield* load() }
for (const [name, command] of Object.entries(document.commands ?? {})) { yield* ctx.command.transform((draft) => {
draft.update(name, (item) => { for (const document of loaded.documents) {
item.template = command.template for (const [name, command] of Object.entries(document.commands ?? {})) {
if (command.description !== undefined) item.description = command.description draft.update(name, (item) => {
if (command.agent !== undefined) item.agent = command.agent item.template = command.template
if (command.model !== undefined) { if (command.description !== undefined) item.description = command.description
const model = ModelV2.parse(command.model) if (command.agent !== undefined) item.agent = command.agent
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant } if (command.model !== undefined) {
} const model = ModelV2.parse(command.model)
if (command.variant !== undefined && item.model !== undefined) { item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
item.model.variant = ModelV2.VariantID.make(command.variant) }
} if (command.variant !== undefined && item.model !== undefined) {
if (command.subagent !== undefined) item.subagent = command.subagent item.model.variant = ModelV2.VariantID.make(command.variant)
}) }
} if (command.subagent !== undefined) item.subagent = command.subagent
})
} }
}), }
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
load().pipe(
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
Effect.andThen(ctx.command.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
) )
}), }),
}) })

View file

@ -2,7 +2,8 @@ export * as ConfigExternalPlugin from "./external"
import type { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect" import type { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
import type { Plugin as PromisePlugin } from "@opencode-ai/plugin/v2/promise" import type { Plugin as PromisePlugin } from "@opencode-ai/plugin/v2/promise"
import { Effect, Schema } from "effect" import { Effect, Schema, Stream } from "effect"
import { createRequire } from "node:module"
import path from "path" import path from "path"
import { fileURLToPath, pathToFileURL } from "url" import { fileURLToPath, pathToFileURL } from "url"
import { Config } from "../../config" import { Config } from "../../config"
@ -35,6 +36,9 @@ const PluginPackage = Schema.Struct({
module: Schema.optional(Schema.String), module: Schema.optional(Schema.String),
}) })
let importGeneration = 0
const moduleCache = createRequire(import.meta.url).cache
export const Plugin = define({ export const Plugin = define({
id: "config-plugin", id: "config-plugin",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
@ -42,8 +46,9 @@ export const Plugin = define({
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const location = yield* Location.Service const location = yield* Location.Service
const npm = yield* Npm.Service const npm = yield* Npm.Service
yield* Effect.gen(function* () { const active = new Set<string>()
const configured: { package: string; options?: Record<string, any> }[] = [] const load = Effect.fn("ConfigExternalPlugin.load")(function* () {
const configured: { package: string; options?: Record<string, unknown> }[] = []
for (const entry of yield* config.entries()) { for (const entry of yield* config.entries()) {
if (entry.type === "document") { if (entry.type === "document") {
@ -98,26 +103,55 @@ export const Plugin = define({
} }
} }
for (const ref of configured) { return yield* Effect.forEach(configured, (ref) =>
yield* Effect.gen(function* () { Effect.gen(function* () {
const entrypoint = path.isAbsolute(ref.package) const entrypoint = path.isAbsolute(ref.package)
? pathToFileURL(ref.package).href ? pathToFileURL(ref.package).href
: (yield* npm.add(ref.package)).entrypoint : (yield* npm.add(ref.package)).entrypoint
if (!entrypoint) return if (!entrypoint) return
yield* Effect.log({ msg: "loading plugin", id: ref.package, entrypoint }) yield* Effect.log({ msg: "loading plugin", id: ref.package, entrypoint })
const mod = yield* Effect.promise(() => import(entrypoint)) const mod = yield* Effect.promise(() => import(cacheBust(entrypoint)))
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value) const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
yield* ctx.plugin.add({ return {
id: plugin.id, id: plugin.id,
effect: (host) => plugin.effect({ ...host, options: ref.options ?? {} }), effect: (host: Parameters<typeof plugin.effect>[0]) =>
}) plugin.effect({ ...host, options: ref.options ?? {} }),
}).pipe(Effect.ignoreCause) }
} }).pipe(
Effect.catchCause((cause) =>
Effect.logError("failed to load plugin", { package: ref.package, cause }).pipe(Effect.as(undefined)),
),
),
).pipe(Effect.map((plugins) => plugins.filter((plugin) => plugin !== undefined)))
}) })
const reconcile = Effect.fn("ConfigExternalPlugin.reconcile")(function* () {
const plugins = yield* load()
const next = new Set(plugins.map((plugin) => plugin.id))
for (const id of active) {
if (!next.has(id)) yield* ctx.plugin.remove(id)
}
for (const plugin of plugins) yield* ctx.plugin.add(plugin)
active.clear()
for (const id of next) active.add(id)
})
yield* reconcile()
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reconcile()),
Effect.forkScoped({ startImmediately: true }),
)
}), }),
}) })
function cacheBust(entrypoint: string) {
const url = path.isAbsolute(entrypoint) ? pathToFileURL(entrypoint) : new URL(entrypoint)
if (url.protocol === "file:") delete moduleCache[fileURLToPath(url)]
url.searchParams.set("opencode-reload", String(++importGeneration))
return url.href
}
const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string) { const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string) {
const pkg = yield* fs.readJson(path.join(directory, "package.json")).pipe( const pkg = yield* fs.readJson(path.join(directory, "package.json")).pipe(
Effect.flatMap(Schema.decodeUnknownEffect(PluginPackage)), Effect.flatMap(Schema.decodeUnknownEffect(PluginPackage)),

View file

@ -1,7 +1,7 @@
export * as ConfigProviderPlugin from "./provider" export * as ConfigProviderPlugin from "./provider"
import { define } from "../../plugin/internal" import { define } from "../../plugin/internal"
import { Effect } from "effect" import { Effect, Stream } from "effect"
import { Config } from "../../config" import { Config } from "../../config"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
@ -9,108 +9,115 @@ export const Plugin = define({
id: "config-provider", id: "config-provider",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service const config = yield* Config.Service
yield* ctx.integration.transform( const loaded = { entries: yield* config.entries() }
Effect.fn(function* (integrations) { yield* ctx.integration.transform((integrations) => {
const files = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document") const files = loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")
const configuredIntegrations = new Set( const configuredIntegrations = new Set(
files.flatMap((file) => files.flatMap((file) =>
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) =>
provider.env === undefined ? [] : [id], provider.env === undefined ? [] : [id],
),
), ),
) ),
for (const file of files) { )
for (const [id, item] of Object.entries(file.info.providers ?? {})) { for (const file of files) {
const integrationID = id for (const [id, item] of Object.entries(file.info.providers ?? {})) {
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue const integrationID = id
integrations.update(integrationID, (integration) => { if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
integration.name = item.name ?? integration.name integrations.update(integrationID, (integration) => {
integration.name = item.name ?? integration.name
})
if (item.env !== undefined) {
integrations.method.update({
integrationID,
method: { type: "env", names: [...item.env] },
}) })
if (item.env !== undefined) {
integrations.method.update({
integrationID,
method: { type: "env", names: [...item.env] },
})
}
} }
} }
}), }
) })
yield* ctx.catalog.transform( yield* ctx.catalog.transform((catalog) => {
Effect.fn(function* (catalog) { const files = loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")
const entries = yield* config.entries() const configuredDefault = Config.latest(loaded.entries, "model")
const files = entries.filter((entry): entry is Config.Document => entry.type === "document") if (configuredDefault !== undefined) {
const configuredDefault = Config.latest(entries, "model") const model = ModelV2.parse(configuredDefault)
if (configuredDefault !== undefined) { catalog.model.default.set(model.providerID, model.modelID)
const model = ModelV2.parse(configuredDefault) }
catalog.model.default.set(model.providerID, model.modelID) for (const file of files) {
} for (const [id, item] of Object.entries(file.info.providers ?? {})) {
for (const file of files) { const providerID = id
for (const [id, item] of Object.entries(file.info.providers ?? {})) { catalog.provider.update(providerID, (provider) => {
const providerID = id if (item.name !== undefined) provider.name = item.name
catalog.provider.update(providerID, (provider) => { if (item.api !== undefined) provider.api = { ...item.api }
if (item.name !== undefined) provider.name = item.name if (item.request !== undefined) {
if (item.api !== undefined) provider.api = { ...item.api } Object.assign(provider.request.settings, item.request.settings)
if (item.request !== undefined) { Object.assign(provider.request.headers, item.request.headers)
Object.assign(provider.request.settings, item.request.settings) Object.assign(provider.request.body, item.request.body)
Object.assign(provider.request.headers, item.request.headers)
Object.assign(provider.request.body, item.request.body)
}
})
for (const [id, config] of Object.entries(item.models ?? {})) {
catalog.model.update(providerID, id, (model) => {
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
if (config.capabilities !== undefined) {
model.capabilities = {
tools: config.capabilities.tools,
input: [...config.capabilities.input],
output: [...config.capabilities.output],
}
}
if (config.request !== undefined) {
Object.assign(model.request.settings, config.request.settings)
Object.assign(model.request.headers, config.request.headers)
Object.assign(model.request.body, config.request.body)
if (config.request.variant !== undefined) model.request.variant = config.request.variant
}
if (config.variants !== undefined) {
for (const variant of config.variants) {
let existing = model.variants.find((item) => item.id === variant.id)
if (!existing) {
existing = {
id: variant.id,
settings: {},
headers: {},
body: {},
}
model.variants.push(existing)
}
Object.assign(existing.settings, variant.settings)
Object.assign(existing.headers, variant.headers)
Object.assign(existing.body, variant.body)
}
}
if (config.cost !== undefined) {
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
tier: cost.tier && { ...cost.tier },
input: cost.input,
output: cost.output,
cache: {
read: cost.cache?.read ?? 0,
write: cost.cache?.write ?? 0,
},
}))
}
if (config.disabled !== undefined) model.enabled = !config.disabled
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
})
} }
})
for (const [id, config] of Object.entries(item.models ?? {})) {
catalog.model.update(providerID, id, (model) => {
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
if (config.capabilities !== undefined) {
model.capabilities = {
tools: config.capabilities.tools,
input: [...config.capabilities.input],
output: [...config.capabilities.output],
}
}
if (config.request !== undefined) {
Object.assign(model.request.settings, config.request.settings)
Object.assign(model.request.headers, config.request.headers)
Object.assign(model.request.body, config.request.body)
if (config.request.variant !== undefined) model.request.variant = config.request.variant
}
if (config.variants !== undefined) {
for (const variant of config.variants) {
let existing = model.variants.find((item) => item.id === variant.id)
if (!existing) {
existing = {
id: variant.id,
settings: {},
headers: {},
body: {},
}
model.variants.push(existing)
}
Object.assign(existing.settings, variant.settings)
Object.assign(existing.headers, variant.headers)
Object.assign(existing.body, variant.body)
}
}
if (config.cost !== undefined) {
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
tier: cost.tier && { ...cost.tier },
input: cost.input,
output: cost.output,
cache: {
read: cost.cache?.read ?? 0,
write: cost.cache?.write ?? 0,
},
}))
}
if (config.disabled !== undefined) model.enabled = !config.disabled
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
})
} }
} }
}), }
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(ctx.integration.reload()),
Effect.andThen(ctx.catalog.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
) )
}), }),
}) })

View file

@ -2,7 +2,7 @@ export * as ConfigReferencePlugin from "./reference"
import { define } from "../../plugin/internal" import { define } from "../../plugin/internal"
import path from "path" import path from "path"
import { Effect } from "effect" import { Effect, Stream } from "effect"
import { Config } from "../../config" import { Config } from "../../config"
import { ConfigReference } from "../reference" import { ConfigReference } from "../reference"
import { Reference } from "../../reference" import { Reference } from "../../reference"
@ -16,40 +16,47 @@ export const Plugin = define({
const config = yield* Config.Service const config = yield* Config.Service
const location = yield* Location.Service const location = yield* Location.Service
const global = yield* Global.Service const global = yield* Global.Service
yield* ctx.reference.transform( const loaded = { entries: yield* config.entries() }
Effect.fn(function* (draft) { yield* ctx.reference.transform((draft) => {
const entries = new Map<string, Reference.Source>() const entries = new Map<string, Reference.Source>()
for (const doc of (yield* config.entries()).filter( for (const doc of loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")) {
(entry): entry is Config.Document => entry.type === "document", const directory = doc.path ? path.dirname(doc.path) : location.directory
)) { for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
const directory = doc.path ? path.dirname(doc.path) : location.directory if (!validAlias(name)) continue
for (const [name, entry] of Object.entries(doc.info.references ?? {})) { const description = typeof entry === "string" ? undefined : entry.description
if (!validAlias(name)) continue const hidden = typeof entry === "string" ? undefined : entry.hidden
const description = typeof entry === "string" ? undefined : entry.description entries.set(
const hidden = typeof entry === "string" ? undefined : entry.hidden name,
entries.set( local(entry)
name, ? Reference.LocalSource.make({
local(entry) type: "local",
? Reference.LocalSource.make({ path: AbsolutePath.make(
type: "local", localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
path: AbsolutePath.make( ),
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path), ...(description === undefined ? {} : { description }),
), ...(hidden === undefined ? {} : { hidden }),
...(description === undefined ? {} : { description }), })
...(hidden === undefined ? {} : { hidden }), : Reference.GitSource.make({
}) type: "git",
: Reference.GitSource.make({ repository: typeof entry === "string" ? entry : entry.repository,
type: "git", ...(entry.branch === undefined ? {} : { branch: entry.branch }),
repository: typeof entry === "string" ? entry : entry.repository, ...(description === undefined ? {} : { description }),
...(entry.branch === undefined ? {} : { branch: entry.branch }), ...(hidden === undefined ? {} : { hidden }),
...(description === undefined ? {} : { description }), }),
...(hidden === undefined ? {} : { hidden }), )
}),
)
}
} }
for (const [name, source] of entries) draft.add(name, source) }
}), for (const [name, source] of entries) draft.add(name, source)
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(ctx.reference.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
) )
}), }),
}) })

View file

@ -2,7 +2,7 @@ export * as ConfigSkillPlugin from "./skill"
import { define } from "../../plugin/internal" import { define } from "../../plugin/internal"
import path from "path" import path from "path"
import { Effect } from "effect" import { Effect, Stream } from "effect"
import { Config } from "../../config" import { Config } from "../../config"
import { AbsolutePath } from "../../schema" import { AbsolutePath } from "../../schema"
import { SkillV2 } from "../../skill" import { SkillV2 } from "../../skill"
@ -15,36 +15,44 @@ export const Plugin = define({
const config = yield* Config.Service const config = yield* Config.Service
const global = yield* Global.Service const global = yield* Global.Service
const location = yield* Location.Service const location = yield* Location.Service
yield* ctx.skill.transform( const loaded = { entries: yield* config.entries() }
Effect.fn(function* (draft) { yield* ctx.skill.transform((draft) => {
const entries = yield* config.entries() const directories = loaded.entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) const items = loaded.entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : [])) for (const directory of directories) {
for (const directory of directories) { draft.source(
draft.source( SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }), )
) draft.source(
draft.source( SkillV2.DirectorySource.make({
SkillV2.DirectorySource.make({ type: "directory",
type: "directory", path: AbsolutePath.make(path.join(directory, "skills")),
path: AbsolutePath.make(path.join(directory, "skills")), }),
}), )
) }
for (const item of items) {
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
draft.source(SkillV2.UrlSource.make({ type: "url", url: item }))
continue
} }
for (const item of items) { const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) { draft.source(
draft.source(SkillV2.UrlSource.make({ type: "url", url: item })) SkillV2.DirectorySource.make({
continue type: "directory",
} path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item }),
draft.source( )
SkillV2.DirectorySource.make({ }
type: "directory", })
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)), yield* ctx.event.subscribe().pipe(
}), Stream.filter((event) => event.type === "config.updated"),
) Stream.runForEach(() =>
} config.entries().pipe(
}), Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(ctx.skill.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
) )
}), }),
}) })

View file

@ -0,0 +1,85 @@
export * as ConfigVariable from "./variable"
import os from "os"
import path from "path"
import { Effect } from "effect"
import { FSUtil } from "../fs-util"
import { InvalidError } from "../v1/config/error"
type ParseSource =
| {
type: "path"
path: string
}
| {
type: "virtual"
source: string
dir: string
}
type SubstituteInput = ParseSource & {
text: string
missing?: "error" | "empty"
env?: Record<string, string>
}
/** Apply {env:VAR} and {file:path} substitutions to config text. */
export const substitute = Effect.fn("ConfigVariable.substitute")(function* (input: SubstituteInput) {
const text = input.text.replace(
/\{env:([^}]+)\}/g,
(_, varName: string) => (input.env?.[varName] ?? process.env[varName]) || "",
)
if (!text.includes("{file:")) return text
return yield* substituteFiles(input, text)
})
const substituteFiles = Effect.fnUntraced(function* (input: SubstituteInput, text: string) {
const fs = yield* FSUtil.Service
const configDir = input.type === "path" ? path.dirname(input.path) : input.dir
const configSource = input.type === "path" ? input.path : input.source
const matches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
let out = ""
let cursor = 0
for (const match of matches) {
const token = match[0]
const index = match.index
out += text.slice(cursor, index)
const lineStart = text.lastIndexOf("\n", index - 1) + 1
const prefix = text.slice(lineStart, index).trimStart()
if (prefix.startsWith("//")) {
out += token
cursor = index + token.length
continue
}
const filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
const expandedPath = filePath.startsWith("~/") ? path.join(os.homedir(), filePath.slice(2)) : filePath
const resolvedPath = path.isAbsolute(expandedPath) ? expandedPath : path.resolve(configDir, expandedPath)
const fileContent = yield* fs.readFileString(resolvedPath).pipe(
Effect.catch((error) => {
if (input.missing === "empty") return Effect.succeed("")
const message = `bad file reference: "${token}"`
return Effect.fail(
new InvalidError(
{
path: configSource,
message:
error._tag === "PlatformError" && error.reason._tag === "NotFound"
? `${message} ${resolvedPath} does not exist`
: message,
},
{ cause: error },
),
)
}),
)
out += JSON.stringify(fileContent.trim()).slice(1, -1)
cursor = index + token.length
}
return out + text.slice(cursor)
})

View file

@ -43,5 +43,6 @@ export const migrations = (
import("./migration/20260702134641_add_session_context_entry"), import("./migration/20260702134641_add_session_context_entry"),
import("./migration/20260703090000_reset_v2_event_rename_sweep"), import("./migration/20260703090000_reset_v2_event_rename_sweep"),
import("./migration/20260703181610_event_created_column"), import("./migration/20260703181610_event_created_column"),
import("./migration/20260703190000_reset_v2_shell_event_payloads"),
]) ])
).map((module) => module.default) satisfies DatabaseMigration.Migration[] ).map((module) => module.default) satisfies DatabaseMigration.Migration[]

View file

@ -0,0 +1,14 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260703190000_reset_v2_shell_event_payloads",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DELETE FROM \`session_input\`;`)
yield* tx.run(`DELETE FROM \`session_message\`;`)
yield* tx.run(`DELETE FROM \`event\`;`)
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -0,0 +1,83 @@
export * as LocationWatcher from "./location-watcher"
import { makeLocationNode } from "../effect/app-node"
import { Context, Effect, Layer, Stream } from "effect"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import os from "os"
import path from "path"
import { Config } from "../config"
import { EventV2 } from "../event"
import { FSUtil } from "../fs-util"
import { Git } from "../git"
import { Location } from "../location"
import { Watcher } from "./watcher"
import { Ignore } from "./ignore"
import { Protected } from "./protected"
function protecteds(dir: string) {
return Protected.paths().filter((item) => {
const relative = path.relative(dir, item)
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)
})
}
export interface Interface {}
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationWatcher") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const location = yield* Location.Service
const watcher = yield* Watcher.Service
const events = yield* EventV2.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const configService = yield* Config.Service
const config = (yield* configService.entries())
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
const publish = (update: { type: "create" | "update" | "delete"; path: string }) =>
events.publish(FileSystem.Event.Changed, {
file: update.path,
event: update.type === "create" ? "add" : update.type === "update" ? "change" : "unlink",
})
if (path.resolve(location.directory) !== path.resolve(os.homedir())) {
yield* watcher
.subscribe({
path: location.directory,
type: "directory",
ignore: [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)],
})
.pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true }))
} else {
yield* Effect.logInfo("location watcher skipped home directory", { directory: location.directory })
}
if (location.vcs?.type === "git") {
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
(entry) => (entry.name === "HEAD" ? [] : [entry.name]),
)
yield* watcher
.subscribe({ path: vcs, type: "directory", ignore })
.pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true }))
}
}
return Service.of({})
}).pipe(
Effect.catchCause((cause) =>
Effect.logError("failed to init location watcher service", { cause }).pipe(Effect.as(Service.of({}))),
),
),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Watcher.node, FSUtil.node, Location.node, Config.node, Git.node, EventV2.node],
})

View file

@ -3,26 +3,20 @@ export * as Watcher from "./watcher"
// @ts-ignore // @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper" import { createWrapper } from "@parcel/watcher/wrapper"
import type ParcelWatcher from "@parcel/watcher" import type ParcelWatcher from "@parcel/watcher"
import { makeLocationNode } from "../effect/app-node" import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Cause, Context, Effect, Layer } from "effect" import { makeGlobalNode } from "../effect/app-node"
import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher" import { Cause, Context, Effect, Layer, PubSub, Scope, Stream } from "effect"
import os from "os" import { KeyedMutex } from "../effect/keyed-mutex"
import path from "path"
import { Config } from "../config"
import { EventV2 } from "../event"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
import { FSUtil } from "../fs-util"
import { Git } from "../git"
import { Location } from "../location"
import { lazy } from "../util/lazy" import { lazy } from "../util/lazy"
import { Ignore } from "./ignore" import { watch as watchFileSystem } from "node:fs"
import { Protected } from "./protected" import path from "path"
declare const OPENCODE_LIBC: string | undefined declare const OPENCODE_LIBC: string | undefined
const SUBSCRIBE_TIMEOUT_MS = 10_000 const SUBSCRIBE_TIMEOUT_MS = 10_000
export const Event = FileSystemWatcher.Event export const Event = { Updated: FileSystem.Event.Changed }
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => { const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
try { try {
@ -42,108 +36,134 @@ function getBackend() {
if (process.platform === "linux") return "inotify" if (process.platform === "linux") return "inotify"
} }
function protecteds(dir: string) { export const hasNativeBinding = () => !!watcher()
return Protected.paths().filter((item) => { export type Update = ParcelWatcher.Event
const relative = path.relative(dir, item)
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative) export type WatchInput =
}) | { readonly path: string; readonly type: "file" }
| { readonly path: string; readonly type: "directory"; readonly ignore?: readonly string[] }
export interface Interface {
readonly subscribe: (input: WatchInput) => Stream.Stream<Update>
} }
export const hasNativeBinding = () => !!watcher() export class Service extends Context.Service<Service, Interface>()("@opencode/Watcher") {}
export interface Interface {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileWatcher") {}
const layer = Layer.effect( const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
if (Flag.OPENCODE_DISABLE_FILEWATCHER) return Service.of({})
const backend = getBackend() const backend = getBackend()
const location = yield* Location.Service const native = watcher()
if (path.resolve(location.directory) === path.resolve(os.homedir())) { if (Flag.OPENCODE_DISABLE_FILEWATCHER) {
yield* Effect.logInfo("watcher skipped home directory", { directory: location.directory }) return Service.of({ subscribe: () => Stream.empty })
return Service.of({})
}
if (!backend) {
yield* Effect.logError("watcher backend not supported", {
directory: location.directory,
platform: process.platform,
})
return Service.of({})
} }
const w = watcher() type Entry = {
if (!w) return Service.of({}) readonly pubsub: PubSub.PubSub<Update>
readonly subscription: { readonly unsubscribe: () => Promise<void> }
yield* Effect.logInfo("watcher backend", { directory: location.directory, platform: process.platform, backend }) refs: number
const events = yield* EventV2.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const subscriptions: ParcelWatcher.AsyncSubscription[] = []
yield* Effect.addFinalizer(() =>
Effect.promise(() => Promise.allSettled(subscriptions.map((subscription) => subscription.unsubscribe()))),
)
const callback: ParcelWatcher.SubscribeCallback = (_error, updates) => {
if (_error) runFork(Effect.logError("watcher callback failed", { error: _error }))
for (const update of updates) {
if (update.type === "create") runFork(events.publish(Event.Updated, { file: update.path, event: "add" }))
if (update.type === "update") runFork(events.publish(Event.Updated, { file: update.path, event: "change" }))
if (update.type === "delete") runFork(events.publish(Event.Updated, { file: update.path, event: "unlink" }))
}
} }
const entries = new Map<string, Entry>()
const locks = KeyedMutex.makeUnsafe<string>()
const subscribe = (directory: string, ignore: string[]) => { const acquire = Effect.fn("Watcher.acquire")(function* (input: WatchInput) {
const pending = w.subscribe(directory, callback, { ignore, backend }) const scope = yield* Scope.Scope
return Effect.promise(() => pending).pipe( const target = path.resolve(input.path)
Effect.tap((subscription) => const directory = input.type === "file" ? path.dirname(target) : target
Effect.sync(() => subscriptions.push(subscription)).pipe( const ignore = [...new Set(input.type === "directory" ? (input.ignore ?? []) : [])].toSorted()
Effect.andThen(Effect.logInfo("watcher subscribed", { directory, backend, ignores: ignore.length })), const id = JSON.stringify([input.type, target, ignore])
), const pubsub = yield* locks.withLock(id)(
), Effect.gen(function* () {
Effect.timeout(SUBSCRIBE_TIMEOUT_MS), const existing = entries.get(id)
Effect.catchCause((cause) => { if (existing) {
pending.then((subscription) => subscription.unsubscribe()).catch(() => {}) existing.refs++
return Effect.logError("failed to subscribe", { directory, cause: Cause.pretty(cause) }) return existing.pubsub
}
const pubsub = yield* PubSub.unbounded<Update>()
const subscription = yield* input.type === "file"
? Effect.sync(() => {
const subscription = watchFileSystem(directory, { recursive: false }, (_event, file) => {
if (file && path.resolve(directory, file.toString()) !== target) return
PubSub.publishUnsafe(pubsub, {
path: target,
type: "update",
} satisfies Update)
})
if ("on" in subscription && typeof subscription.on === "function") {
subscription.on("error", (error: unknown) =>
Effect.runFork(Effect.logError("watcher callback failed", { path: target, error })),
)
}
return { unsubscribe: () => Promise.resolve(subscription.close()) }
})
: subscribeDirectory(native, backend, directory, ignore, pubsub)
if (subscription) {
entries.set(id, { pubsub, subscription, refs: 1 })
yield* Effect.logInfo("watcher started", {
path: target,
type: input.type,
backend: input.type === "file" ? "node" : backend,
ignores: ignore.length,
})
return pubsub
}
yield* PubSub.shutdown(pubsub)
return pubsub
}), }),
) )
}
const configService = yield* Config.Service yield* Scope.addFinalizer(
const config = (yield* configService.entries()) scope,
.filter((entry): entry is Config.Document => entry.type === "document") locks.withLock(id)(
.flatMap((item) => item.info.watcher?.ignore ?? []) Effect.gen(function* () {
yield* Effect.forkScoped( const entry = entries.get(id)
subscribe(location.directory, [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)]), if (!entry) return
) entry.refs--
if (entry.refs > 0) return
if (location.vcs?.type === "git") { entries.delete(id)
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory yield* Effect.promise(() => entry.subscription.unsubscribe()).pipe(Effect.ignore)
const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined yield* PubSub.shutdown(entry.pubsub)
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) { yield* Effect.logInfo("watcher stopped", { path: target, type: input.type })
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap( }),
(entry) => (entry.name === "HEAD" ? [] : [entry.name]), ),
)
yield* Effect.forkScoped(subscribe(vcs, ignore))
}
}
return Service.of({})
}).pipe(
Effect.catchCause((cause) => {
return Effect.logError("failed to init watcher service", { cause: Cause.pretty(cause) }).pipe(
Effect.as(Service.of({})),
) )
}), return pubsub
), })
const subscribe = (input: WatchInput) =>
Stream.unwrap(acquire(input).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub))))
return Service.of({ subscribe })
}),
) )
export const node = makeLocationNode({ export const node = makeGlobalNode({ service: Service, layer, deps: [] })
service: Service,
layer, function subscribeDirectory(
deps: [FSUtil.node, Location.node, Config.node, Git.node, EventV2.node], native: typeof import("@parcel/watcher") | undefined,
}) backend: ParcelWatcher.BackendType | undefined,
directory: string,
ignore: string[],
pubsub: PubSub.PubSub<Update>,
) {
if (!native || !backend) {
return Effect.logError("watcher backend not supported", { directory, platform: process.platform }).pipe(
Effect.as(undefined),
)
}
const callback: ParcelWatcher.SubscribeCallback = (error, updates) => {
if (error) Effect.runFork(Effect.logError("watcher callback failed", { error }))
for (const update of updates) PubSub.publishUnsafe(pubsub, update)
}
const pending = native.subscribe(directory, callback, { ignore, backend })
return Effect.promise(() => pending).pipe(
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) => {
pending.then((subscription) => subscription.unsubscribe()).catch(() => {})
return Effect.logError("failed to subscribe", {
directory,
cause: Cause.pretty(cause),
}).pipe(Effect.as(undefined))
}),
)
}

View file

@ -11,7 +11,7 @@ import { FileSystem } from "./filesystem"
import { FileSystemSearch } from "./filesystem/search" import { FileSystemSearch } from "./filesystem/search"
import { Generate } from "./generate" import { Generate } from "./generate"
import { Form } from "./form" import { Form } from "./form"
import { Watcher } from "./filesystem/watcher" import { LocationWatcher } from "./filesystem/location-watcher"
import { Image } from "./image" import { Image } from "./image"
import { Integration } from "./integration" import { Integration } from "./integration"
import { Location } from "./location" import { Location } from "./location"
@ -21,8 +21,6 @@ import { MCP } from "./mcp/index"
import { PermissionV2 } from "./permission" import { PermissionV2 } from "./permission"
import { PluginV2 } from "./plugin" import { PluginV2 } from "./plugin"
import { PluginInternal } from "./plugin/internal" import { PluginInternal } from "./plugin/internal"
import { Policy } from "./policy"
import { Project } from "./project"
import { ProjectCopy } from "./project/copy" import { ProjectCopy } from "./project/copy"
import { Pty } from "./pty" import { Pty } from "./pty"
import { QuestionV2 } from "./question" import { QuestionV2 } from "./question"
@ -49,10 +47,8 @@ import { Vcs } from "./vcs"
export { LocationServiceMap } from "./location-service-map" export { LocationServiceMap } from "./location-service-map"
export const locationServices = LayerNode.group([ const locationServiceNodes = [
Project.node,
Location.node, Location.node,
Policy.node,
Config.node, Config.node,
AgentV2.node, AgentV2.node,
CommandV2.node, CommandV2.node,
@ -66,7 +62,7 @@ export const locationServices = LayerNode.group([
ProjectCopy.refreshNode, ProjectCopy.refreshNode,
FileSystemSearch.node, FileSystemSearch.node,
FileSystem.node, FileSystem.node,
Watcher.node, LocationWatcher.node,
Pty.node, Pty.node,
Shell.node, Shell.node,
SkillV2.node, SkillV2.node,
@ -96,7 +92,9 @@ export const locationServices = LayerNode.group([
Snapshot.node, Snapshot.node,
SessionRunnerLLM.node, SessionRunnerLLM.node,
Vcs.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 LocationServices = LayerNode.Output<typeof locationServices>
export type LocationError = LayerNode.Error<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", { export class NeedsAuthError extends Schema.TaggedErrorClass<NeedsAuthError>()("MCP.NeedsAuthError", {
server: Schema.String, server: Schema.String,
}) {} }) {
override get message() {
return `MCP server requires authentication: ${this.server}`
}
}
export class ConnectError extends Schema.TaggedErrorClass<ConnectError>()("MCP.ConnectError", { export class ConnectError extends Schema.TaggedErrorClass<ConnectError>()("MCP.ConnectError", {
server: Schema.String, 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", { export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("MCP.NotFoundError", {
server: ServerName, server: ServerName,
}) {} }) {
override get message() {
return `MCP server not found: ${this.server}`
}
}
export class ToolCallError extends Schema.TaggedErrorClass<ToolCallError>()("MCP.ToolCallError", { export class ToolCallError extends Schema.TaggedErrorClass<ToolCallError>()("MCP.ToolCallError", {
server: ServerName, server: ServerName,
@ -201,7 +205,7 @@ export const layer = Layer.effect(
for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) { for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) {
runtime.set(ServerName.make(name), { runtime.set(ServerName.make(name), {
config: { ...server, timeout: { ...timeout, ...server.timeout } }, config: { ...server, timeout: { ...timeout, ...server.timeout } },
status: { status: "disconnected" }, status: { status: "pending" },
startup: Deferred.makeUnsafe<void>(), startup: Deferred.makeUnsafe<void>(),
}) })
} }

View file

@ -67,7 +67,7 @@ export const Model = Schema.Struct({
attachment: Schema.Boolean, attachment: Schema.Boolean,
reasoning: Schema.Boolean, reasoning: Schema.Boolean,
reasoning_options: Schema.optional(Schema.Array(ReasoningOption)), reasoning_options: Schema.optional(Schema.Array(ReasoningOption)),
temperature: Schema.Boolean, temperature: Schema.optional(Schema.Boolean),
tool_call: Schema.Boolean, tool_call: Schema.Boolean,
interleaved: Schema.optional( interleaved: Schema.optional(
Schema.Union([ Schema.Union([

View file

@ -1,12 +1,14 @@
export * as PluginHost from "./host" export * as PluginHost from "./host"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { Effect, Schema } from "effect" import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { Effect, Schema, Stream } from "effect"
import { AgentV2 } from "../agent" import { AgentV2 } from "../agent"
import { AISDK } from "../aisdk" import { AISDK } from "../aisdk"
import { Catalog } from "../catalog" import { Catalog } from "../catalog"
import { CommandV2 } from "../command" import { CommandV2 } from "../command"
import { Credential } from "../credential" import { Credential } from "../credential"
import { EventV2 } from "../event"
import { Integration } from "../integration" import { Integration } from "../integration"
import { Location } from "../location" import { Location } from "../location"
import { ModelV2 } from "../model" import { ModelV2 } from "../model"
@ -21,12 +23,14 @@ import { ToolHooks } from "../tool/hooks"
import { WorkspaceV2 } from "../workspace" import { WorkspaceV2 } from "../workspace"
const mutable = <T>(value: T) => value as DeepMutable<T> const mutable = <T>(value: T) => value as DeepMutable<T>
const isEvent = Schema.is(Schema.Union(EventManifest.ServerDefinitions))
export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) { export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) {
const agents = yield* AgentV2.Service const agents = yield* AgentV2.Service
const aisdk = yield* AISDK.Service const aisdk = yield* AISDK.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const commands = yield* CommandV2.Service const commands = yield* CommandV2.Service
const events = yield* EventV2.Service
const integration = yield* Integration.Service const integration = yield* Integration.Service
const location = yield* Location.Service const location = yield* Location.Service
const reference = yield* Reference.Service const reference = yield* Reference.Service
@ -52,6 +56,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
}) })
const isCurrentLocation = (ref: Location.Ref) => const isCurrentLocation = (ref: Location.Ref) =>
ref.directory === location.directory && ref.workspaceID === location.workspaceID ref.directory === location.directory && ref.workspaceID === location.workspaceID
const response = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(Effect.map((data) => ({ location: locationInfo(), data })))
return { return {
options: {}, options: {},
@ -63,15 +69,15 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
}, },
reload: agents.reload, reload: agents.reload,
transform: (callback) => transform: (callback) =>
agents.transform((draft) => agents.transform((draft) => {
callback({ callback({
list: () => mutable(draft.list()), list: () => mutable(draft.list()),
get: (id) => mutable(draft.get(AgentV2.ID.make(id))), get: (id) => mutable(draft.get(AgentV2.ID.make(id))),
default: (id) => draft.default(id === undefined ? undefined : AgentV2.ID.make(id)), default: (id) => draft.default(id === undefined ? undefined : AgentV2.ID.make(id)),
update: (id, update) => draft.update(AgentV2.ID.make(id), update), update: (id, update) => draft.update(AgentV2.ID.make(id), update),
remove: (id) => draft.remove(AgentV2.ID.make(id)), remove: (id) => draft.remove(AgentV2.ID.make(id)),
}), })
), }),
}, },
aisdk: { aisdk: {
sdk: (callback) => sdk: (callback) =>
@ -102,9 +108,26 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
}), }),
}, },
catalog: { catalog: {
provider: {
list: () => response(catalog.provider.available()),
get: (input) =>
catalog.provider
.get(ProviderV2.ID.make(input.providerID))
.pipe(
Effect.flatMap((provider) =>
provider === undefined
? Effect.fail(new Error(`Provider not found: ${input.providerID}`))
: response(Effect.succeed(provider)),
),
),
},
model: {
list: () => response(catalog.model.available()),
default: () => response(catalog.model.default()),
},
reload: catalog.reload, reload: catalog.reload,
transform: (callback) => transform: (callback) =>
catalog.transform((draft) => catalog.transform((draft) => {
callback({ callback({
provider: { provider: {
list: () => mutable(draft.provider.list()), list: () => mutable(draft.provider.list()),
@ -125,14 +148,42 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
draft.model.default.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), draft.model.default.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
}, },
}, },
}), })
), }),
}, },
command: { command: {
list: () => response(commands.list()),
reload: commands.reload, reload: commands.reload,
transform: commands.transform, transform: (callback) =>
commands.transform((draft) => {
callback(draft)
}),
},
event: {
subscribe: () => events.live().pipe(Stream.filter(isEvent)),
}, },
integration: { integration: {
list: () => response(integration.list()),
get: (input) => response(integration.get(Integration.ID.make(input.integrationID))),
connectKey: (input) =>
integration.connection.key({
integrationID: Integration.ID.make(input.integrationID),
key: input.key,
label: input.label,
}),
connectOauth: (input) =>
response(
integration.connection.oauth({
integrationID: Integration.ID.make(input.integrationID),
methodID: Integration.MethodID.make(input.methodID),
inputs: input.inputs,
label: input.label,
}),
),
attemptStatus: (input) => response(integration.attempt.status(Integration.AttemptID.make(input.attemptID))),
attemptComplete: (input) =>
integration.attempt.complete({ attemptID: Integration.AttemptID.make(input.attemptID), code: input.code }),
attemptCancel: (input) => integration.attempt.cancel(Integration.AttemptID.make(input.attemptID)),
reload: integration.reload, reload: integration.reload,
connection: { connection: {
active: (id) => integration.connection.active(Integration.ID.make(id)), active: (id) => integration.connection.active(Integration.ID.make(id)),
@ -142,7 +193,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
), ),
}, },
transform: (callback) => transform: (callback) =>
integration.transform((draft) => integration.transform((draft) => {
callback({ callback({
list: () => mutable(draft.list()), list: () => mutable(draft.list()),
get: (id) => mutable(draft.get(Integration.ID.make(id))), get: (id) => mutable(draft.get(Integration.ID.make(id))),
@ -219,33 +270,36 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
remove: (id, method) => remove: (id, method) =>
draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)), draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)),
}, },
}), })
), }),
}, },
plugin: { plugin: {
list: () => response(plugin.list()),
add: (input) => plugin.add(PluginV2.ID.make(input.id), input.effect), add: (input) => plugin.add(PluginV2.ID.make(input.id), input.effect),
remove: (id) => plugin.remove(PluginV2.ID.make(id)), remove: (id) => plugin.remove(PluginV2.ID.make(id)),
}, },
reference: { reference: {
list: () => response(reference.list()),
reload: reference.reload, reload: reference.reload,
transform: (callback) => transform: (callback) =>
reference.transform((draft) => reference.transform((draft) => {
callback({ callback({
add: (name, source) => draft.add(name, Schema.decodeUnknownSync(Reference.Source)(source)), add: (name, source) => draft.add(name, Schema.decodeUnknownSync(Reference.Source)(source)),
remove: draft.remove, remove: draft.remove,
list: draft.list, list: draft.list,
}), })
), }),
}, },
skill: { skill: {
list: () => response(skill.list()),
reload: skill.reload, reload: skill.reload,
transform: (callback) => transform: (callback) =>
skill.transform((draft) => skill.transform((draft) => {
callback({ callback({
source: (source) => draft.source(Schema.decodeUnknownSync(SkillV2.Source)(source)), source: (source) => draft.source(Schema.decodeUnknownSync(SkillV2.Source)(source)),
list: draft.list, list: draft.list,
}), })
), }),
}, },
tool: { tool: {
register: (input) => tools.register(input), register: (input) => tools.register(input),

View file

@ -201,64 +201,65 @@ export const ModelsDevPlugin = define({
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const modelsDev = yield* ModelsDev.Service const modelsDev = yield* ModelsDev.Service
const events = yield* EventV2.Service const events = yield* EventV2.Service
yield* ctx.integration.transform( const loaded = { data: yield* modelsDev.get() }
Effect.fn(function* (integrations) { yield* ctx.integration.transform((integrations) => {
const data = yield* modelsDev.get() for (const item of Object.values(loaded.data)) {
for (const item of Object.values(data)) { if (item.env.length === 0) continue
if (item.env.length === 0) continue const integrationID = item.id
const integrationID = item.id integrations.update(integrationID, (integration) => (integration.name = item.name))
integrations.update(integrationID, (integration) => (integration.name = item.name)) integrations.method.update({
integrations.method.update({ integrationID,
integrationID, method: { type: "key" },
method: { type: "key" }, })
}) integrations.method.update({
integrations.method.update({ integrationID,
integrationID, method: { type: "env", names: [...item.env] },
method: { type: "env", names: [...item.env] }, })
}) }
} })
}), yield* ctx.catalog.transform((catalog) => {
) for (const item of Object.values(loaded.data)) {
yield* ctx.catalog.transform( const providerID = ProviderV2.ID.make(item.id)
Effect.fn(function* (catalog) { catalog.provider.update(providerID, (provider) => {
const data = yield* modelsDev.get() provider.name = item.name
for (const item of Object.values(data)) { provider.api = item.npm
const providerID = ProviderV2.ID.make(item.id) ? {
catalog.provider.update(providerID, (provider) => { type: "aisdk",
provider.name = item.name package: item.npm,
provider.api = item.npm url: item.api,
? { }
type: "aisdk", : {
package: item.npm, type: "native",
url: item.api, url: item.api,
} settings: {},
: { }
type: "native", })
url: item.api,
settings: {},
}
})
for (const model of Object.values(item.models)) { for (const model of Object.values(item.models)) {
const baseCost = cost(model.cost) const baseCost = cost(model.cost)
const variants = reasoningVariants(item, model) const variants = reasoningVariants(item, model)
catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost, variants })) catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost, variants }))
for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) { for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) {
catalog.model.update(providerID, `${model.id}-${mode}`, (draft) => catalog.model.update(providerID, `${model.id}-${mode}`, (draft) =>
applyModel(draft, model, { applyModel(draft, model, {
name: modeName(model, mode), name: modeName(model, mode),
cost: mergeCost(baseCost, options.cost), cost: mergeCost(baseCost, options.cost),
request: options.provider, request: options.provider,
variants, variants,
}), }),
) )
}
} }
} }
}), }
) })
yield* events.subscribe(ModelsDev.Event.Refreshed).pipe( yield* events.subscribe(ModelsDev.Event.Refreshed).pipe(
Stream.runForEach(() => ctx.integration.reload().pipe(Effect.andThen(ctx.catalog.reload()))), Stream.runForEach(() =>
modelsDev.get().pipe(
Effect.tap((data) => Effect.sync(() => (loaded.data = data))),
Effect.andThen(ctx.integration.reload()),
Effect.andThen(ctx.catalog.reload()),
),
),
Effect.forkScoped({ startImmediately: true }), Effect.forkScoped({ startImmediately: true }),
) )
}), }),

View file

@ -1,12 +1,11 @@
export * as PluginPromise from "./promise" export * as PluginPromise from "./promise"
import { define } from "@opencode-ai/plugin/v2/effect" import { define } from "@opencode-ai/plugin/v2/effect"
import type { Plugin, PluginContext, Registration } from "@opencode-ai/plugin/v2/promise" import type { Plugin, PluginContext } from "@opencode-ai/plugin/v2/promise"
import { Effect, Scope } from "effect" import { Effect, Scope, Stream } from "effect"
// The Effect host hands back this registration shape; mirror it structurally so
// we do not have to alias the Effect package's `Registration` against the Promise one.
type HostRegistration = { readonly dispose: Effect.Effect<void> } type HostRegistration = { readonly dispose: Effect.Effect<void> }
type Registration = { readonly dispose: () => Promise<void> }
/** /**
* Adapts a Promise plugin into an Effect plugin so the existing Effect-only * Adapts a Promise plugin into an Effect plugin so the existing Effect-only
@ -31,20 +30,23 @@ export function fromPromise(plugin: Plugin) {
dispose: () => Effect.runPromiseWith(context)(registration.dispose), dispose: () => Effect.runPromiseWith(context)(registration.dispose),
})) }))
const run = (effect: Effect.Effect<void>) => Effect.runPromiseWith(context)(effect) const run = <A, E>(effect: Effect.Effect<A, E>) => Effect.runPromiseWith(context)(effect)
const transform = const transform =
<Draft>(domain: { <Draft>(domain: {
transform: ( transform: (callback: (draft: Draft) => void) => Effect.Effect<HostRegistration, never, Scope.Scope>
callback: (draft: Draft) => Effect.Effect<void> | void,
) => Effect.Effect<HostRegistration, never, Scope.Scope>
}) => }) =>
(callback: (draft: Draft) => Promise<void> | void) => (callback: (draft: Draft) => void) =>
register(domain.transform((draft) => Effect.promise(() => Promise.resolve(callback(draft))))) register(
domain.transform((draft) => {
callback(draft)
}),
)
const context2: PluginContext = { const context2: PluginContext = {
options: host.options, options: host.options,
agent: { agent: {
list: (input) => run(host.agent.list(input)),
transform: transform(host.agent), transform: transform(host.agent),
reload: () => run(host.agent.reload()), reload: () => run(host.agent.reload()),
}, },
@ -55,14 +57,33 @@ export function fromPromise(plugin: Plugin) {
register(host.aisdk.language((event) => Effect.promise(() => Promise.resolve(callback(event))))), register(host.aisdk.language((event) => Effect.promise(() => Promise.resolve(callback(event))))),
}, },
catalog: { catalog: {
provider: {
list: (input) => run(host.catalog.provider.list(input)),
get: (input) => run(host.catalog.provider.get(input)),
},
model: {
list: (input) => run(host.catalog.model.list(input)),
default: (input) => run(host.catalog.model.default(input)),
},
transform: transform(host.catalog), transform: transform(host.catalog),
reload: () => run(host.catalog.reload()), reload: () => run(host.catalog.reload()),
}, },
command: { command: {
list: (input) => run(host.command.list(input)),
transform: transform(host.command), transform: transform(host.command),
reload: () => run(host.command.reload()), reload: () => run(host.command.reload()),
}, },
event: {
subscribe: () => Stream.toAsyncIterable(host.event.subscribe()),
},
integration: { integration: {
list: (input) => run(host.integration.list(input)),
get: (input) => run(host.integration.get(input)),
connectKey: (input) => run(host.integration.connectKey(input)),
connectOauth: (input) => run(host.integration.connectOauth(input)),
attemptStatus: (input) => run(host.integration.attemptStatus(input)),
attemptComplete: (input) => run(host.integration.attemptComplete(input)),
attemptCancel: (input) => run(host.integration.attemptCancel(input)),
transform: transform(host.integration), transform: transform(host.integration),
reload: () => run(host.integration.reload()), reload: () => run(host.integration.reload()),
connection: { connection: {
@ -71,6 +92,7 @@ export function fromPromise(plugin: Plugin) {
}, },
}, },
plugin: { plugin: {
list: (input) => run(host.plugin.list(input)),
add: (input) => { add: (input) => {
const child = fromPromise(input) const child = fromPromise(input)
return run(host.plugin.add(child)) return run(host.plugin.add(child))
@ -78,13 +100,22 @@ export function fromPromise(plugin: Plugin) {
remove: (id) => run(host.plugin.remove(id)), remove: (id) => run(host.plugin.remove(id)),
}, },
reference: { reference: {
list: (input) => run(host.reference.list(input)),
transform: transform(host.reference), transform: transform(host.reference),
reload: () => run(host.reference.reload()), reload: () => run(host.reference.reload()),
}, },
skill: { skill: {
list: (input) => run(host.skill.list(input)),
transform: transform(host.skill), transform: transform(host.skill),
reload: () => run(host.skill.reload()), reload: () => run(host.skill.reload()),
}, },
session: {
create: (input) => run(host.session.create(input)),
get: (input) => run(host.session.get(input)),
prompt: (input) => run(host.session.prompt(input)),
command: (input) => run(host.session.command(input)),
interrupt: (input) => run(host.session.interrupt(input)),
},
} }
yield* Effect.promise(() => Promise.resolve(plugin.setup(context2))) yield* Effect.promise(() => Promise.resolve(plugin.setup(context2)))

View file

@ -62,22 +62,20 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) {
export const AmazonBedrockPlugin = define({ export const AmazonBedrockPlugin = define({
id: "amazon-bedrock", id: "amazon-bedrock",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform( yield* ctx.catalog.transform((evt) => {
Effect.fn(function* (evt) { for (const item of evt.provider.list()) {
for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/amazon-bedrock") continue
if (item.provider.api.package !== "@ai-sdk/amazon-bedrock") continue evt.provider.update(item.provider.id, (provider) => {
evt.provider.update(item.provider.id, (provider) => { if (provider.api.type !== "aisdk") return
if (provider.api.type !== "aisdk") return if (typeof provider.request.body.endpoint !== "string") return
if (typeof provider.request.body.endpoint !== "string") return // The AI SDK expects a base URL, but users configure Bedrock private/VPC
// The AI SDK expects a base URL, but users configure Bedrock private/VPC // endpoints as `endpoint`; move it into the catalog endpoint URL once.
// endpoints as `endpoint`; move it into the catalog endpoint URL once. provider.api.url = provider.request.body.endpoint
provider.api.url = provider.request.body.endpoint delete provider.request.body.endpoint
delete provider.request.body.endpoint })
}) }
} })
}),
)
yield* ctx.aisdk.sdk( yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return

View file

@ -4,18 +4,16 @@ import { define } from "../internal"
export const AnthropicPlugin = define({ export const AnthropicPlugin = define({
id: "anthropic", id: "anthropic",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform( yield* ctx.catalog.transform((evt) => {
Effect.fn(function* (evt) { for (const item of evt.provider.list()) {
for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/anthropic") continue
if (item.provider.api.package !== "@ai-sdk/anthropic") continue evt.provider.update(item.provider.id, (provider) => {
evt.provider.update(item.provider.id, (provider) => { provider.request.headers["anthropic-beta"] =
provider.request.headers["anthropic-beta"] = "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14" })
}) }
} })
}),
)
yield* ctx.aisdk.sdk( yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/anthropic") return if (evt.package !== "@ai-sdk/anthropic") return

View file

@ -13,21 +13,19 @@ function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
export const AzurePlugin = define({ export const AzurePlugin = define({
id: "azure", id: "azure",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform( yield* ctx.catalog.transform((evt) => {
Effect.fn(function* (evt) { for (const item of evt.provider.list()) {
for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/azure") continue
if (item.provider.api.package !== "@ai-sdk/azure") continue const configured = item.provider.request.body.resourceName
const configured = item.provider.request.body.resourceName const resourceName =
const resourceName = typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME
typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME if (!resourceName) continue
if (!resourceName) continue evt.provider.update(item.provider.id, (provider) => {
evt.provider.update(item.provider.id, (provider) => { provider.request.body.resourceName = resourceName
provider.request.body.resourceName = resourceName })
}) }
} })
}),
)
yield* ctx.aisdk.sdk( yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/azure") return if (evt.package !== "@ai-sdk/azure") return
@ -58,20 +56,18 @@ export const AzurePlugin = define({
export const AzureCognitiveServicesPlugin = define({ export const AzureCognitiveServicesPlugin = define({
id: "azure-cognitive-services", id: "azure-cognitive-services",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform( yield* ctx.catalog.transform((evt) => {
Effect.fn(function* (evt) { const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME if (!resourceName) return
if (!resourceName) return for (const item of evt.provider.list()) {
for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue if (!item.provider.id.includes("azure-cognitive-services")) continue
if (!item.provider.id.includes("azure-cognitive-services")) continue evt.provider.update(item.provider.id, (provider) => {
evt.provider.update(item.provider.id, (provider) => { provider.request.body.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai`
provider.request.body.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai` })
}) }
} })
}),
)
yield* ctx.aisdk.language( yield* ctx.aisdk.language(
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return

View file

@ -4,17 +4,15 @@ import { define } from "../internal"
export const CerebrasPlugin = define({ export const CerebrasPlugin = define({
id: "cerebras", id: "cerebras",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform( yield* ctx.catalog.transform((evt) => {
Effect.fn(function* (evt) { for (const item of evt.provider.list()) {
for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/cerebras") continue
if (item.provider.api.package !== "@ai-sdk/cerebras") continue evt.provider.update(item.provider.id, (provider) => {
evt.provider.update(item.provider.id, (provider) => { provider.request.headers["X-Cerebras-3rd-Party-Integration"] = "opencode"
provider.request.headers["X-Cerebras-3rd-Party-Integration"] = "opencode" })
}) }
} })
}),
)
yield* ctx.aisdk.sdk( yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/cerebras") return if (evt.package !== "@ai-sdk/cerebras") return

View file

@ -9,18 +9,16 @@ const providerID = ProviderV2.ID.make("cloudflare-workers-ai")
export const CloudflareWorkersAIPlugin = define({ export const CloudflareWorkersAIPlugin = define({
id: "cloudflare-workers-ai", id: "cloudflare-workers-ai",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform( yield* ctx.catalog.transform((evt) => {
Effect.fn(function* (evt) { const item = evt.provider.get(providerID)
const item = evt.provider.get(providerID) if (!item) return
if (!item) return evt.provider.update(item.provider.id, (provider) => {
evt.provider.update(item.provider.id, (provider) => { if (provider.api.type !== "aisdk") return
if (provider.api.type !== "aisdk") return if (provider.api.url) return
if (provider.api.url) return const accountId = resolveAccountId(provider.request.body)
const accountId = resolveAccountId(provider.request.body) if (accountId) provider.api.url = workersEndpoint(accountId)
if (accountId) provider.api.url = workersEndpoint(accountId) })
}) })
}),
)
yield* ctx.aisdk.sdk( yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID !== providerID) return if (evt.model.providerID !== providerID) return

View file

@ -14,17 +14,15 @@ function shouldUseResponses(modelID: string) {
export const GithubCopilotPlugin = define({ export const GithubCopilotPlugin = define({
id: "github-copilot", id: "github-copilot",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform( yield* ctx.catalog.transform((evt) => {
Effect.fn(function* (evt) { const item = evt.provider.get(ProviderV2.ID.githubCopilot)
const item = evt.provider.get(ProviderV2.ID.githubCopilot) if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return
if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => { // This chat-only alias conflicts with the Copilot GPT-5 Responses route,
// This chat-only alias conflicts with the Copilot GPT-5 Responses route, // so hide it only for Copilot rather than for every provider catalog.
// so hide it only for Copilot rather than for every provider catalog. model.enabled = false
model.enabled = false })
}) })
}),
)
yield* ctx.aisdk.sdk( yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/github-copilot") return if (evt.package !== "@ai-sdk/github-copilot") return

View file

@ -57,33 +57,31 @@ function authFetch(fetchWithRuntimeOptions?: unknown) {
export const GoogleVertexPlugin = define({ export const GoogleVertexPlugin = define({
id: "google-vertex", id: "google-vertex",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform( yield* ctx.catalog.transform((evt) => {
Effect.fn(function* (evt) { for (const item of evt.provider.list()) {
for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.type !== "aisdk") continue if (
if ( item.provider.api.package !== "@ai-sdk/google-vertex" &&
item.provider.api.package !== "@ai-sdk/google-vertex" && !(
!( item.provider.id === ProviderV2.ID.googleVertex &&
item.provider.id === ProviderV2.ID.googleVertex && item.provider.api.package.includes("@ai-sdk/openai-compatible")
item.provider.api.package.includes("@ai-sdk/openai-compatible")
)
) )
continue )
const project = resolveProject(item.provider.request.body) continue
const location = String(resolveLocation(item.provider.request.body)) const project = resolveProject(item.provider.request.body)
evt.provider.update(item.provider.id, (provider) => { const location = String(resolveLocation(item.provider.request.body))
if (project) provider.request.body.project = project evt.provider.update(item.provider.id, (provider) => {
provider.request.body.location = location if (project) provider.request.body.project = project
if (provider.api.type === "aisdk" && provider.api.url) { provider.request.body.location = location
provider.api.url = replaceVertexVars(provider.api.url, project, location) if (provider.api.type === "aisdk" && provider.api.url) {
} provider.api.url = replaceVertexVars(provider.api.url, project, location)
if (provider.api.type === "aisdk" && provider.api.package.includes("@ai-sdk/openai-compatible")) { }
provider.request.body.fetch = authFetch(provider.request.body.fetch) if (provider.api.type === "aisdk" && provider.api.package.includes("@ai-sdk/openai-compatible")) {
} provider.request.body.fetch = authFetch(provider.request.body.fetch)
}) }
} })
}), }
) })
yield* ctx.aisdk.sdk( yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) { if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) {
@ -115,28 +113,26 @@ export const GoogleVertexPlugin = define({
export const GoogleVertexAnthropicPlugin = define({ export const GoogleVertexAnthropicPlugin = define({
id: "google-vertex-anthropic", id: "google-vertex-anthropic",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform( yield* ctx.catalog.transform((evt) => {
Effect.fn(function* (evt) { for (const item of evt.provider.list()) {
for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/google-vertex/anthropic") continue
if (item.provider.api.package !== "@ai-sdk/google-vertex/anthropic") continue const project =
const project = item.provider.request.body.project ??
item.provider.request.body.project ?? process.env.GOOGLE_CLOUD_PROJECT ??
process.env.GOOGLE_CLOUD_PROJECT ?? process.env.GCP_PROJECT ??
process.env.GCP_PROJECT ?? process.env.GCLOUD_PROJECT
process.env.GCLOUD_PROJECT const location =
const location = item.provider.request.body.location ??
item.provider.request.body.location ?? process.env.GOOGLE_CLOUD_LOCATION ??
process.env.GOOGLE_CLOUD_LOCATION ?? process.env.VERTEX_LOCATION ??
process.env.VERTEX_LOCATION ?? "global"
"global" evt.provider.update(item.provider.id, (provider) => {
evt.provider.update(item.provider.id, (provider) => { if (project) provider.request.body.project = project
if (project) provider.request.body.project = project provider.request.body.location = location
provider.request.body.location = location })
}) }
} })
}),
)
yield* ctx.aisdk.sdk( yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/google-vertex/anthropic") return if (evt.package !== "@ai-sdk/google-vertex/anthropic") return

View file

@ -4,18 +4,16 @@ import { define } from "../internal"
export const KiloPlugin = define({ export const KiloPlugin = define({
id: "kilo", id: "kilo",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform( yield* ctx.catalog.transform((evt) => {
Effect.fn(function* (evt) { for (const item of evt.provider.list()) {
for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue if (item.provider.api.url !== "https://api.kilo.ai/api/gateway") continue
if (item.provider.api.url !== "https://api.kilo.ai/api/gateway") continue evt.provider.update(item.provider.id, (provider) => {
evt.provider.update(item.provider.id, (provider) => { provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/" provider.request.headers["X-Title"] = "opencode"
provider.request.headers["X-Title"] = "opencode" })
}) }
} })
}),
)
}), }),
}) })

View file

@ -6,21 +6,20 @@ export const LLMGatewayPlugin = define({
id: "llmgateway", id: "llmgateway",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
yield* ctx.catalog.transform( const configured = new Set((yield* integrations.list()).map((integration) => integration.id))
Effect.fn(function* (evt) { yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
if (item.provider.disabled) continue if (item.provider.disabled) continue
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue
if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue if (!configured.has(Integration.ID.make(item.provider.id))) continue
evt.provider.update(item.provider.id, (provider) => { evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/" provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.request.headers["X-Title"] = "opencode" provider.request.headers["X-Title"] = "opencode"
provider.request.headers["X-Source"] = "opencode" provider.request.headers["X-Source"] = "opencode"
}) })
} }
}), })
)
}), }),
}) })

View file

@ -4,19 +4,17 @@ import { define } from "../internal"
export const NvidiaPlugin = define({ export const NvidiaPlugin = define({
id: "nvidia", id: "nvidia",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform( yield* ctx.catalog.transform((evt) => {
Effect.fn(function* (evt) { for (const item of evt.provider.list()) {
for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue if (item.provider.api.url !== "https://integrate.api.nvidia.com/v1") continue
if (item.provider.api.url !== "https://integrate.api.nvidia.com/v1") continue evt.provider.update(item.provider.id, (provider) => {
evt.provider.update(item.provider.id, (provider) => { provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/" provider.request.headers["X-Title"] = "opencode"
provider.request.headers["X-Title"] = "opencode" provider.request.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode"
provider.request.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode" })
}) }
} })
}),
)
}), }),
}) })

View file

@ -177,34 +177,32 @@ export const OpenAIPlugin = define({
draft.method.update(browser) draft.method.update(browser)
draft.method.update(headless) draft.method.update(headless)
}) })
yield* ctx.catalog.transform( yield* ctx.catalog.transform((evt) => {
Effect.fn(function* (evt) { for (const item of evt.provider.list()) {
for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai") continue
if (item.provider.api.package !== "@ai-sdk/openai") continue if (!item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) continue
if (!item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) continue evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => { // OpenAIPlugin sends OpenAI models through Responses; this alias is a
// OpenAIPlugin sends OpenAI models through Responses; this alias is a // chat-completions-only model, so hide it only from OpenAI's catalog.
// chat-completions-only model, so hide it only from OpenAI's catalog. model.enabled = false
model.enabled = false })
}) }
} if (!chatgpt) return
if (!chatgpt) return const item = evt.provider.get(ProviderV2.ID.openai)
const item = evt.provider.get(ProviderV2.ID.openai) if (!item) return
if (!item) return for (const model of item.models.values()) {
for (const model of item.models.values()) { // ChatGPT-plan tokens only authorize codex-eligible models, and the
// ChatGPT-plan tokens only authorize codex-eligible models, and the // subscription covers usage, so hide the rest and zero the cost.
// subscription covers usage, so hide the rest and zero the cost. evt.model.update(item.provider.id, model.id, (draft) => {
evt.model.update(item.provider.id, model.id, (draft) => { if (!OpenAICodex.eligible(draft.api.id)) {
if (!OpenAICodex.eligible(draft.api.id)) { draft.enabled = false
draft.enabled = false return
return }
} draft.cost = []
draft.cost = [] })
}) }
} })
}),
)
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload()))) const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe( yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(

View file

@ -5,26 +5,24 @@ import { define } from "../internal"
export const OpenRouterPlugin = define({ export const OpenRouterPlugin = define({
id: "openrouter", id: "openrouter",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform( yield* ctx.catalog.transform((evt) => {
Effect.fn(function* (evt) { for (const item of evt.provider.list()) {
for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@openrouter/ai-sdk-provider") continue
if (item.provider.api.package !== "@openrouter/ai-sdk-provider") continue evt.provider.update(item.provider.id, (provider) => {
evt.provider.update(item.provider.id, (provider) => { provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/" provider.request.headers["X-Title"] = "opencode"
provider.request.headers["X-Title"] = "opencode" })
for (const modelID of [ModelV2.ID.make("gpt-5-chat-latest"), ModelV2.ID.make("openai/gpt-5-chat")]) {
if (!item.models.has(modelID)) continue
evt.model.update(item.provider.id, modelID, (model) => {
// These are OpenRouter-specific OpenAI chat aliases that do not work
// on the generic path. Keep custom providers with matching IDs untouched.
model.enabled = false
}) })
for (const modelID of [ModelV2.ID.make("gpt-5-chat-latest"), ModelV2.ID.make("openai/gpt-5-chat")]) {
if (!item.models.has(modelID)) continue
evt.model.update(item.provider.id, modelID, (model) => {
// These are OpenRouter-specific OpenAI chat aliases that do not work
// on the generic path. Keep custom providers with matching IDs untouched.
model.enabled = false
})
}
} }
}), }
) })
yield* ctx.aisdk.sdk( yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@openrouter/ai-sdk-provider") return if (evt.package !== "@openrouter/ai-sdk-provider") return

View file

@ -4,18 +4,16 @@ import { define } from "../internal"
export const VercelPlugin = define({ export const VercelPlugin = define({
id: "vercel", id: "vercel",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform( yield* ctx.catalog.transform((evt) => {
Effect.fn(function* (evt) { for (const item of evt.provider.list()) {
for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/vercel") continue
if (item.provider.api.package !== "@ai-sdk/vercel") continue evt.provider.update(item.provider.id, (provider) => {
evt.provider.update(item.provider.id, (provider) => { provider.request.headers["http-referer"] = "https://opencode.ai/"
provider.request.headers["http-referer"] = "https://opencode.ai/" provider.request.headers["x-title"] = "opencode"
provider.request.headers["x-title"] = "opencode" })
}) }
} })
}),
)
yield* ctx.aisdk.sdk( yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/vercel") return if (evt.package !== "@ai-sdk/vercel") return

View file

@ -4,18 +4,16 @@ import { define } from "../internal"
export const ZenmuxPlugin = define({ export const ZenmuxPlugin = define({
id: "zenmux", id: "zenmux",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform( yield* ctx.catalog.transform((evt) => {
Effect.fn(function* (evt) { for (const item of evt.provider.list()) {
for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue if (item.provider.api.url !== "https://zenmux.ai/api/v1") continue
if (item.provider.api.url !== "https://zenmux.ai/api/v1") continue evt.provider.update(item.provider.id, (provider) => {
evt.provider.update(item.provider.id, (provider) => { provider.request.headers["HTTP-Referer"] ??= "https://opencode.ai/"
provider.request.headers["HTTP-Referer"] ??= "https://opencode.ai/" provider.request.headers["X-Title"] ??= "opencode"
provider.request.headers["X-Title"] ??= "opencode" })
}) }
} })
}),
)
}), }),
}) })

View file

@ -1,48 +0,0 @@
export * as Policy from "./policy"
import { makeLocationNode } from "./effect/app-node"
import { Context, Effect, Layer, Schema } from "effect"
import { Wildcard } from "./util/wildcard"
import { Location } from "./location"
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: PolicyEffect,
resource: Schema.String,
}) {}
export interface Interface {
readonly load: (statements: Info[]) => Effect.Effect<void>
readonly evaluate: (action: string, resource: string, fallback: Effect) => Effect.Effect<Effect>
readonly hasStatements: () => boolean
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Policy") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
let statements: Info[] = []
yield* Location.Service
return Service.of({
load: Effect.fn("Policy.load")(function* (input) {
statements = input
}),
hasStatements: () => statements.length > 0,
evaluate: Effect.fn("Policy.evaluate")(function* (action, resource, fallback) {
return (
statements.findLast(
(statement) => Wildcard.match(action, statement.action) && Wildcard.match(resource, statement.resource),
)?.effect ?? fallback
)
}),
})
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] })

View file

@ -41,8 +41,8 @@ import type { EventLog } from "@opencode-ai/schema/event-log"
import { SkillV2 } from "./skill" import { SkillV2 } from "./skill"
import { Job } from "./job" import { Job } from "./job"
import { CommandV2 } from "./command" import { CommandV2 } from "./command"
import { Identifier } from "./util/identifier"
import { Shell } from "./shell" import { Shell } from "./shell"
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
import { KeyedMutex } from "./effect/keyed-mutex" import { KeyedMutex } from "./effect/keyed-mutex"
const SUBAGENT_COMMAND_STARTED = const SUBAGENT_COMMAND_STARTED =
@ -276,19 +276,6 @@ 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({ const result = Service.of({
create: Effect.fn("V2Session.create")(function* (input) { create: Effect.fn("V2Session.create")(function* (input) {
const sessionID = input.id ?? SessionSchema.ID.create() const sessionID = input.id ?? SessionSchema.ID.create()
@ -617,23 +604,38 @@ const layer = Layer.effect(
Effect.gen(function* () { Effect.gen(function* () {
activeShells.add(input.sessionID) activeShells.add(input.sessionID)
if ((yield* execution.active).has(input.sessionID)) yield* execution.awaitIdle(input.sessionID) if ((yield* execution.active).has(input.sessionID)) yield* execution.awaitIdle(input.sessionID)
const callID = Identifier.ascending() const started = yield* Effect.gen(function* () {
const shell = yield* Shell.Service
return yield* shell.create({ command: input.command, cwd: session.location.directory })
}).pipe(Effect.provide(locations.get(session.location)))
yield* events.publish( yield* events.publish(
SessionEvent.Shell.Started, SessionEvent.Shell.Started,
{ {
sessionID: input.sessionID, sessionID: input.sessionID,
callID, shell: started,
command: input.command,
}, },
{ id: input.id }, { id: input.id },
) )
const output = yield* runShellCommand(input.command, session.location.directory).pipe( const completed = yield* Effect.gen(function* () {
Effect.provide(locations.get(session.location)), const shell = yield* Shell.Service
) const terminal = yield* shell.wait(started.id).pipe(
Effect.map((info) => ({ info, retained: true as const })),
Effect.catchTag("Shell.NotFoundError", () =>
Effect.succeed({ info: synthesizeTerminalShellInfo(started), retained: false as const }),
),
)
const output = terminal.retained
? yield* shell
.output(started.id, { limit: SHELL_MAX_CAPTURE_BYTES })
.pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(missingShellOutput())))
: missingShellOutput()
return { shell: terminal.info, output }
})
.pipe(Effect.provide(locations.get(session.location)))
yield* events.publish(SessionEvent.Shell.Ended, { yield* events.publish(SessionEvent.Shell.Ended, {
sessionID: input.sessionID, sessionID: input.sessionID,
callID, shell: completed.shell,
output, output: completed.output,
}) })
}).pipe( }).pipe(
Effect.ensuring( Effect.ensuring(
@ -773,6 +775,26 @@ const layer = Layer.effect(
}), }),
) )
function missingShellOutput() {
const output = "Shell command output is no longer available."
return {
output,
cursor: Buffer.byteLength(output),
size: Buffer.byteLength(output),
truncated: false,
}
}
function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Info {
return {
...started,
// The Shell record was removed before waiters could observe it; publish a terminal
// boundary instead of leaving the Session shell message permanently running.
status: "killed",
time: { ...started.time, completed: Date.now() },
}
}
const resolvePrompt = (input: PromptInput.Prompt) => const resolvePrompt = (input: PromptInput.Prompt) =>
Prompt.make({ Prompt.make({
text: input.text, text: input.text,

View file

@ -129,7 +129,7 @@ const serialize = (message: SessionMessage.Message) => {
if (message.type === "system") return `[System update]: ${message.text}` if (message.type === "system") return `[System update]: ${message.text}`
if (message.type === "synthetic") return `[Synthetic context]: ${message.text}` if (message.type === "synthetic") return `[Synthetic context]: ${message.text}`
if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}` if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}`
if (message.type === "shell") return `[Shell]: ${message.command}\n${truncate(message.output)}` if (message.type === "shell") return `[Shell]: ${message.shell.command}\n${truncate(message.output?.output ?? "")}`
return "" return ""
} }

View file

@ -18,7 +18,7 @@ const decodeApplied = Schema.decodeUnknownOption(SystemContext.Applied)
* Loads or creates the session's durable context checkpoint, narrating any * Loads or creates the session's durable context checkpoint, narrating any
* drift since the model was last told as a chronological update. Completed * drift since the model was last told as a chronological update. Completed
* compaction rebaselines; nothing else rewrites the baseline. Runs before * 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* ( export const prepare = Effect.fn("SessionContextCheckpoint.prepare")(function* (
db: DatabaseService, db: DatabaseService,

View file

@ -20,7 +20,7 @@ const layer = Layer.effect(
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) { drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
const session = yield* store.get(sessionID) const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`)) if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force })).pipe( return yield* SessionRunner.Service.use((runner) => runner.drain({ sessionID, force })).pipe(
Effect.provide(locations.get(session.location)), Effect.provide(locations.get(session.location)),
Effect.tapCause((cause) => Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause) Cause.hasInterruptsOnly(cause)

View file

@ -34,7 +34,7 @@ const messageRows = Effect.fnUntraced(function* (
and( and(
eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.session_id, sessionID),
// Keep system updates visible in the gap between a completed compaction // 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. // folded into a new baseline.
compaction compaction
? or( ? or(

View file

@ -36,9 +36,9 @@ const layer = Layer.effect(
// absolute paths, but the human-facing description shows paths relative to the project // 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. // root so opening a subdirectory still describes paths from the project root.
const root = yield* fs.resolve(location.project.directory) const root = yield* fs.resolve(location.project.directory)
// Same-turn parallel reads settle concurrently, so an in-memory claim guards each // 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 // 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 injected = yield* Ref.make<Map<SessionSchema.ID, Set<string>>>(new Map())
const load = Effect.fn("SessionInstructions.load")(function* (input: { const load = Effect.fn("SessionInstructions.load")(function* (input: {

View file

@ -8,21 +8,25 @@ export type MemoryState = {
} }
export interface Adapter { export interface Adapter {
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined> readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
readonly getAssistant: (messageID: SessionMessage.ID) => Effect.Effect<SessionMessage.Assistant | undefined> readonly getAssistant: (
readonly getCurrentShell: (callID: string) => Effect.Effect<SessionMessage.Shell | undefined> messageID: SessionMessage.ID,
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void> ) => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void> readonly getShell: (
readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect<void> shellID: SessionMessage.Shell["shell"]["id"],
) => Effect.Effect<SessionMessage.Shell | undefined, never, never>
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void, never, never>
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void, never, never>
readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect<void, never, never>
} }
export function memory(state: MemoryState): Adapter { export function memory(state: MemoryState): Adapter {
const assistantIndex = (messageID: SessionMessage.ID) => const assistantIndex = (messageID: SessionMessage.ID) =>
state.messages.findLastIndex((message) => message.id === messageID) state.messages.findLastIndex((message) => message.id === messageID)
// A newer turn supersedes stale incomplete rows; never resume an older assistant projection. const shellIndex = (messageID: SessionMessage.ID) =>
state.messages.findLastIndex((message) => message.id === messageID)
// A newer step supersedes stale incomplete rows; never resume an older assistant projection.
const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant") const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant")
const activeShellIndex = (callID: string) =>
state.messages.findLastIndex((message) => message.type === "shell" && message.callID === callID)
return { return {
getCurrentAssistant() { getCurrentAssistant() {
@ -41,12 +45,11 @@ export function memory(state: MemoryState): Adapter {
return assistant?.type === "assistant" ? assistant : undefined return assistant?.type === "assistant" ? assistant : undefined
}) })
}, },
getCurrentShell(callID) { getShell(shellID) {
return Effect.sync(() => { return Effect.sync(() => {
const index = activeShellIndex(callID) return state.messages.find((message): message is SessionMessage.Shell => {
if (index < 0) return return message.type === "shell" && message.shell.id === shellID
const shell = state.messages[index] })
return shell?.type === "shell" ? shell : undefined
}) })
}, },
updateAssistant(assistant) { updateAssistant(assistant) {
@ -60,7 +63,7 @@ export function memory(state: MemoryState): Adapter {
}, },
updateShell(shell) { updateShell(shell) {
return Effect.sync(() => { return Effect.sync(() => {
const index = activeShellIndex(shell.callID) const index = shellIndex(shell.id)
if (index < 0) return if (index < 0) return
const current = state.messages[index] const current = state.messages[index]
if (current?.type !== "shell") return if (current?.type !== "shell") return
@ -100,7 +103,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return Effect.gen(function* () { return Effect.gen(function* () {
yield* SessionEvent.All.match(event, { yield* SessionEvent.All.match(event, {
"agent.selected": (event) => { "session.agent.selected": (event) => {
return adapter.appendMessage( return adapter.appendMessage(
SessionMessage.AgentSelected.make({ SessionMessage.AgentSelected.make({
id: SessionMessage.ID.fromEvent(event.id), id: SessionMessage.ID.fromEvent(event.id),
@ -111,7 +114,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}), }),
) )
}, },
"model.selected": (event) => { "session.model.selected": (event) => {
return adapter.appendMessage( return adapter.appendMessage(
SessionMessage.ModelSelected.make({ SessionMessage.ModelSelected.make({
id: SessionMessage.ID.fromEvent(event.id), id: SessionMessage.ID.fromEvent(event.id),
@ -123,11 +126,11 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
) )
}, },
"session.moved": () => Effect.void, "session.moved": () => Effect.void,
renamed: () => Effect.void, "session.renamed": () => Effect.void,
forked: () => Effect.void, "session.forked": () => Effect.void,
"prompt.promoted": () => Effect.void, "session.prompt.promoted": () => Effect.void,
"prompt.admitted": () => Effect.void, "session.prompt.admitted": () => Effect.void,
"execution.settled": () => Effect.void, "session.execution.settled": () => Effect.void,
"session.context.updated": (event) => "session.context.updated": (event) =>
adapter.appendMessage( adapter.appendMessage(
SessionMessage.System.make({ SessionMessage.System.make({
@ -137,7 +140,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
time: { created: event.created }, time: { created: event.created },
}), }),
), ),
synthetic: (event) => { "session.synthetic": (event) => {
return adapter.appendMessage( return adapter.appendMessage(
SessionMessage.Synthetic.make({ SessionMessage.Synthetic.make({
sessionID: event.data.sessionID, sessionID: event.data.sessionID,
@ -150,7 +153,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}), }),
) )
}, },
"skill.activated": (event) => { "session.skill.activated": (event) => {
return adapter.appendMessage( return adapter.appendMessage(
SessionMessage.Skill.make({ SessionMessage.Skill.make({
id: SessionMessage.ID.fromEvent(event.id), id: SessionMessage.ID.fromEvent(event.id),
@ -161,25 +164,24 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}), }),
) )
}, },
"shell.started": (event) => { "session.shell.started": (event) => {
return adapter.appendMessage( return adapter.appendMessage(
SessionMessage.Shell.make({ SessionMessage.Shell.make({
id: SessionMessage.ID.fromEvent(event.id), id: SessionMessage.ID.fromEvent(event.id),
type: "shell", type: "shell",
metadata: event.metadata, metadata: event.metadata,
callID: event.data.callID, shell: event.data.shell,
command: event.data.command,
output: "",
time: { created: event.created }, time: { created: event.created },
}), }),
) )
}, },
"shell.ended": (event) => { "session.shell.ended": (event) => {
return Effect.gen(function* () { return Effect.gen(function* () {
const currentShell = yield* adapter.getCurrentShell(event.data.callID) const currentShell = yield* adapter.getShell(event.data.shell.id)
if (currentShell) { if (currentShell) {
yield* adapter.updateShell( yield* adapter.updateShell(
produce(currentShell, (draft) => { produce(currentShell, (draft) => {
draft.shell = castDraft(event.data.shell)
draft.output = event.data.output draft.output = event.data.output
draft.time.completed = event.created draft.time.completed = event.created
}), }),
@ -187,7 +189,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
} }
}) })
}, },
"step.started": (event) => { "session.step.started": (event) => {
return Effect.gen(function* () { return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant() const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) { if (currentAssistant) {
@ -210,7 +212,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
) )
}) })
}, },
"step.ended": (event) => { "session.step.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = event.created draft.time.completed = event.created
draft.finish = event.data.finish draft.finish = event.data.finish
@ -224,33 +226,33 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
} }
}) })
}, },
"step.failed": (event) => { "session.step.failed": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = event.created draft.time.completed = event.created
draft.finish = "error" draft.finish = "error"
draft.error = event.data.error draft.error = event.data.error
}) })
}, },
"text.started": (event) => { "session.text.started": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.content.push( draft.content.push(
castDraft(SessionMessage.AssistantText.make({ type: "text", id: event.data.textID, text: "" })), castDraft(SessionMessage.AssistantText.make({ type: "text", id: event.data.textID, text: "" })),
) )
}) })
}, },
"text.delta": (event) => { "session.text.delta": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestText(draft, event.data.textID) const match = latestText(draft, event.data.textID)
if (match) match.text += event.data.delta if (match) match.text += event.data.delta
}) })
}, },
"text.ended": (event) => { "session.text.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestText(draft, event.data.textID) const match = latestText(draft, event.data.textID)
if (match) match.text = event.data.text if (match) match.text = event.data.text
}) })
}, },
"tool.input.started": (event) => { "session.tool.input.started": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.content.push( draft.content.push(
castDraft( castDraft(
@ -265,14 +267,14 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
) )
}) })
}, },
"tool.input.delta": () => Effect.void, "session.tool.input.delta": () => Effect.void,
"tool.input.ended": (event) => { "session.tool.input.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID) const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "pending") match.state.input = event.data.text if (match && match.state.status === "pending") match.state.input = event.data.text
}) })
}, },
"tool.called": (event) => { "session.tool.called": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID) const match = latestTool(draft, event.data.callID)
if (match) { if (match) {
@ -289,7 +291,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
} }
}) })
}, },
"tool.progress": (event) => { "session.tool.progress": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID) const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "running") { if (match && match.state.status === "running") {
@ -298,7 +300,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
} }
}) })
}, },
"tool.success": (event) => { "session.tool.success": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID) const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "running") { if (match && match.state.status === "running") {
@ -321,7 +323,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
} }
}) })
}, },
"tool.failed": (event) => { "session.tool.failed": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID) const match = latestTool(draft, event.data.callID)
if (match && (match.state.status === "pending" || match.state.status === "running")) { if (match && (match.state.status === "pending" || match.state.status === "running")) {
@ -344,7 +346,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
} }
}) })
}, },
"reasoning.started": (event) => { "session.reasoning.started": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.content.push( draft.content.push(
castDraft( castDraft(
@ -359,13 +361,13 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
) )
}) })
}, },
"reasoning.delta": (event) => { "session.reasoning.delta": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestReasoning(draft, event.data.reasoningID) const match = latestReasoning(draft, event.data.reasoningID)
if (match) match.text += event.data.delta if (match) match.text += event.data.delta
}) })
}, },
"reasoning.ended": (event) => { "session.reasoning.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestReasoning(draft, event.data.reasoningID) const match = latestReasoning(draft, event.data.reasoningID)
if (match) { if (match) {
@ -375,10 +377,10 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
} }
}) })
}, },
retried: () => Effect.void, "session.retried": () => Effect.void,
"compaction.started": () => Effect.void, "session.compaction.started": () => Effect.void,
"compaction.delta": () => Effect.void, "session.compaction.delta": () => Effect.void,
"compaction.ended": (event) => { "session.compaction.ended": (event) => {
return adapter.appendMessage( return adapter.appendMessage(
SessionMessage.Compaction.make({ SessionMessage.Compaction.make({
id: SessionMessage.ID.fromEvent(event.id), id: SessionMessage.ID.fromEvent(event.id),
@ -391,9 +393,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}), }),
) )
}, },
"revert.staged": () => Effect.void, "session.revert.staged": () => Effect.void,
"revert.cleared": () => Effect.void, "session.revert.cleared": () => Effect.void,
"revert.committed": () => Effect.void, "session.revert.committed": () => Effect.void,
}) })
}) })
} }

View file

@ -168,7 +168,8 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
.get() .get()
.pipe(Effect.orDie) .pipe(Effect.orDie)
: undefined : undefined
if (event.data.from && !boundary) return yield* Effect.die(new Error(`Fork boundary message not found: ${event.data.from}`)) if (event.data.from && !boundary)
return yield* Effect.die(new Error(`Fork boundary message not found: ${event.data.from}`))
const copied = yield* db const copied = yield* db
.select({ seq: SessionMessageTable.seq }) .select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable) .from(SessionMessageTable)
@ -357,7 +358,7 @@ function run(db: DatabaseService, event: MessageEvent) {
const adapter: SessionMessageUpdater.Adapter = { const adapter: SessionMessageUpdater.Adapter = {
getCurrentAssistant() { getCurrentAssistant() {
return Effect.gen(function* () { 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 const row = yield* db
.select() .select()
.from(SessionMessageTable) .from(SessionMessageTable)
@ -392,18 +393,25 @@ function run(db: DatabaseService, event: MessageEvent) {
return message.type === "assistant" ? message : undefined return message.type === "assistant" ? message : undefined
}) })
}, },
getCurrentShell(callID) { getShell(shellID) {
return Effect.gen(function* () { return Effect.gen(function* () {
const rows = yield* db const row = yield* db
.select() .select()
.from(SessionMessageTable) .from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "shell"))) .where(
and(
eq(SessionMessageTable.session_id, event.data.sessionID),
eq(SessionMessageTable.type, "shell"),
sql`json_extract(${SessionMessageTable.data}, '$.shell.id') = ${shellID}`,
),
)
.orderBy(desc(SessionMessageTable.seq)) .orderBy(desc(SessionMessageTable.seq))
.all() .limit(1)
.get()
.pipe(Effect.orDie) .pipe(Effect.orDie)
return rows if (!row) return
.map(decodeRow) const message = decodeRow(row)
.find((message): message is SessionMessage.Shell => message.type === "shell" && message.callID === callID) return message.type === "shell" ? message : undefined
}) })
}, },
updateAssistant: updateMessage, updateAssistant: updateMessage,

View file

@ -9,16 +9,12 @@ import type { SystemContext } from "../../system-context/index"
import type { ToolOutputStore } from "../../tool-output-store" import type { ToolOutputStore } from "../../tool-output-store"
export type RunError = export type RunError =
| LLMError LLMError | SessionRunnerModel.Error | MessageDecodeError | SystemContext.InitializationBlocked | ToolOutputStore.Error
| SessionRunnerModel.Error
| MessageDecodeError
| SystemContext.InitializationBlocked
| ToolOutputStore.Error
/** Runs one local continuation from already-recorded Session history. */ /** Runs one local continuation from already-recorded Session history. */
export interface Interface { export interface Interface {
/** Drains eligible durable work. Explicit runs perform one provider attempt even when no work is eligible. */ /** Drains eligible durable work. Explicit runs perform one physical attempt even when no work is eligible. */
readonly run: (input: { readonly drain: (input: {
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
readonly force: boolean readonly force: boolean
}) => Effect.Effect<void, RunError> }) => Effect.Effect<void, RunError>

View file

@ -61,11 +61,11 @@ import { llmClient } from "../../effect/app-node-platform"
* - Runtime context assembly * - Runtime context assembly
* - Track V1 runtime-context parity canonically in `specs/v2/session.md`. * - 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 * - [x] Translate every projected V2 Session message variant into canonical
* `@opencode-ai/llm` messages. * `@opencode-ai/llm` messages.
* - [ ] Resolve policy-filtered built-in, MCP, plugin, and structured-output tool definitions. * - [ ] 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. * - [x] Persist assistant text and usage events incrementally as they arrive.
* - [ ] Persist snapshots, patches, and retry notices 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. * - [x] Persist reasoning, provider errors, and tool-call events incrementally as they arrive.
@ -77,8 +77,8 @@ import { llmClient } from "../../effect/app-node-platform"
* - [x] Start each recorded local call eagerly and await all settlements before continuation. * - [x] Start each recorded local call eagerly and await all settlements before continuation.
* - [ ] Add scoped runtime context, progress updates, attachment normalization, * - [ ] Add scoped runtime context, progress updates, attachment normalization,
* plugins, and cancellation settlement. * plugins, and cancellation settlement.
* - [x] Reload projected history and start the next explicit provider turn after local tool results. * - [x] Reload projected history and start the next explicit step after local tool results.
* - [x] Continue for durable user steering accepted during an active provider turn. * - [x] Continue for durable user steering accepted during an active step.
* - [ ] Continue for compaction or another continuation condition when required. * - [ ] Continue for compaction or another continuation condition when required.
* *
* - Post-run maintenance * - Post-run maintenance
@ -86,12 +86,12 @@ import { llmClient } from "../../effect/app-node-platform"
* - [ ] Coalesce streamed deltas and add covering projected-history indexes. * - [ ] Coalesce streamed deltas and add covering projected-history indexes.
* - [ ] Update title, summaries, compaction state, and cleanup in bounded background work. * - [ ] 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. * 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 * 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 * step. 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. * explicit loop starts the next step after local settlement. Configured agent step limits bound the loop.
*/ */
const layer = Layer.effect( const layer = Layer.effect(
@ -114,7 +114,7 @@ const layer = Layer.effect(
const db = (yield* Database.Service).db const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service const compaction = yield* SessionCompaction.Service
const title = yield* SessionTitle.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 // 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. // re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history.
const titleAttempted = new Set<SessionSchema.ID>() const titleAttempted = new Set<SessionSchema.ID>()
@ -166,7 +166,7 @@ const layer = Layer.effect(
{ concurrency: "unbounded" }, { concurrency: "unbounded" },
).pipe(Effect.map(SystemContext.combine)) ).pipe(Effect.map(SystemContext.combine))
const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* ( const attemptStep = Effect.fn("SessionRunner.attemptStep")(function* (
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
promotion: SessionInput.Delivery | undefined, promotion: SessionInput.Delivery | undefined,
step: number, step: number,
@ -177,7 +177,7 @@ const layer = Layer.effect(
return yield* Effect.interrupt return yield* Effect.interrupt
const agent = yield* agents.select(session.agent) const agent = yield* agents.select(session.agent)
// Establish what the model knows before admitting what the user said, so // 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( const checkpoint = yield* SessionContextCheckpoint.prepare(
db, db,
events, events,
@ -231,7 +231,7 @@ const layer = Layer.effect(
snapshot: startSnapshot, snapshot: startSnapshot,
}) })
const publication = Semaphore.makeUnsafe(1) 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. // mid-event.
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect) const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) => const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
@ -282,7 +282,7 @@ const layer = Layer.effect(
Effect.ensuring(serialized(publisher.flush())), 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. // assistant step.
const publishStepEnd = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) => const publishStepEnd = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) =>
Effect.gen(function* () { Effect.gen(function* () {
@ -316,7 +316,7 @@ const layer = Layer.effect(
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause) const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
// A context overflow before any assistant output is recoverable: compact and // 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 ( if (
recoverOverflow && recoverOverflow &&
!publisher.hasAssistantStarted() && !publisher.hasAssistantStarted() &&
@ -325,7 +325,7 @@ const layer = Layer.effect(
) )
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const 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 // thrown LLM failure fails hosted tool calls and the assistant unless a provider
// error was already recorded from the stream. // error was already recorded from the stream.
if (overflowFailure) yield* publish(overflowFailure) if (overflowFailure) yield* publish(overflowFailure)
@ -346,12 +346,12 @@ const layer = Layer.effect(
if (questionDismissed || streamInterrupted || toolsInterrupted) { if (questionDismissed || streamInterrupted || toolsInterrupted) {
yield* FiberSet.clear(toolFibers) yield* FiberSet.clear(toolFibers)
yield* serialized(publisher.failUnsettledTools("Tool execution interrupted")) 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. // Match V1: dismissing a question halts the loop like an interruption.
if (questionDismissed) return yield* Effect.interrupt if (questionDismissed) return yield* Effect.interrupt
} }
// A settled tool fiber failure is one of two things. A defect from a tool // 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 // 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. // could not be persisted) also fails the assistant and then fails the drain.
const settledFailure = settled._tag === "Failure" && !toolsInterrupted ? settled.cause : undefined const settledFailure = settled._tag === "Failure" && !toolsInterrupted ? settled.cause : undefined
@ -387,7 +387,7 @@ const layer = Layer.effect(
) )
}, Effect.scoped) }, Effect.scoped)
const runTurn = Effect.fnUntraced(function* ( const runStep = Effect.fnUntraced(function* (
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
promotion: SessionInput.Delivery | undefined, promotion: SessionInput.Delivery | undefined,
step: number, step: number,
@ -399,7 +399,7 @@ const layer = Layer.effect(
let currentPromotion = promotion let currentPromotion = promotion
let currentStep = step let currentStep = step
while (true) { 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 === "Completed") return { needsContinuation: attempt.needsContinuation, step: attempt.step }
if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined
yield* Effect.yieldNow yield* Effect.yieldNow
@ -410,7 +410,7 @@ const layer = Layer.effect(
// ExecutionSettled is published per execution (busy period) by SessionExecution, not per // ExecutionSettled is published per execution (busy period) by SessionExecution, not per
// drain here. // drain here.
const run = Effect.fn("SessionRunner.run")(function* (input: { const drain = Effect.fn("SessionRunner.drain")(function* (input: {
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
readonly force: boolean readonly force: boolean
}) { }) {
@ -423,9 +423,13 @@ const layer = Layer.effect(
while (shouldRun) { while (shouldRun) {
let needsContinuation = true let needsContinuation = true
let step = 1 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) { while (needsContinuation) {
const result = yield* runTurn(input.sessionID, promotion, step) const result = yield* runStep(input.sessionID, promotion, step)
// Steer/queue promotion inside runTurn has already made the pending input a visible // 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. // user message by this point, so the first-user-message check below is reliable.
if (!titleAttempted.has(input.sessionID)) { if (!titleAttempted.has(input.sessionID)) {
titleAttempted.add(input.sessionID) titleAttempted.add(input.sessionID)
@ -441,7 +445,7 @@ const layer = Layer.effect(
} }
}) })
return Service.of({ run }) return Service.of({ drain })
}), }),
) )

View file

@ -50,7 +50,7 @@ const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue):
return { structured: record(settled.structured), content: settled.content } 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) => { export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) => {
const tools = new Map< const tools = new Map<
string, string,

View file

@ -139,7 +139,7 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
Message.make({ Message.make({
id: message.id, id: message.id,
role: "user", role: "user",
content: `Shell command: ${message.command}\n\n${message.output}`, content: `Shell command: ${message.shell.command}\n\n${message.output?.output ?? ""}`,
metadata: message.metadata, metadata: message.metadata,
}), }),
] ]

View file

@ -3,7 +3,7 @@ export * as SkillV2 from "./skill"
import { makeLocationNode } from "./effect/app-node" import { makeLocationNode } from "./effect/app-node"
import path from "path" import path from "path"
import { Context, Effect, Layer, Schema, Stream, Types } from "effect" import { Context, Effect, Layer, Schema, Stream, Types } from "effect"
import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher" import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Skill } from "@opencode-ai/schema/skill" import { Skill } from "@opencode-ai/schema/skill"
import { AgentV2 } from "./agent" import { AgentV2 } from "./agent"
import { ConfigMarkdown } from "./config/markdown" import { ConfigMarkdown } from "./config/markdown"
@ -153,7 +153,7 @@ const layer = Layer.effect(
yield* events.publish(Event.Updated, {}).pipe(Effect.asVoid) yield* events.publish(Event.Updated, {}).pipe(Effect.asVoid)
}) })
yield* events.subscribe(FileSystemWatcher.Event.Updated).pipe( yield* events.subscribe(FileSystem.Event.Changed).pipe(
Stream.runForEach((event) => invalidate(event.data.file)), Stream.runForEach((event) => invalidate(event.data.file)),
Effect.forkScoped({ startImmediately: true }), Effect.forkScoped({ startImmediately: true }),
) )

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: * The durable `Applied` record tracks what the model was last told, per source:
* it is the model's current belief. Interpreters uphold one invariant * it is the model's current belief. Interpreters uphold one invariant
* `reconcile` never rewrites the baseline; it only narrates drift as update * `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. * baseline text.
* *
* Returning `unavailable` means observation failed temporarily. It differs from * Returning `unavailable` means observation failed temporarily. It differs from

View file

@ -2,7 +2,6 @@ export * as ConfigV1 from "./config"
import { Schema } from "effect" import { Schema } from "effect"
import { NonNegativeInt, PositiveInt, type DeepMutable } from "../../schema" import { NonNegativeInt, PositiveInt, type DeepMutable } from "../../schema"
import { ConfigExperimental } from "../../config/experimental"
import { ConfigReference } from "../../config/reference" import { ConfigReference } from "../../config/reference"
import { ConfigAgentV1 } from "./agent" import { ConfigAgentV1 } from "./agent"
import { ConfigAttachmentV1 } from "./attachment" import { ConfigAttachmentV1 } from "./attachment"
@ -179,9 +178,6 @@ export const Info = Schema.Struct({
mcp_timeout: Schema.optional(PositiveInt).annotate({ mcp_timeout: Schema.optional(PositiveInt).annotate({
description: "Timeout in milliseconds for model context protocol (MCP) requests", description: "Timeout in milliseconds for model context protocol (MCP) requests",
}), }),
policies: Schema.optional(Schema.mutable(Schema.Array(ConfigExperimental.Policy))).annotate({
description: "Policy statements applied to supported resources, such as provider access",
}),
}), }),
), ),
}).annotate({ identifier: "Config" }) }).annotate({ identifier: "Config" })

View file

@ -78,7 +78,6 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
plugins: info.plugin?.map((plugin) => plugins: info.plugin?.map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] }, typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
), ),
experimental: info.experimental?.policies && { policies: info.experimental.policies },
providers: providers(info.provider), providers: providers(info.provider),
} }
} }

View file

@ -8,7 +8,6 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { Policy } from "@opencode-ai/core/policy"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location" import { location } from "./fixture/location"
@ -24,7 +23,7 @@ const locationLayer = Layer.succeed(
Location.Service.of(location({ directory: AbsolutePath.make("test") })), Location.Service.of(location({ directory: AbsolutePath.make("test") })),
) )
const catalogLayer = AppNodeBuilder.build( const catalogLayer = AppNodeBuilder.build(
LayerNode.group([Catalog.node, EventV2.node, Credential.node, Integration.node, Policy.node]), LayerNode.group([Catalog.node, EventV2.node, Credential.node, Integration.node]),
[[Location.node, locationLayer]], [[Location.node, locationLayer]],
) )
const it = testEffect(catalogLayer) const it = testEffect(catalogLayer)
@ -333,21 +332,4 @@ describe("CatalogV2", () => {
expect((yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini") expect((yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini")
}), }),
) )
it.effect("removes providers denied by policy after loading", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const policy = yield* Policy.Service
const providerID = ProviderV2.ID.make("blocked")
yield* policy.load([new Policy.Info({ effect: "deny", action: "provider.use", resource: "blocked" })])
yield* catalog.transform((catalog) => {
catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, ModelV2.ID.make("model"), () => {})
})
expect(yield* catalog.provider.all()).toEqual([])
expect(yield* catalog.model.all()).toEqual([])
expect(yield* catalog.provider.get(providerID)).toBeUndefined()
}),
)
}) })

View file

@ -1,13 +1,15 @@
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect" import { Effect, PubSub, Schema, Stream } from "effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { CommandV2 } from "@opencode-ai/core/command" import { CommandV2 } from "@opencode-ai/core/command"
import { Config } from "@opencode-ai/core/config" import { Config } from "@opencode-ai/core/config"
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command" import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index" import { MCP } from "@opencode-ai/core/mcp/index"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
@ -19,7 +21,7 @@ import { testEffect } from "../lib/effect"
import { host } from "../plugin/host" import { host } from "../plugin/host"
const it = testEffect( const it = testEffect(
AppNodeBuilder.build(LayerNode.group([CommandV2.node, FSUtil.node]), [ AppNodeBuilder.build(LayerNode.group([CommandV2.node, EventV2.node, FSUtil.node]), [
[MCP.node, emptyMcpLayer], [MCP.node, emptyMcpLayer],
[Config.node, emptyConfigLayer], [Config.node, emptyConfigLayer],
[Location.node, testLocationLayer], [Location.node, testLocationLayer],
@ -60,7 +62,19 @@ Legacy review`,
}) })
const command = yield* CommandV2.Service const command = yield* CommandV2.Service
yield* ConfigCommandPlugin.Plugin.effect(host({ command: { ...command, reload: command.reload } })).pipe( const events = yield* EventV2.Service
const update = yield* events.publish(ConfigSchema.Event.Updated, {})
const updates = yield* PubSub.unbounded<typeof update>()
yield* ConfigCommandPlugin.Plugin.effect(
host({
command: {
list: () => Effect.die("unused command.list"),
transform: command.transform,
reload: command.reload,
},
event: { subscribe: () => Stream.fromPubSub(updates) },
}),
).pipe(
Effect.provideService( Effect.provideService(
Config.Service, Config.Service,
Config.Service.of({ Config.Service.of({
@ -93,6 +107,15 @@ Legacy review`,
CommandV2.Info.make({ name: "legacy", template: "Legacy review", subagent: true }), CommandV2.Info.make({ name: "legacy", template: "Legacy review", subagent: true }),
CommandV2.Info.make({ name: "nested/docs", template: "Write docs" }), CommandV2.Info.make({ name: "nested/docs", template: "Write docs" }),
]) ])
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "commands", "review.md"), "Review again"))
yield* Effect.sleep("10 millis")
yield* PubSub.publish(updates, update)
for (let attempt = 0; attempt < 100; attempt++) {
if ((yield* command.get("review"))?.template === "Review again") break
yield* Effect.sleep("10 millis")
}
expect((yield* command.get("review"))?.template).toBe("Review again")
}), }),
), ),
), ),

View file

@ -1,18 +1,20 @@
import path from "path" import path from "path"
import fs from "fs/promises" import fs from "fs/promises"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer, Schema } from "effect" import { Effect, Fiber, Layer, PubSub, Schema, Stream } from "effect"
import { FastCheck } from "effect/testing" import { FastCheck } from "effect/testing"
import { Config } from "@opencode-ai/core/config" import { Config } from "@opencode-ai/core/config"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { ConfigProvider } from "@opencode-ai/core/config/provider" import { ConfigProvider } from "@opencode-ai/core/config/provider"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate" import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { EventV2 } from "@opencode-ai/core/event"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { Policy } from "@opencode-ai/core/policy"
import { Project } from "@opencode-ai/core/project" import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location" import { location } from "../fixture/location"
@ -26,6 +28,7 @@ function testLayer(
globalDirectory = path.join(directory, "global"), globalDirectory = path.join(directory, "global"),
projectDirectory = directory, projectDirectory = directory,
vcs?: Project.Vcs, vcs?: Project.Vcs,
watcher?: Layer.Layer<Watcher.Service>,
) { ) {
const locationLayer = Layer.succeed( const locationLayer = Layer.succeed(
Location.Service, Location.Service,
@ -36,9 +39,10 @@ function testLayer(
), ),
), ),
) )
return AppNodeBuilder.build(LayerNode.group([Config.node, Policy.node]), [ return AppNodeBuilder.build(LayerNode.group([Config.node, EventV2.node]), [
[Location.node, locationLayer], [Location.node, locationLayer],
[Global.node, Global.layerWith({ config: globalDirectory })], [Global.node, Global.layerWith({ config: globalDirectory })],
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
]) ])
} }
@ -52,6 +56,52 @@ const provider = {
} }
describe("Config", () => { describe("Config", () => {
it.live("reloads external config and publishes directory updates", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const file = path.join(global, "opencode.json")
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.mkdir(project, { recursive: true })
await fs.writeFile(file, JSON.stringify({ shell: "first" }))
})
const updates = yield* PubSub.unbounded<Watcher.Update>()
const watcher = Layer.succeed(
Watcher.Service,
Watcher.Service.of({
subscribe: () => Stream.fromPubSub(updates),
}),
)
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const events = yield* EventV2.Service
const changed = yield* events
.subscribe(ConfigSchema.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.sleep("10 millis")
yield* PubSub.publish(updates, {
type: "update",
path: path.join(global, "commands", "review.md"),
} satisfies Watcher.Update)
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ shell: "second" })))
yield* PubSub.publish(updates, { type: "update", path: file } satisfies Watcher.Update)
expect(yield* Fiber.join(changed)).toHaveLength(1)
expect(Config.latest(yield* config.entries(), "shell")).toBe("second")
}).pipe(Effect.provide(testLayer(project, global, project, undefined, watcher)))
}),
),
),
)
it.effect("returns the latest defined scalar from priority-ordered documents", () => it.effect("returns the latest defined scalar from priority-ordered documents", () =>
Effect.sync(() => { Effect.sync(() => {
const entries = [ const entries = [
@ -242,6 +292,72 @@ describe("Config", () => {
), ),
) )
it.live("substitutes environment variables and relative file contents", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = {
token: process.env.OPENCODE_TEST_MCP_TOKEN,
missing: process.env.OPENCODE_TEST_MISSING,
}
process.env.OPENCODE_TEST_MCP_TOKEN = "secret"
delete process.env.OPENCODE_TEST_MISSING
return previous
}),
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Promise.all([
fs.writeFile(path.join(tmp.path, "token.txt"), 'file\n"token"\n'),
fs.writeFile(
path.join(tmp.path, "opencode.jsonc"),
`{
// Ignored reference: {file:missing.txt}
"username": "user-{env:OPENCODE_TEST_MISSING}",
"mcp": {
"servers": {
"remote": {
"type": "remote",
"url": "https://example.com/mcp",
"headers": {
"Authorization": "Bearer {env:OPENCODE_TEST_MCP_TOKEN}",
"X-Token": "{file:token.txt}"
}
}
}
}
}`,
),
]),
)
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const document = (yield* config.entries()).find((entry) => entry.type === "document")
expect(document?.info.username).toBe("user-")
const remote = document?.info.mcp?.servers?.remote
expect(remote?.type).toBe("remote")
if (remote?.type !== "remote") return
expect(remote.headers).toEqual({
Authorization: "Bearer secret",
"X-Token": 'file\n"token"',
})
}).pipe(Effect.provide(testLayer(tmp.path)))
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
(previous) =>
Effect.sync(() => {
if (previous.token === undefined) delete process.env.OPENCODE_TEST_MCP_TOKEN
else process.env.OPENCODE_TEST_MCP_TOKEN = previous.token
if (previous.missing === undefined) delete process.env.OPENCODE_TEST_MISSING
else process.env.OPENCODE_TEST_MISSING = previous.missing
}),
),
)
it.live("does not load legacy config.json files", () => it.live("does not load legacy config.json files", () =>
Effect.acquireRelease( Effect.acquireRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
@ -274,7 +390,6 @@ describe("Config", () => {
const file = path.join(tmp.path, "opencode.json") const file = path.join(tmp.path, "opencode.json")
const contents = JSON.stringify({ const contents = JSON.stringify({
shell: "/bin/zsh", shell: "/bin/zsh",
experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] },
providers: { local: provider }, providers: { local: provider },
}) })
yield* Effect.promise(() => fs.writeFile(file, contents)) yield* Effect.promise(() => fs.writeFile(file, contents))
@ -285,11 +400,6 @@ describe("Config", () => {
expect(documents[0]?.info.$schema).toBeUndefined() expect(documents[0]?.info.$schema).toBeUndefined()
expect(documents[0]?.info.shell).toBe("/bin/zsh") expect(documents[0]?.info.shell).toBe("/bin/zsh")
expect(documents[0]?.info.experimental?.policies?.[0]).toEqual({
effect: "deny",
action: "provider.use",
resource: "openai",
})
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe(contents) expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe(contents)
}).pipe(Effect.provide(testLayer(tmp.path))) }).pipe(Effect.provide(testLayer(tmp.path)))
}), }),
@ -723,40 +833,6 @@ describe("Config", () => {
), ),
) )
it.live("loads policy statements in reverse config order", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
return Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.writeFile(
path.join(global, "opencode.json"),
JSON.stringify({
experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] },
}),
)
await fs.writeFile(
path.join(tmp.path, "opencode.json"),
JSON.stringify({
experimental: { policies: [{ effect: "allow", action: "provider.use", resource: "openai" }] },
}),
)
})
return yield* Effect.gen(function* () {
const policy = yield* Policy.Service
expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
}).pipe(Effect.provide(testLayer(tmp.path, global)))
})
}),
),
)
it.live("loads global, ancestor, and .opencode configuration up to the project boundary", () => it.live("loads global, ancestor, and .opencode configuration up to the project boundary", () =>
Effect.acquireRelease( Effect.acquireRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),

View file

@ -1,15 +1,19 @@
import fs from "fs/promises"
import path from "path" import path from "path"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config" import { Config } from "@opencode-ai/core/config"
import { ConfigExternalPlugin } from "@opencode-ai/core/config/plugin/external" import { ConfigExternalPlugin } from "@opencode-ai/core/config/plugin/external"
import { EventV2 } from "@opencode-ai/core/event"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { Npm } from "@opencode-ai/core/npm" import { Npm } from "@opencode-ai/core/npm"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host" import { PluginHost } from "@opencode-ai/core/plugin/host"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture" import { PluginTestLayer } from "../plugin/fixture"
@ -240,6 +244,49 @@ describe("ConfigExternalPlugin", () => {
}) })
}), }),
) )
it.live("reloads changed plugin source from the same entrypoint", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const agents = yield* AgentV2.Service
const events = yield* EventV2.Service
const fsUtil = yield* FSUtil.Service
const location = yield* Location.Service
const npm = yield* Npm.Service
const host = yield* PluginHost.make(plugins)
const plugin = path.join(tmp.path, "plugin.ts")
const config = Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({ plugins: [plugin] }),
}),
]),
})
yield* Effect.promise(() => fs.writeFile(plugin, pluginSource("First source")))
yield* ConfigExternalPlugin.Plugin.effect(host).pipe(
Effect.provideService(PluginV2.Service, plugins),
Effect.provideService(FSUtil.Service, fsUtil),
Effect.provideService(Location.Service, location),
Effect.provideService(Npm.Service, npm),
Effect.provideService(Config.Service, config),
)
expect((yield* waitForAgent(agents, "hot-reload"))?.description).toBe("First source")
yield* Effect.promise(() => fs.writeFile(plugin, pluginSource("Second source")))
yield* events.publish(ConfigSchema.Event.Updated, {})
expect(yield* waitForAgentDescription(agents, "hot-reload", "Second source")).toBe(true)
}),
),
),
)
}) })
const waitForAgent = Effect.fnUntraced(function* (agents: AgentV2.Interface, id: string) { const waitForAgent = Effect.fnUntraced(function* (agents: AgentV2.Interface, id: string) {
@ -250,3 +297,29 @@ const waitForAgent = Effect.fnUntraced(function* (agents: AgentV2.Interface, id:
} }
return yield* Effect.die(`Timed out waiting for agent ${id}`) return yield* Effect.die(`Timed out waiting for agent ${id}`)
}) })
const waitForAgentDescription = Effect.fnUntraced(function* (
agents: AgentV2.Interface,
id: string,
description: string,
) {
for (let attempt = 0; attempt < 100; attempt++) {
if ((yield* agents.get(AgentV2.ID.make(id)))?.description === description) return true
yield* Effect.sleep("10 millis")
}
return false
})
function pluginSource(description: string) {
return `export default {
id: "source-hot-reload",
setup: async (ctx) => {
await ctx.agent.transform((agents) => {
agents.update("hot-reload", (agent) => {
agent.description = ${JSON.stringify(description)}
agent.mode = "subagent"
})
})
},
}`
}

View file

@ -0,0 +1,125 @@
import path from "path"
import { describe, expect } from "bun:test"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { CommandV2 } from "@opencode-ai/core/command"
import { Config } from "@opencode-ai/core/config"
import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
import { ConfigExternalPlugin } from "@opencode-ai/core/config/plugin/external"
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
import { ConfigReferencePlugin } from "@opencode-ai/core/config/plugin/reference"
import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill"
import { EventV2 } from "@opencode-ai/core/event"
import { Global } from "@opencode-ai/core/global"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Reference } from "@opencode-ai/core/reference"
import { SkillV2 } from "@opencode-ai/core/skill"
import { Effect, Schema } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
const it = testEffect(PluginTestLayer)
const decode = Schema.decodeUnknownSync(Config.Info)
const document = path.join(import.meta.dir, "opencode.json")
describe("config plugin reloads", () => {
it.live("reloads every config-backed domain", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service
const catalog = yield* Catalog.Service
const commands = yield* CommandV2.Service
const events = yield* EventV2.Service
const plugins = yield* PluginV2.Service
const references = yield* Reference.Service
const skills = yield* SkillV2.Service
const host = yield* PluginHost.make(plugins)
let entries: Config.Entry[] = [config("first", "First plugin")]
const service = Config.Service.of({ entries: () => Effect.sync(() => entries) })
const setup = <R>(effect: Effect.Effect<void, never, R>) =>
effect.pipe(Effect.provideService(Config.Service, service))
yield* setup(ConfigAgentPlugin.Plugin.effect(host))
yield* setup(ConfigCommandPlugin.Plugin.effect(host))
yield* setup(ConfigSkillPlugin.Plugin.effect(host))
yield* setup(ConfigReferencePlugin.Plugin.effect(host))
yield* setup(ConfigProviderPlugin.Plugin.effect(host))
yield* setup(ConfigExternalPlugin.Plugin.effect(host))
expect((yield* agents.get(AgentV2.ID.make("first")))?.description).toBe("First agent")
expect((yield* commands.get("first"))?.description).toBe("First command")
expect(
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"),
).toBe(true)
expect((yield* references.list()).map((reference) => reference.name)).toEqual(["first"])
expect(yield* catalog.provider.get(ProviderV2.ID.make("first"))).toBeDefined()
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("First plugin")
entries = [config("second", "Second plugin")]
yield* events.publish(ConfigSchema.Event.Updated, {})
yield* waitUntil(
Effect.gen(function* () {
return (
(yield* agents.get(AgentV2.ID.make("first"))) === undefined &&
(yield* agents.get(AgentV2.ID.make("second")))?.description === "Second agent" &&
(yield* commands.get("first")) === undefined &&
(yield* commands.get("second"))?.description === "Second command" &&
(yield* references.list()).some((reference) => reference.name === "second") &&
(yield* catalog.provider.get(ProviderV2.ID.make("first"))) === undefined &&
(yield* catalog.provider.get(ProviderV2.ID.make("second"))) !== undefined &&
(yield* agents.get(AgentV2.ID.make("configured")))?.description === "Second plugin"
)
}),
)
expect(
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"),
).toBe(false)
expect(
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/second"),
).toBe(true)
entries = [config("second")]
yield* events.publish(ConfigSchema.Event.Updated, {})
yield* waitUntil(agents.get(AgentV2.ID.make("configured")).pipe(Effect.map((agent) => agent === undefined)))
}).pipe(Effect.provideService(Global.Service, Global.Service.of(Global.make()))),
)
})
function config(name: string, pluginDescription?: string) {
return new Config.Document({
type: "document",
path: document,
info: decode({
agents: { [name]: { description: `${title(name)} agent`, mode: "subagent" } },
commands: { [name]: { template: `${title(name)} command`, description: `${title(name)} command` } },
skills: [`/skills/${name}`],
references: { [name]: `/references/${name}` },
providers: { [name]: { models: { chat: { name: `${title(name)} model` } } } },
plugins:
pluginDescription === undefined
? []
: [
{
package: "../plugin/fixtures/config-promise-plugin.ts",
options: { description: pluginDescription },
},
],
}),
})
}
function title(value: string) {
return value.charAt(0).toUpperCase() + value.slice(1)
}
const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect<boolean>) {
for (let attempt = 0; attempt < 100; attempt++) {
if (yield* condition) return
yield* Effect.sleep("10 millis")
}
return yield* Effect.die("Timed out waiting for config plugin reloads")
})

View file

@ -36,7 +36,7 @@ describe("ConfigSkillPlugin.Plugin", () => {
yield* ConfigSkillPlugin.Plugin.effect( yield* ConfigSkillPlugin.Plugin.effect(
host({ host({
skill: { transform, reload: () => Effect.void }, skill: { list: () => Effect.die("unused skill.list"), transform, reload: () => Effect.void },
}), }),
).pipe( ).pipe(
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home: "/home/test" })), Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home: "/home/test" })),

View file

@ -8,7 +8,9 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { LocationWatcher } from "@opencode-ai/core/filesystem/location-watcher"
import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location" import { location } from "../fixture/location"
@ -34,7 +36,7 @@ function provide(directory: string, vcs?: Location.Interface["vcs"]) {
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })), Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
) )
return Effect.provide( return Effect.provide(
AppNodeBuilder.build(Watcher.node, [ AppNodeBuilder.build(LocationWatcher.node, [
[Config.node, configLayer], [Config.node, configLayer],
[Location.node, locationLayer], [Location.node, locationLayer],
]), ]),
@ -66,7 +68,7 @@ function wait(check: (event: WatcherEvent) => boolean) {
return Effect.gen(function* () { return Effect.gen(function* () {
const events = yield* EventV2.Service const events = yield* EventV2.Service
const deferred = yield* Deferred.make<WatcherEvent>() const deferred = yield* Deferred.make<WatcherEvent>()
const fiber = yield* events.subscribe(Watcher.Event.Updated).pipe( const fiber = yield* events.subscribe(FileSystem.Event.Changed).pipe(
Stream.runForEach((event) => { Stream.runForEach((event) => {
if (!check(event.data)) return Effect.void if (!check(event.data)) return Effect.void
return Deferred.succeed(deferred, event.data).pipe(Effect.asVoid) return Deferred.succeed(deferred, event.data).pipe(Effect.asVoid)
@ -136,7 +138,27 @@ function ready(directory: string) {
}) })
} }
describeWatcher("Watcher", () => { describeWatcher("LocationWatcher", () => {
it.live("limits file watches to the exact target", () =>
withTmp((directory) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const watcher = yield* Watcher.Service
const target = path.join(directory, "opencode.json")
const sibling = path.join(directory, "other.json")
const update = yield* watcher
.subscribe({ path: target, type: "file" })
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* Effect.yieldNow
yield* fs.writeFileString(sibling, "sibling")
yield* fs.writeFileString(target, "target")
expect((yield* Fiber.join(update)).valueOrUndefined?.path).toBe(target)
}).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))),
),
)
it.live("publishes root create, update, and delete events", () => it.live("publishes root create, update, and delete events", () =>
withTmp( withTmp(
(directory) => (directory) =>

View file

@ -51,27 +51,18 @@ describe("LocationServiceMap", () => {
), ),
) )
it.live("isolates location state while sharing location policy with catalog", () => it.live("isolates catalog state by location", () =>
Effect.acquireRelease( Effect.acquireRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
(dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)), (dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
).pipe( ).pipe(
Effect.flatMap(([blocked, allowed]) => Effect.flatMap(([blocked, allowed]) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* Effect.promise(() => const update = (directory: string, providerID: ProviderV2.ID) =>
fs.writeFile(
path.join(blocked.path, "opencode.json"),
JSON.stringify({
experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "test" }] },
}),
),
)
const update = (directory: string) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* Reference.Service yield* Reference.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) yield* catalog.transform((editor) => editor.provider.update(providerID, () => {}))
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
// Tool plugins register during the forked PluginInternal boot; wait for // Tool plugins register during the forked PluginInternal boot; wait for
// every expected tool rather than relying on batch ordering. // every expected tool rather than relying on batch ordering.
@ -103,8 +94,11 @@ describe("LocationServiceMap", () => {
), ),
) )
const blockedState = yield* update(blocked.path) const blockedID = ProviderV2.ID.make("blocked-location")
expect(blockedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(false) const allowedID = ProviderV2.ID.make("allowed-location")
const blockedState = yield* update(blocked.path, blockedID)
expect(blockedState.providers.some((provider) => provider.id === blockedID)).toBe(true)
expect(blockedState.providers.some((provider) => provider.id === allowedID)).toBe(false)
expect(blockedState.tools.map((tool) => tool.name).sort()).toEqual([ expect(blockedState.tools.map((tool) => tool.name).sort()).toEqual([
"edit", "edit",
"glob", "glob",
@ -119,8 +113,9 @@ describe("LocationServiceMap", () => {
"websearch", "websearch",
"write", "write",
]) ])
const allowedState = yield* update(allowed.path) const allowedState = yield* update(allowed.path, allowedID)
expect(allowedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(true) expect(allowedState.providers.some((provider) => provider.id === allowedID)).toBe(true)
expect(allowedState.providers.some((provider) => provider.id === blockedID)).toBe(false)
expect(allowedState.tools.map((tool) => tool.name).sort()).toEqual([ expect(allowedState.tools.map((tool) => tool.name).sort()).toEqual([
"edit", "edit",
"glob", "glob",

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

View file

@ -149,6 +149,22 @@ describe("ModelsDev Service", () => {
}), }),
) )
it.effect("allows models.dev entries without temperature metadata", () =>
Effect.sync(() => {
const result = Schema.decodeUnknownSync(ModelsDev.Model)({
id: "no-temperature-model",
name: "No Temperature Model",
release_date: "2026-01-01",
attachment: false,
reasoning: false,
tool_call: true,
limit: { context: 128000, output: 8192 },
})
expect(result.temperature).toBeUndefined()
}),
)
it.live("get() returns providers from disk when cache file exists", () => it.live("get() returns providers from disk when cache file exists", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* writeCache(fixture) yield* writeCache(fixture)

View file

@ -1,8 +1,11 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Exit, Fiber, Schema } from "effect" import { Effect, Exit, Fiber, Schema, Stream } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { define } from "@opencode-ai/plugin/v2/effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
import { EventV2 } from "@opencode-ai/core/event"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionMessage } from "@opencode-ai/core/session/message"
import { Tool } from "@opencode-ai/core/tool/tool" import { Tool } from "@opencode-ai/core/tool/tool"
@ -14,6 +17,24 @@ import { PluginTestLayer } from "./plugin/fixture"
const it = testEffect(PluginTestLayer) const it = testEffect(PluginTestLayer)
describe("PluginV2", () => { describe("PluginV2", () => {
it.live("exposes public events through the plugin context", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const events = yield* EventV2.Service
const host = yield* PluginHost.make(plugins)
const received = yield* host.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.sleep("10 millis")
yield* events.publish(ConfigSchema.Event.Updated, {})
expect((yield* Fiber.join(received)).valueOrUndefined?.type).toBe("config.updated")
}),
)
it.effect("waits for a plugin and returns immediately once active", () => it.effect("waits for a plugin and returns immediately once active", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugins = yield* PluginV2.Service const plugins = yield* PluginV2.Service

View file

@ -23,7 +23,11 @@ describe("CommandPlugin.Plugin", () => {
const command = yield* CommandV2.Service const command = yield* CommandV2.Service
yield* CommandPlugin.Plugin.effect( yield* CommandPlugin.Plugin.effect(
host({ host({
command: { transform: command.transform, reload: command.reload }, command: {
list: () => Effect.die("unused command.list"),
transform: command.transform,
reload: command.reload,
},
}), }),
).pipe( ).pipe(
Effect.provideService( Effect.provideService(

View file

@ -6,7 +6,7 @@ import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2/types" import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2/types"
import { Effect } from "effect" import { Effect, Stream } from "effect"
type Overrides = Partial<Omit<PluginContext, "options">> type Overrides = Partial<Omit<PluginContext, "options">>
@ -23,14 +23,33 @@ export function host(overrides: Overrides = {}): PluginContext {
language: () => Effect.die("unused aisdk.language"), language: () => Effect.die("unused aisdk.language"),
}, },
catalog: overrides.catalog ?? { catalog: overrides.catalog ?? {
provider: {
list: () => Effect.die("unused catalog.provider.list"),
get: () => Effect.die("unused catalog.provider.get"),
},
model: {
list: () => Effect.die("unused catalog.model.list"),
default: () => Effect.die("unused catalog.model.default"),
},
transform: () => Effect.die("unused catalog.transform"), transform: () => Effect.die("unused catalog.transform"),
reload: () => Effect.die("unused catalog.reload"), reload: () => Effect.die("unused catalog.reload"),
}, },
command: overrides.command ?? { command: overrides.command ?? {
list: () => Effect.die("unused command.list"),
transform: () => Effect.die("unused command.transform"), transform: () => Effect.die("unused command.transform"),
reload: () => Effect.die("unused command.reload"), reload: () => Effect.die("unused command.reload"),
}, },
event: overrides.event ?? {
subscribe: () => Stream.empty,
},
integration: overrides.integration ?? { integration: overrides.integration ?? {
list: () => Effect.die("unused integration.list"),
get: () => Effect.die("unused integration.get"),
connectKey: () => Effect.die("unused integration.connectKey"),
connectOauth: () => Effect.die("unused integration.connectOauth"),
attemptStatus: () => Effect.die("unused integration.attemptStatus"),
attemptComplete: () => Effect.die("unused integration.attemptComplete"),
attemptCancel: () => Effect.die("unused integration.attemptCancel"),
transform: () => Effect.die("unused integration.transform"), transform: () => Effect.die("unused integration.transform"),
reload: () => Effect.die("unused integration.reload"), reload: () => Effect.die("unused integration.reload"),
connection: { connection: {
@ -39,14 +58,17 @@ export function host(overrides: Overrides = {}): PluginContext {
}, },
}, },
plugin: overrides.plugin ?? { plugin: overrides.plugin ?? {
list: () => Effect.die("unused plugin.list"),
add: () => Effect.die("unused plugin.add"), add: () => Effect.die("unused plugin.add"),
remove: () => Effect.die("unused plugin.remove"), remove: () => Effect.die("unused plugin.remove"),
}, },
reference: overrides.reference ?? { reference: overrides.reference ?? {
list: () => Effect.die("unused reference.list"),
transform: () => Effect.die("unused reference.transform"), transform: () => Effect.die("unused reference.transform"),
reload: () => Effect.die("unused reference.reload"), reload: () => Effect.die("unused reference.reload"),
}, },
skill: overrides.skill ?? { skill: overrides.skill ?? {
list: () => Effect.die("unused skill.list"),
transform: () => Effect.die("unused skill.transform"), transform: () => Effect.die("unused skill.transform"),
reload: () => Effect.die("unused skill.reload"), reload: () => Effect.die("unused skill.reload"),
}, },
@ -94,6 +116,14 @@ export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] {
export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"] { export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"] {
return { return {
provider: {
list: () => Effect.die("unused catalog.provider.list"),
get: () => Effect.die("unused catalog.provider.get"),
},
model: {
list: () => Effect.die("unused catalog.model.list"),
default: () => Effect.die("unused catalog.model.default"),
},
reload: catalog.reload, reload: catalog.reload,
transform: (callback) => transform: (callback) =>
catalog.transform((draft) => catalog.transform((draft) =>
@ -158,6 +188,13 @@ export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"
export function integrationHost(integration: Integration.Interface): PluginContext["integration"] { export function integrationHost(integration: Integration.Interface): PluginContext["integration"] {
return { return {
list: () => Effect.die("unused integration.list"),
get: () => Effect.die("unused integration.get"),
connectKey: () => Effect.die("unused integration.connectKey"),
connectOauth: () => Effect.die("unused integration.connectOauth"),
attemptStatus: () => Effect.die("unused integration.attemptStatus"),
attemptComplete: () => Effect.die("unused integration.attemptComplete"),
attemptCancel: () => Effect.die("unused integration.attemptCancel"),
reload: integration.reload, reload: integration.reload,
connection: { connection: {
active: (id) => integration.connection.active(Integration.ID.make(id)), active: (id) => integration.connection.active(Integration.ID.make(id)),

View file

@ -11,6 +11,35 @@ import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer) const it = testEffect(PluginTestLayer)
describe("fromPromise", () => { describe("fromPromise", () => {
it.effect("forwards standard client reads", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make(plugin)
const seen: string[] = []
const promisePlugin = define({
id: "promise-client-reads",
setup: async (ctx) => {
const results = await Promise.all([
ctx.agent.list(),
ctx.catalog.provider.list(),
ctx.catalog.model.list(),
ctx.command.list(),
ctx.integration.list(),
ctx.plugin.list(),
ctx.reference.list(),
ctx.skill.list(),
])
seen.push(...results.map((result) => result.location.directory))
},
})
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
expect(seen).toHaveLength(8)
expect(new Set(seen).size).toBe(1)
}),
)
it.effect("loads a promise plugin and registers a transform hook", () => it.effect("loads a promise plugin and registers a transform hook", () =>
Effect.gen(function* () { Effect.gen(function* () {
const agents = yield* AgentV2.Service const agents = yield* AgentV2.Service

View file

@ -19,7 +19,15 @@ describe("SkillPlugin.Plugin", () => {
it.effect("registers built-in skills", () => it.effect("registers built-in skills", () =>
Effect.gen(function* () { Effect.gen(function* () {
const skill = yield* SkillV2.Service const skill = yield* SkillV2.Service
yield* SkillPlugin.Plugin.effect(host({ skill: { ...skill, reload: skill.reload } })).pipe( yield* SkillPlugin.Plugin.effect(
host({
skill: {
list: () => Effect.die("unused skill.list"),
transform: skill.transform,
reload: skill.reload,
},
}),
).pipe(
Effect.provideService(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })), Effect.provideService(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })),
Effect.provideService( Effect.provideService(
Location.Service, Location.Service,

View file

@ -1,85 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { Policy } from "@opencode-ai/core/policy"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(Policy.node, [
[
Location.node,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
],
]),
)
describe("Policy", () => {
it.effect("returns the caller's fallback when no statement matches", () =>
Effect.gen(function* () {
const policy = yield* Policy.Service
expect(yield* policy.evaluate("provider.use", "anthropic", "allow")).toBe("allow")
expect(yield* policy.evaluate("provider.use", "anthropic", "deny")).toBe("deny")
}),
)
it.effect("evaluates wildcard provider rules in written order", () =>
Effect.gen(function* () {
const policy = yield* Policy.Service
yield* policy.load([
new Policy.Info({
effect: "deny",
action: "provider.*",
resource: "*",
}),
new Policy.Info({
effect: "allow",
action: "provider.use",
resource: "anthropic",
}),
])
expect(yield* policy.evaluate("provider.use", "anthropic", "allow")).toBe("allow")
expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
}),
)
it.effect("matches action and resource independently", () =>
Effect.gen(function* () {
const policy = yield* Policy.Service
yield* policy.load([
new Policy.Info({
effect: "deny",
action: "provider.*",
resource: "company-*",
}),
])
expect(yield* policy.evaluate("provider.use", "company-stable", "allow")).toBe("deny")
expect(yield* policy.evaluate("plugin.load", "company-stable", "allow")).toBe("allow")
}),
)
it.effect("uses the last matching loaded statement", () =>
Effect.gen(function* () {
const policy = yield* Policy.Service
yield* policy.load([
new Policy.Info({
effect: "allow",
action: "provider.use",
resource: "openai",
}),
new Policy.Info({
effect: "deny",
action: "provider.use",
resource: "openai",
}),
])
expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
}),
)
})

View file

@ -213,7 +213,7 @@ describe("SessionV2.create", () => {
expect(forkContext.map((message) => message.id)).not.toEqual(parentContext.map((message) => message.id)) expect(forkContext.map((message) => message.id)).not.toEqual(parentContext.map((message) => message.id))
expect(history).toHaveLength(1) expect(history).toHaveLength(1)
expect(history[0]).toMatchObject({ expect(history[0]).toMatchObject({
type: "forked", type: "session.forked",
durable: { seq: 0 }, durable: { seq: 0 },
data: { sessionID: forked.id, parentID: parent.id }, data: { sessionID: forked.id, parentID: parent.id },
}) })
@ -378,8 +378,8 @@ describe("SessionV2.create", () => {
expect( expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(2), Stream.runCollect)), Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(2), Stream.runCollect)),
).toMatchObject([ ).toMatchObject([
{ durable: { seq: 1 }, type: "prompt.admitted", data: { prompt: { text: "Hello" } } }, { durable: { seq: 1 }, type: "session.prompt.admitted", data: { prompt: { text: "Hello" } } },
{ durable: { seq: 2 }, type: "prompt.promoted" }, { durable: { seq: 2 }, type: "session.prompt.promoted" },
]) ])
}), }),
) )
@ -494,8 +494,9 @@ describe("SessionV2.create", () => {
const messages = yield* session.messages({ sessionID: created.id, order: "asc" }) const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell") const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell")
expect(shell).toMatchObject({ type: "shell", command: "echo hello" }) expect(shell).toMatchObject({ type: "shell", shell: { command: "echo hello", status: "exited", exit: 0 } })
expect(shell?.output).toContain("hello") expect(shell?.output?.output).toContain("hello")
expect(shell?.output?.truncated).toBe(false)
expect(shell?.time.completed).toBeDefined() expect(shell?.time.completed).toBeDefined()
}), }),
), ),
@ -513,7 +514,8 @@ describe("SessionV2.create", () => {
const messages = yield* session.messages({ sessionID: created.id, order: "asc" }) const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell") const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell")
expect(shell).toMatchObject({ type: "shell", command: "false" }) expect(shell).toMatchObject({ type: "shell", shell: { command: "false", status: "exited" } })
expect(shell?.shell.exit).not.toBe(0)
expect(shell?.time.completed).toBeDefined() expect(shell?.time.completed).toBeDefined()
}), }),
), ),
@ -529,7 +531,7 @@ describe("SessionV2.create", () => {
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" }) expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
expect( expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)), Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)),
).toMatchObject([{ type: "agent.selected", data: { agent: "plan" } }]) ).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan" } }])
}), }),
) )
@ -562,7 +564,7 @@ describe("SessionV2.create", () => {
expect(yield* session.get(created.id)).toMatchObject({ model }) expect(yield* session.get(created.id)).toMatchObject({ model })
expect( expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)), Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)),
).toMatchObject([{ type: "model.selected", data: { model } }]) ).toMatchObject([{ type: "session.model.selected", data: { model } }])
}), }),
) )

View file

@ -40,14 +40,14 @@ describe("SessionV2.log", () => {
const session = yield* SessionV2.Service const session = yield* SessionV2.Service
const events = yield* EventV2.Service const events = yield* EventV2.Service
const created = yield* session.create({ location }) const created = yield* session.create({ location })
yield* session.rename({ sessionID: created.id, title: "renamed" }) yield* session.rename({ sessionID: created.id, title: "session.renamed" })
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id }))) const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id })))
const watermark = (yield* events.sequences([created.id])).get(created.id) const watermark = (yield* events.sequences([created.id])).get(created.id)
// Session creation commits a non-public durable event, so the marker's // Session creation commits a non-public durable event, so the marker's
// seq covers more of the aggregate than the public events emitted. // seq covers more of the aggregate than the public events emitted.
expect(items.map((item) => item.type)).toEqual(["renamed", "log.synced"]) expect(items.map((item) => item.type)).toEqual(["session.renamed", "log.synced"])
expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: watermark }) expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: watermark })
}), }),
) )
@ -64,7 +64,7 @@ describe("SessionV2.log", () => {
yield* session.rename({ sessionID: created.id, title: "renamed live" }) yield* session.rename({ sessionID: created.id, title: "renamed live" })
const items = Array.from(yield* Fiber.join(fiber)) const items = Array.from(yield* Fiber.join(fiber))
expect(items.map((item) => item.type)).toEqual(["log.synced", "renamed"]) expect(items.map((item) => item.type)).toEqual(["log.synced", "session.renamed"])
}), }),
) )
@ -137,7 +137,7 @@ describe("SessionV2 watermarks", () => {
const events = yield* EventV2.Service const events = yield* EventV2.Service
const first = yield* session.create({ location }) const first = yield* session.create({ location })
const second = yield* session.create({ location }) const second = yield* session.create({ location })
yield* session.rename({ sessionID: first.id, title: "renamed" }) yield* session.rename({ sessionID: first.id, title: "session.renamed" })
const page = yield* session.list() const page = yield* session.list()
const sequences = yield* events.sequences([first.id, second.id]) const sequences = yield* events.sequences([first.id, second.id])

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