Compare commits

..

46 Commits

Author SHA1 Message Date
Aiden Cline c590e27639 feat(core): add grouped and deferred tool registration (#35232) 2026-07-04 11:49:24 -05:00
Kit Langton 8f4b62eb49 fix(core): report missing search paths (#35337) 2026-07-04 12:12:56 -04:00
Kit Langton 945d1c8cb2 fix(core): bound recursive file watching (#35323) 2026-07-04 11:55:05 -04:00
Kit Langton 610e618bc5 fix(tui): clear completed background shell status (#35320) 2026-07-04 11:33:45 -04:00
Kit Langton 9daa4d85a4 fix(tui): reconcile session state after reconnect (#35262) 2026-07-04 11:05:41 -04:00
Kit Langton 62af66a74f fix(tui): distinguish variant switch notices (#35315) 2026-07-04 10:58:56 -04:00
Kit Langton 5b44e5bf41 test(core): release shell test locations (#35271) 2026-07-04 10:54:34 -04:00
Dax Raad a15afbe8f2 bye bye orchestrator 2026-07-04 02:04:58 -04:00
Dax Raad 8e0856c43b fix(core): disable external plugin hot reload 2026-07-04 01:58:17 -04:00
Dax Raad f66c829231 Revert "fix(core): bust external plugin import cache"
This reverts commit afe3ebbc35.
2026-07-04 01:54:30 -04:00
Aiden Cline 3baaabede8 fix(core): resolve mcp header env placeholders (#35236) 2026-07-03 23:00:10 -05:00
Dax Raad afe3ebbc35 fix(core): bust external plugin import cache 2026-07-03 23:12:09 -04:00
Kit Langton e2faeb84e5 fix(core): tolerate minimal FSWatcher typings (#35264) 2026-07-03 22:37:42 -04:00
Kit Langton 35ed09ff37 fix(tui): improve MCP error details (#35263) 2026-07-03 22:26:54 -04:00
Dax Raad 9751615651 fix(core): reload all config plugins 2026-07-03 20:41:33 -04:00
Dax Raad c9b24ef027 feat(core): reload config on filesystem changes 2026-07-03 20:35:04 -04:00
Aiden Cline b04d8d53e6 fix(tui): expand MCP server errors in dialog (#35243) 2026-07-03 18:19:16 -05:00
Dax Raad 7bd3f8ac83 chore(client): remove cyclic test dependencies 2026-07-03 18:12:22 -04:00
Dax Raad 6ae2fa5196 feat(plugin): align hooks with client APIs 2026-07-03 18:12:22 -04:00
Kit Langton 650d774372 refactor(schema): session shell payloads and event prefix restore (#35229) 2026-07-03 17:30:25 -04:00
Simon Klee 64e4f6f91b cli: route run commands through v2 APIs (#35234) 2026-07-03 22:47:04 +02:00
Aiden Cline d097cc8065 feat(tui): render execute child calls on v2 (#35231) 2026-07-03 15:10:25 -05:00
Aiden Cline 438654768c fix(codemode): require exact tool paths in guidance (#35224) 2026-07-03 14:55:03 -05:00
James Long 37b26e495b feat(simulation): share control protocol schemas (#35230) 2026-07-03 15:46:04 -04:00
Kit Langton 5d14c7a185 refactor(core): align runner naming with step vocabulary (#35227) 2026-07-03 15:41:50 -04:00
James Long e66cbf36e9 fix(core): constrain location services (#35228) 2026-07-03 15:34:30 -04:00
Dax Raad 50977dd4fe server: emit logs for every HTTP request to help debug API traffic 2026-07-03 15:13:37 -04:00
Dax Raad a644e0e7a0 sync 2026-07-03 14:58:23 -04:00
Kit Langton ca861fdf43 docs: consolidate session work-unit vocabulary (#35218) 2026-07-03 14:49:05 -04:00
Dax Raad 34a08cbdb8 fix(core): tolerate missing models.dev temperature 2026-07-03 14:46:54 -04:00
Aiden Cline bf3ae45439 fix(core): clean up mcp event surface (#35221) 2026-07-03 13:43:21 -05:00
Kit Langton 394e0b9045 refactor(schema): rename V2 session events and normalize payloads (#35217) 2026-07-03 14:25:59 -04:00
James Long 4790a2772c feat(simulation): add driver controlled backend LLM (#35186) 2026-07-03 14:19:25 -04:00
Dax Raad 2ff19171dc feat(tui): reload command restarts the service 2026-07-03 14:01:01 -04:00
Kit Langton 1401901529 chore: effect pattern lint infrastructure (#35210) 2026-07-03 13:58:26 -04:00
Kit Langton 1b88ff8d53 fix(core): clean up Effect audit patterns (#35174) 2026-07-03 13:54:13 -04:00
Dax Raad 08293e74a0 refactor(cli): single password config with legacy fallback 2026-07-03 13:41:15 -04:00
Dax Raad 24d26365e6 refactor(cli): manage environment variables with effect config 2026-07-03 13:37:12 -04:00
Dax Raad c22793d5f6 feat(cli): validate explicit server before starting the tui 2026-07-03 13:34:12 -04:00
Dax Raad c6a52a39b5 fix(cli): read OPENCODE_PASSWORD for explicit server auth 2026-07-03 13:34:12 -04:00
James Long aad8d90dd1 refactor(core): move path resolve into fs service (#35201) 2026-07-03 13:25:38 -04:00
Dax Raad 6f47459c68 chore: update lockfile 2026-07-03 12:42:09 -04:00
Dax Raad dd768e30e2 refactor(client): namespace service exports and share the registration contract 2026-07-03 12:36:56 -04:00
Kit Langton de476aa51b refactor(schema): declare event durability at definition level (#35172) 2026-07-03 12:35:35 -04:00
James Long bd8d858bf7 feat(core): implement V2 session.shell (#35183) 2026-07-03 12:19:09 -04:00
Aiden Cline 5fc4e18b82 test(core): cover when AND semantics and multiselect neq (#35182) 2026-07-03 11:10:49 -05:00
356 changed files with 9496 additions and 5604 deletions
-36
View File
@@ -1,36 +0,0 @@
export default {
id: "Orchestrator",
setup: async (ctx) => {
await ctx.agent.transform((agents) => {
agents.update("orchestrator", (agent) => {
agent.description = "Coordinates work by delegating implementation tasks to the minion subagent."
agent.mode = "primary"
agent.system = [
"You are Orchestrator, the primary coordinating agent for this repository. You do meta work only: you coordinate, brief, and synthesize — you do not perform the work itself.",
"Delegate ALL actual work to the minion subagent — implementation, exploration, discovery, searching the codebase, reading files to understand a problem, and even trivial one-line edits. Task size is never a reason to do it yourself, and there is no 'final integration' exception.",
"You are not hard-banned from tools, but direct tool use is reserved for coordination overhead: a quick peek to phrase a better brief, a fast read-only check to verify a minion's reported result, or answering a question about coordination state. If a tool call is producing the answer or the artifact the user asked for, that call belongs to a minion, not you.",
"Exploration is work. If the user asks how something works or where something lives, delegate the investigation to a minion rather than exploring yourself.",
"Always start minion subagents in the background. Even if you have nothing else to coordinate right now, the user may assign you new work while a Minion runs, and you must stay free to receive it. Never poll; you will be notified when they finish.",
"Give each minion a clear, self-contained brief: the goal, constraints, expected output, and any files or context already known from the user or previous minion reports.",
"Synthesize minion results, decide next steps, and report back concisely.",
].join("\n")
})
agents.update("minion", (agent) => {
agent.description = "Subagent that executes focused tasks delegated by Orchestrator."
agent.mode = "subagent"
agent.model = { providerID: "opencode", id: "glm-5.2" }
agent.system = [
"You are minion, a focused execution subagent for this repository.",
"Complete the specific task delegated to you by Orchestrator using the available tools.",
"Inspect the codebase before making assumptions, make targeted changes when requested, and verify your work when feasible.",
"Follow the repository's AGENTS.md conventions: respect the style guide, run `bun typecheck` from the affected package directory after code changes, never run tests from the repo root, and do not modify packages/opencode unless the task explicitly says V1 work.",
"If the task is ambiguous or you hit a blocker, stop and report your findings instead of guessing.",
"Keep your final response concise: summarize what you did, list important files changed or findings, and call out blockers or verification gaps.",
"Do not delegate to other subagents; execute the assigned work yourself.",
].join("\n")
agent.permissions.push({ action: "subagent", resource: "*", effect: "deny" })
})
})
},
}
+19
View File
@@ -104,6 +104,25 @@ bun dev api <operationId> --param key=value
- If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control. - If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control.
- Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters. - Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters.
## Auditing installed `opencode2` sessions
Installed next-channel sessions normally use `~/.local/share/opencode/opencode-next.db` and `~/.local/share/opencode/log/opencode.log`; `OPENCODE_DB` can override the database. Before calling `opencode2 api`, inspect `~/.local/state/opencode/service.json` because the command may start a daemon when none is healthy.
For a supplied `ses_...` ID, compare three sources:
- `opencode2 api get /api/session/active` and the Session/message endpoints for live server state.
- The database's ordered `event` rows for durable history.
- `packages/tui/src/context/data.tsx` and the relevant route for client projection and rendering.
Locate an uncertain database without modifying it:
```bash
SESSION=ses_...
for db in ~/.local/share/opencode/*.db; do
sqlite3 "file:$db?mode=ro" "select 1 from session where id='$SESSION' limit 1" 2>/dev/null | grep -q 1 && printf '%s\n' "$db"
done
```
## Logs ## Logs
- Log files live under `~/.local/share/opencode/log/`. In a local/dev checkout the active file is `opencode-local.log`; `opencode.log` is used for non-local (released) channel installs. Both are append-only, shared across every CLI and server process on the machine. - Log files live under `~/.local/share/opencode/log/`. In a local/dev checkout the active file is `opencode-local.log`; `opencode.log` is used for non-local (released) channel installs. Both are append-only, shared across every CLI and server process on the machine.
+4 -2
View File
@@ -151,13 +151,15 @@ const table = sqliteTable("session", {
## V2 Session Core ## V2 Session Core
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_input` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries. - Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_input` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries.
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Historical projected prompts lazily synthesize promoted inbox records during exact retry. - 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.
+38 -23
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`.
+42 -14
View File
@@ -14,6 +14,7 @@
}, },
"devDependencies": { "devDependencies": {
"@actions/artifact": "5.0.1", "@actions/artifact": "5.0.1",
"@ast-grep/cli": "0.44.0",
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1", "@types/mime-types": "3.0.1",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
@@ -121,14 +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": {
"@opencode-ai/core": "workspace:*", "@effect/platform-node": "catalog:",
"@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:",
@@ -597,7 +598,6 @@
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:", "@openauthjs/openauth": "catalog:",
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/llm": "workspace:*", "@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/protocol": "workspace:*", "@opencode-ai/protocol": "workspace:*",
@@ -694,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:*",
@@ -795,6 +796,7 @@
"dependencies": { "dependencies": {
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*", "@opencode-ai/protocol": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
}, },
@@ -848,6 +850,21 @@
"vite": "catalog:", "vite": "catalog:",
}, },
}, },
"packages/simulation": {
"name": "@opencode-ai/simulation",
"version": "1.17.13",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opentui/core": "catalog:",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.17.13", "version": "1.17.13",
@@ -962,6 +979,7 @@
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opentui/core": "catalog:", "@opentui/core": "catalog:",
"@opentui/keymap": "catalog:", "@opentui/keymap": "catalog:",
@@ -1236,6 +1254,22 @@
"@anycable/core": ["@anycable/core@0.9.2", "", { "dependencies": { "nanoevents": "^7.0.1" } }, "sha512-x5ZXDcW/N4cxWl93CnbHs/u7qq4793jS2kNPWm+duPrXlrva+ml2ZGT7X9tuOBKzyIHf60zWCdIK7TUgMPAwXA=="], "@anycable/core": ["@anycable/core@0.9.2", "", { "dependencies": { "nanoevents": "^7.0.1" } }, "sha512-x5ZXDcW/N4cxWl93CnbHs/u7qq4793jS2kNPWm+duPrXlrva+ml2ZGT7X9tuOBKzyIHf60zWCdIK7TUgMPAwXA=="],
"@ast-grep/cli": ["@ast-grep/cli@0.44.0", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.44.0", "@ast-grep/cli-darwin-x64": "0.44.0", "@ast-grep/cli-linux-arm64-gnu": "0.44.0", "@ast-grep/cli-linux-x64-gnu": "0.44.0", "@ast-grep/cli-win32-arm64-msvc": "0.44.0", "@ast-grep/cli-win32-ia32-msvc": "0.44.0", "@ast-grep/cli-win32-x64-msvc": "0.44.0" }, "bin": { "sg": "sg", "ast-grep": "ast-grep" } }, "sha512-Jf4PuP7XjzsMa3m9gYxmzV8KyWZc4w1ZzKe/t0+90wWxmSasQJe6AtMkJxHEi98MGgfAF1nWziqjDd0/6EsBjA=="],
"@ast-grep/cli-darwin-arm64": ["@ast-grep/cli-darwin-arm64@0.44.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bF7euu/hF/cYg4510z8110vh60rrqfrBdsfRqVGd6xqNSPENu7CJnTVN/Z4Nk5U1NM8YKzUD+dYx1ySUJ0CUNQ=="],
"@ast-grep/cli-darwin-x64": ["@ast-grep/cli-darwin-x64@0.44.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-0fI9caQGp1dFcmBATNlVytIRdAeYb91v1D2xjMIi1bSX+l8Uj846JUiaimUGBuBZmyFq+BScoWM4RnprEmZMpQ=="],
"@ast-grep/cli-linux-arm64-gnu": ["@ast-grep/cli-linux-arm64-gnu@0.44.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-JB6EUnqEtGGtyg1GqNquld/++1CvaWD7r84IwwhddX1qx0NmDoHyn2mKd8vnQ24Z0RkV3g7y7foMLakELbGtDw=="],
"@ast-grep/cli-linux-x64-gnu": ["@ast-grep/cli-linux-x64-gnu@0.44.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rNL0LsI682D9EMzfaGVEtZa1xaqTtGb2I+Zk4ZzidX6u+fF7f79wdqyKahKjXzoIrGkuhkoL3gcyLKAtQd9+qg=="],
"@ast-grep/cli-win32-arm64-msvc": ["@ast-grep/cli-win32-arm64-msvc@0.44.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-lqD0MhGQAddh2YoV/brKQ6GVcFLmRiTBwIElutwedaUvRCdasTGukFPYuSWk/iI8Kv19xom6s7l+mGuZ7v+xwQ=="],
"@ast-grep/cli-win32-ia32-msvc": ["@ast-grep/cli-win32-ia32-msvc@0.44.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-ZJrnS+2OkNfwyr6yrN69glP67uybBxDvl9mqZvh1J44vB3OFn9U9c+cVAoZIAo7JD5F4rZNxwyu3gcy4+xuwEA=="],
"@ast-grep/cli-win32-x64-msvc": ["@ast-grep/cli-win32-x64-msvc@0.44.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OJEo7f95YYaSuS1byUB7ZctbzxoA7/wCoAol+pt6pvfdW/8Wq+L1qU28glwx7dQ0HgTsnPZbWpXQwmZpCBHhZg=="],
"@astrojs/check": ["@astrojs/check@0.9.6", "", { "dependencies": { "@astrojs/language-server": "^2.16.1", "chokidar": "^4.0.1", "kleur": "^4.1.5", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": "^5.0.0" }, "bin": { "astro-check": "bin/astro-check.js" } }, "sha512-jlaEu5SxvSgmfGIFfNgcn5/f+29H61NJzEMfAZ82Xopr4XBchXB1GVlcJsE+elUlsYSbXlptZLX+JMG3b/wZEA=="], "@astrojs/check": ["@astrojs/check@0.9.6", "", { "dependencies": { "@astrojs/language-server": "^2.16.1", "chokidar": "^4.0.1", "kleur": "^4.1.5", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": "^5.0.0" }, "bin": { "astro-check": "bin/astro-check.js" } }, "sha512-jlaEu5SxvSgmfGIFfNgcn5/f+29H61NJzEMfAZ82Xopr4XBchXB1GVlcJsE+elUlsYSbXlptZLX+JMG3b/wZEA=="],
"@astrojs/cloudflare": ["@astrojs/cloudflare@12.6.3", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.1", "@astrojs/underscore-redirects": "1.0.0", "@cloudflare/workers-types": "^4.20250507.0", "tinyglobby": "^0.2.13", "vite": "^6.3.5", "wrangler": "^4.14.1" }, "peerDependencies": { "astro": "^5.0.0" } }, "sha512-xhJptF5tU2k5eo70nIMyL1Udma0CqmUEnGSlGyFflLqSY82CRQI6nWZ/xZt0ZvmXuErUjIx0YYQNfZsz5CNjLQ=="], "@astrojs/cloudflare": ["@astrojs/cloudflare@12.6.3", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.1", "@astrojs/underscore-redirects": "1.0.0", "@cloudflare/workers-types": "^4.20250507.0", "tinyglobby": "^0.2.13", "vite": "^6.3.5", "wrangler": "^4.14.1" }, "peerDependencies": { "astro": "^5.0.0" } }, "sha512-xhJptF5tU2k5eo70nIMyL1Udma0CqmUEnGSlGyFflLqSY82CRQI6nWZ/xZt0ZvmXuErUjIx0YYQNfZsz5CNjLQ=="],
@@ -2002,6 +2036,8 @@
"@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"], "@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"],
"@opencode-ai/simulation": ["@opencode-ai/simulation@workspace:packages/simulation"],
"@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"], "@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"],
"@opencode-ai/stats-app": ["@opencode-ai/stats-app@workspace:packages/stats/app"], "@opencode-ai/stats-app": ["@opencode-ai/stats-app@workspace:packages/stats/app"],
@@ -3490,7 +3526,7 @@
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
"detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="],
@@ -6048,6 +6084,8 @@
"@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], "@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
"@parcel/watcher/detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
"@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], "@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
"@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], "@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
@@ -6114,8 +6152,6 @@
"@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], "@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
"@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
@@ -6328,8 +6364,6 @@
"light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="], "light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="],
"lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="], "md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="],
@@ -6360,8 +6394,6 @@
"node-gyp/undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="], "node-gyp/undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="],
"node-gyp-build-optional-packages/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"nypm/citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], "nypm/citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="],
@@ -6428,8 +6460,6 @@
"serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
"sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"shiki/@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="], "shiki/@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="],
"shiki/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], "shiki/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
@@ -7078,8 +7108,6 @@
"opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], "opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
"openid-client/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
+3
View File
@@ -14,6 +14,8 @@
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev", "dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook", "dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint", "lint": "oxlint",
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/core/src packages/server/src packages/protocol/src packages/cli/src",
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
"typecheck": "bun turbo typecheck", "typecheck": "bun turbo typecheck",
"upgrade-opentui": "bun run script/upgrade-opentui.ts", "upgrade-opentui": "bun run script/upgrade-opentui.ts",
"postinstall": "bun run --cwd packages/core fix-node-pty", "postinstall": "bun run --cwd packages/core fix-node-pty",
@@ -95,6 +97,7 @@
}, },
"devDependencies": { "devDependencies": {
"@actions/artifact": "5.0.1", "@actions/artifact": "5.0.1",
"@ast-grep/cli": "0.44.0",
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1", "@types/mime-types": "3.0.1",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
@@ -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>
@@ -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>
@@ -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"
@@ -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",
+1 -1
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
+1 -1
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>)
+1 -2
View File
@@ -4,7 +4,6 @@ import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime" import { Runtime } from "../../framework/runtime"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect"
import { ServiceConfig } from "../../services/service-config" import { ServiceConfig } from "../../services/service-config"
import type { Transport } from "@opencode-ai/client/effect"
const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"]) const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"])
@@ -61,7 +60,7 @@ export function rawRequest(input: readonly string[]) {
} }
function resolveRequest( function resolveRequest(
transport: Transport, transport: Service.Transport,
input: readonly string[], input: readonly string[],
params: Record<string, string>, params: Record<string, string>,
) { ) {
@@ -1,5 +1,5 @@
import { EOL } from "os" import { EOL } from "os"
import * as Effect from "effect/Effect" import { Effect } from "effect"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
+43 -7
View File
@@ -1,9 +1,9 @@
import { NodeFileSystem } from "@effect/platform-node" import { NodeFileSystem } from "@effect/platform-node"
import { Commands } from "../commands" import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime" import { Runtime } from "../../framework/runtime"
import { Effect, Option } from "effect" import { Effect, Option, Redacted } from "effect"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect"
import type { Transport } from "@opencode-ai/client/effect" import { Env } from "../../env"
import { ServiceConfig } from "../../services/service-config" import { ServiceConfig } from "../../services/service-config"
import { Standalone } from "../../services/standalone" import { Standalone } from "../../services/standalone"
import { Updater } from "../../services/updater" import { Updater } from "../../services/updater"
@@ -19,11 +19,29 @@ export default Runtime.handler(Commands, (input) =>
return yield* Effect.fail(new Error("--server and --standalone cannot be combined")) return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
const transport = yield* Effect.gen(function* () { const transport = yield* Effect.gen(function* () {
if (server !== undefined) { if (server !== undefined) {
const password = process.env["OPENCODE_SERVER_PASSWORD"] const password = yield* Env.password
return { const explicit = {
url: server, url: server,
headers: password ? { authorization: "Basic " + btoa("opencode:" + password) } : undefined, headers: password
} satisfies Transport ? { authorization: "Basic " + btoa("opencode:" + Redacted.value(password)) }
: undefined,
} satisfies Service.Transport
// Fail loudly before entering the TUI: an explicit server that is
// unreachable or rejects auth should not present as reconnect churn.
const response = yield* Effect.tryPromise(() =>
fetch(new URL("/api/health", server), { headers: explicit.headers, signal: AbortSignal.timeout(5_000) }),
).pipe(Effect.mapError((cause) => new Error(`Could not reach server at ${server}`, { cause })))
if (response.status === 401)
return yield* Effect.fail(
new Error(
password
? `Server at ${server} rejected the password`
: `Server at ${server} requires a password; set OPENCODE_PASSWORD`,
),
)
if (!response.ok)
return yield* Effect.fail(new Error(`Server at ${server} responded with status ${response.status}`))
return explicit
} }
if (input.standalone) return yield* Standalone.transport() if (input.standalone) return yield* Standalone.transport()
const options = yield* ServiceConfig.options() const options = yield* ServiceConfig.options()
@@ -45,6 +63,24 @@ export default Runtime.handler(Commands, (input) =>
}).pipe(Effect.provide(NodeFileSystem.layer)), }).pipe(Effect.provide(NodeFileSystem.layer)),
) )
: () => Promise.resolve(transport) : () => Promise.resolve(transport)
yield* runTui(transport, { continue: input.continue, sessionID: Option.getOrUndefined(input.session) }, discover) // Restart the managed service in place; start() resolves once the
// replacement is healthy and the reconnect loop reattaches on its own.
// Only meaningful in service mode: --server is not ours to restart and a
// standalone child cannot be respawned.
const reload = serviceOptions
? () =>
Effect.runPromise(
Effect.gen(function* () {
yield* Service.stop(serviceOptions)
yield* Service.start(serviceOptions)
}).pipe(Effect.provide(NodeFileSystem.layer)),
)
: undefined
yield* runTui(
transport,
{ continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
discover,
reload,
)
}), }),
) )
@@ -1,5 +1,5 @@
import { EOL } from "node:os" import { EOL } from "node:os"
import * as Effect from "effect/Effect" import { Effect } from "effect"
import { createOpencodeClient, type McpServer } from "@opencode-ai/sdk/v2/client" import { createOpencodeClient, type McpServer } from "@opencode-ai/sdk/v2/client"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
@@ -1,4 +1,4 @@
import * as Effect from "effect/Effect" import { Effect } from "effect"
import { Commands } from "../commands" import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime" import { Runtime } from "../../framework/runtime"
+26 -17
View File
@@ -3,9 +3,9 @@ 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, FileSystem, Layer, Option, Schedule, Schema } from "effect" import { Context, Effect, FileSystem, Layer, Option, Redacted, Schedule, Schema } from "effect"
import * as Effect from "effect/Effect"
import { HttpRouter, HttpServer } from "effect/unstable/http" import { HttpRouter, HttpServer } from "effect/unstable/http"
import { createServer } from "node:http" import { createServer } from "node:http"
import { createRoutes } from "@opencode-ai/server/routes" import { createRoutes } from "@opencode-ai/server/routes"
@@ -14,6 +14,8 @@ import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { Commands } from "../commands" import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime" import { Runtime } from "../../framework/runtime"
import { Service } from "@opencode-ai/client/effect"
import { Env } from "../../env"
import { ServiceConfig } from "../../services/service-config" import { ServiceConfig } from "../../services/service-config"
import { Updater } from "../../services/updater" import { Updater } from "../../services/updater"
import { randomBytes, randomUUID } from "crypto" import { randomBytes, randomUUID } from "crypto"
@@ -25,12 +27,19 @@ export default Runtime.handler(
if (input.service) yield* Effect.sync(() => process.chdir(Global.Path.home)) if (input.service) yield* Effect.sync(() => process.chdir(Global.Path.home))
return yield* Effect.scoped( return yield* Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
const standalonePassword = process.env.OPENCODE_SERVER_PASSWORD const standalonePassword = yield* Env.password
if (input.stdio) delete process.env.OPENCODE_SERVER_PASSWORD // Keep the lease credential out of the environment inherited by any
// process this server spawns.
if (input.stdio) {
delete process.env.OPENCODE_PASSWORD
delete process.env.OPENCODE_SERVER_PASSWORD
}
const config = input.service ? yield* ServiceConfig.read() : {} const config = input.service ? yield* ServiceConfig.read() : {}
const password = input.service const password = input.service
? yield* ServiceConfig.password() ? yield* ServiceConfig.password()
: standalonePassword || randomBytes(32).toString("base64url") : standalonePassword
? Redacted.value(standalonePassword)
: randomBytes(32).toString("base64url")
if (!password) return yield* Effect.fail(new Error("Missing server password")) if (!password) return yield* Effect.fail(new Error("Missing server password"))
const hostname = Option.getOrUndefined(input.hostname) ?? config.hostname ?? "127.0.0.1" const hostname = Option.getOrUndefined(input.hostname) ?? config.hostname ?? "127.0.0.1"
const port = Option.isSome(input.port) const port = Option.isSome(input.port)
@@ -51,7 +60,7 @@ export default Runtime.handler(
if (!input.service && !input.stdio && !standalonePassword) console.log(`server password ${password}`) if (!input.service && !input.stdio && !standalonePassword) console.log(`server password ${password}`)
const updater = yield* Updater.Service const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped) yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped)
return yield* (input.stdio ? waitForStdinClose() : Effect.never) return yield* input.stdio ? waitForStdinClose() : Effect.never
}).pipe(Effect.annotateLogs({ role: "server" })), }).pipe(Effect.annotateLogs({ role: "server" })),
) )
}), }),
@@ -63,8 +72,11 @@ export default Runtime.handler(
// not a startup lock: the atomic rename elects the latest writer, the watcher // not a startup lock: the atomic rename elects the latest writer, the watcher
// self-evicts losers, and the finalizer id-guard keeps an exiting server from // self-evicts losers, and the finalizer id-guard keeps an exiting server from
// deleting its successor's registration. // deleting its successor's registration.
const RegistrationId = Schema.Struct({ id: Schema.optional(Schema.String) }) // Written and read through Service.Info so the file the server registers is
const decodeRegistrationId = Schema.decodeUnknownEffect(Schema.fromJsonString(RegistrationId)) // provably the contract clients discover with.
const infoJson = Schema.fromJsonString(Service.Info)
const encodeInfo = Schema.encodeEffect(infoJson)
const decodeInfo = Schema.decodeUnknownEffect(infoJson)
const register = Effect.fnUntraced(function* (address: HttpServer.Address) { const register = Effect.fnUntraced(function* (address: HttpServer.Address) {
const fs = yield* FileSystem.FileSystem const fs = yield* FileSystem.FileSystem
@@ -73,20 +85,17 @@ const register = Effect.fnUntraced(function* (address: HttpServer.Address) {
const secret = yield* ServiceConfig.password() const secret = yield* ServiceConfig.password()
const temp = file + "." + id + ".tmp" const temp = file + "." + id + ".tmp"
yield* fs.makeDirectory(path.dirname(file), { recursive: true }) yield* fs.makeDirectory(path.dirname(file), { recursive: true })
yield* fs.writeFileString( const encoded = yield* encodeInfo({
temp,
JSON.stringify({
id, id,
version: InstallationVersion, version: InstallationVersion,
url: HttpServer.formatAddress(address), url: HttpServer.formatAddress(address),
pid: process.pid, pid: process.pid,
password: secret, password: secret,
}), })
{ mode: 0o600 }, yield* fs.writeFileString(temp, encoded, { mode: 0o600 })
)
yield* fs.rename(temp, file) yield* fs.rename(temp, file)
const currentID = fs.readFileString(file).pipe( const currentID = fs.readFileString(file).pipe(
Effect.flatMap(decodeRegistrationId), Effect.flatMap(decodeInfo),
Effect.map((info) => info.id), Effect.map((info) => info.id),
Effect.orElseSucceed(() => undefined), Effect.orElseSucceed(() => undefined),
) )
@@ -134,9 +143,9 @@ function listen(hostname: string, port: Option.Option<number>, password: string)
function bind(hostname: string, port: number, password: string) { 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()))),
@@ -1,6 +1,5 @@
import { EOL } from "os" import { EOL } from "os"
import { Option } from "effect" import { Effect, Option } from "effect"
import * as Effect from "effect/Effect"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config" import { ServiceConfig } from "../../../services/service-config"
@@ -1,5 +1,5 @@
import { EOL } from "os" import { EOL } from "os"
import * as Effect from "effect/Effect" import { Effect } from "effect"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
@@ -1,4 +1,4 @@
import * as Effect from "effect/Effect" import { Effect } from "effect"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config" import { ServiceConfig } from "../../../services/service-config"
@@ -1,5 +1,5 @@
import { EOL } from "os" import { EOL } from "os"
import * as Effect from "effect/Effect" import { Effect } from "effect"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
@@ -1,5 +1,5 @@
import { EOL } from "os" import { EOL } from "os"
import * as Effect from "effect/Effect" import { Effect } from "effect"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
@@ -1,4 +1,4 @@
import * as Effect from "effect/Effect" import { Effect } from "effect"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
@@ -1,4 +1,4 @@
import * as Effect from "effect/Effect" import { Effect } from "effect"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config" import { ServiceConfig } from "../../../services/service-config"
+15
View File
@@ -0,0 +1,15 @@
import { Config } from "effect"
// Every environment variable the CLI reads, in one place. Consumers yield
// these instead of touching process.env so the full surface stays visible,
// typed, and redacted where secret.
// The opencode server password: sent by clients connecting to an explicit
// --server, and adopted by a manually run or standalone server. The legacy
// name is still honored.
export const password = Config.redacted("OPENCODE_PASSWORD").pipe(
Config.orElse(() => Config.redacted("OPENCODE_SERVER_PASSWORD")),
Config.withDefault(undefined),
)
export * as Env from "./env"
+15 -6
View File
@@ -1,9 +1,8 @@
import * as Effect from "effect/Effect" import { Effect, FileSystem, Scope } from "effect"
import * as Command from "effect/unstable/cli/Command" import { Command } from "effect/unstable/cli"
import { Spec } from "./spec" import { Spec } from "./spec"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Updater } from "../services/updater" import { Updater } from "../services/updater"
import { FileSystem, Scope } from "effect"
export type Input<Value> = export type Input<Value> =
Value extends Spec.Node<infer _Name, infer Command, infer _Commands> Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
@@ -12,11 +11,21 @@ export type Input<Value> =
? Input ? Input
: never : never
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope> type RuntimeHandler = (
input: unknown,
) => Effect.Effect<void, unknown, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
type Loader<Node extends Spec.Any> = () => Promise<{ type Loader<Node extends Spec.Any> = () => Promise<{
default: (input: Input<Node>) => Effect.Effect<void, any, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope> default: (
input: Input<Node>,
) => Effect.Effect<void, any, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
}> }>
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope> type ProvidedCommand = Command.Command<
string,
unknown,
unknown,
unknown,
FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope
>
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
? Loader<Node> ? Loader<Node>
+1 -1
View File
@@ -1,4 +1,4 @@
import * as Command from "effect/unstable/cli/Command" import { Command } from "effect/unstable/cli"
type Options<Config extends Command.Command.Config, Commands extends ReadonlyArray<Any>> = { type Options<Config extends Command.Command.Config, Commands extends ReadonlyArray<Any>> = {
readonly description?: string readonly description?: string
+8 -6
View File
@@ -1,10 +1,7 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import * as NodeRuntime from "@effect/platform-node/NodeRuntime" import { NodeFileSystem, NodeRuntime, NodeServices } from "@effect/platform-node"
import * as NodeServices from "@effect/platform-node/NodeServices" import { Effect, Layer, Logger, References } from "effect"
import { NodeFileSystem } from "@effect/platform-node"
import * as Effect from "effect/Effect"
import { Layer, Logger, References } from "effect"
import { Commands } from "./commands/commands" import { Commands } from "./commands/commands"
import { Runtime } from "./framework/runtime" import { Runtime } from "./framework/runtime"
import { Logging } from "@opencode-ai/core/observability/logging" import { Logging } from "@opencode-ai/core/observability/logging"
@@ -46,7 +43,12 @@ const Handlers = Runtime.handlers(Commands, {
serve: () => import("./commands/handlers/serve"), serve: () => import("./commands/handlers/serve"),
}) })
Effect.logInfo("cli starting", { version: InstallationVersion, channel: InstallationChannel, local: InstallationLocal }).pipe( Effect.logInfo("cli starting", {
version: InstallationVersion,
channel: InstallationChannel,
local: InstallationLocal,
args: process.argv.slice(2),
}).pipe(
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })), Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
Effect.annotateLogs({ role: "cli" }), Effect.annotateLogs({ role: "cli" }),
Effect.provide(Updater.layer), Effect.provide(Updater.layer),
+1 -1
View File
@@ -5,7 +5,7 @@ import { Effect, FileSystem, Schema } from "effect"
import { randomBytes } from "crypto" import { randomBytes } from "crypto"
import path from "path" import path from "path"
// The CLI's service configuration file, plus the ServiceOptions binding that // The CLI's service configuration file, plus the Service.Options binding that
// points the client package's service operations at this CLI: which // points the client package's service operations at this CLI: which
// registration file (by channel), which version, and how to spawn opencode. // registration file (by channel), which version, and how to spawn opencode.
+3 -1
View File
@@ -15,7 +15,9 @@ function command(password: string) {
if (!compiled && entrypoint.length === 0) throw new Error("Failed to resolve CLI entrypoint") if (!compiled && entrypoint.length === 0) throw new Error("Failed to resolve CLI entrypoint")
return ChildProcess.make(process.execPath, [...entrypoint, "serve", "--stdio", "--port", "0"], { return ChildProcess.make(process.execPath, [...entrypoint, "serve", "--stdio", "--port", "0"], {
cwd: process.cwd(), cwd: process.cwd(),
env: { OPENCODE_SERVER_PASSWORD: password }, // Explicit entry wins over anything inherited, so a user-exported
// OPENCODE_PASSWORD cannot shadow the child's lease credential.
env: { OPENCODE_PASSWORD: password },
extendEnv: true, extendEnv: true,
// The server treats EOF on this pipe as the end of its ownership lease. // The server treats EOF on this pipe as the end of its ownership lease.
// The OS closes it even when the TUI is killed before Effect finalizers run. // The OS closes it even when the TUI is killed before Effect finalizers run.
+8 -2
View File
@@ -5,11 +5,16 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins" import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
import { OpenCode } from "@opencode-ai/client/promise" import { OpenCode } from "@opencode-ai/client/promise"
import type { Transport } from "@opencode-ai/client/effect" import type { Service } from "@opencode-ai/client/effect"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { Args } from "@opencode-ai/tui/context/args" import type { Args } from "@opencode-ai/tui/context/args"
export function runTui(transport: Transport, args: Args, discover?: () => Promise<Transport>) { export function runTui(
transport: Service.Transport,
args: Args,
discover?: () => Promise<Service.Transport>,
reload?: () => Promise<void>,
) {
const config = TuiConfig.resolve({}, { terminalSuspend: false }) const config = TuiConfig.resolve({}, { terminalSuspend: false })
let disposeSlots: (() => void) | undefined let disposeSlots: (() => void) | undefined
return Effect.gen(function* () { return Effect.gen(function* () {
@@ -33,6 +38,7 @@ export function runTui(transport: Transport, args: Args, discover?: () => Promis
} }
} }
: undefined, : undefined,
reload,
args, args,
config, config,
pluginHost: { pluginHost: {
+17 -5
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:",
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
await $`rm -rf dist`
await $`bun tsc -p tsconfig.build.json`
+2 -2
View File
@@ -32,8 +32,8 @@ await Effect.runPromise(
fileURLToPath(new URL("../src/effect/generated", import.meta.url)), 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 },
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bun
import { Script } from "@opencode-ai/script"
import { $ } from "bun"
import { rm } from "node:fs/promises"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
const originalText = await Bun.file("package.json").text()
const pkg = JSON.parse(originalText) as {
name: string
version: string
exports: Record<string, string | { import: string; types: string }>
}
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
console.log(`already published ${pkg.name}@${pkg.version}`)
process.exit(0)
}
try {
await $`bun run typecheck`
await $`bun run build`
pkg.exports = Object.fromEntries(
Object.entries(pkg.exports).map(([key, value]) => {
if (typeof value !== "string") return [key, value]
return [
key,
{
import: value.replace("./src/", "./dist/").replace(/\.ts$/, ".js"),
types: value.replace("./src/", "./dist/").replace(/\.ts$/, ".d.ts"),
},
]
}),
)
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
await rm(tarball, { force: true })
await $`bun pm pack`
await $`npm publish ${tarball} --tag ${Script.channel} --access public`
} finally {
await Bun.write("package.json", originalText)
await rm(tarball, { force: true })
}
+8
View File
@@ -0,0 +1,8 @@
import type { ModelApi, ProviderApi } from "./api/api.js"
export type * from "./api/api.js"
export interface CatalogApi<E = never> {
readonly provider: ProviderApi<E>
readonly model: ModelApi<E>
}
@@ -1,7 +1,7 @@
// Generated by @opencode-ai/httpapi-codegen. Do not edit. // 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
@@ -153,96 +153,105 @@ export type Endpoint4_11Input = {
export type Endpoint4_11Output = EffectValue<ReturnType<RawClient["server.session"]["session.synthetic"]>> export type Endpoint4_11Output = EffectValue<ReturnType<RawClient["server.session"]["session.synthetic"]>>
export type SessionSyntheticOperation<E = never> = (input: Endpoint4_11Input) => Effect.Effect<Endpoint4_11Output, E> export type SessionSyntheticOperation<E = never> = (input: Endpoint4_11Input) => Effect.Effect<Endpoint4_11Output, E>
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.compact"]>[0] type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.shell"]>[0]
export type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] } export type Endpoint4_12Input = {
export type Endpoint4_12Output = EffectValue<ReturnType<RawClient["server.session"]["session.compact"]>> readonly sessionID: Endpoint4_12Request["params"]["sessionID"]
export type SessionCompactOperation<E = never> = (input: Endpoint4_12Input) => Effect.Effect<Endpoint4_12Output, E> readonly id?: Endpoint4_12Request["payload"]["id"]
readonly command: Endpoint4_12Request["payload"]["command"]
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
export type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] }
export type Endpoint4_13Output = EffectValue<ReturnType<RawClient["server.session"]["session.wait"]>>
export type SessionWaitOperation<E = never> = (input: Endpoint4_13Input) => Effect.Effect<Endpoint4_13Output, E>
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
export type Endpoint4_14Input = {
readonly sessionID: Endpoint4_14Request["params"]["sessionID"]
readonly messageID: Endpoint4_14Request["payload"]["messageID"]
readonly files?: Endpoint4_14Request["payload"]["files"]
} }
export type Endpoint4_14Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.stage"]>>["data"] export type Endpoint4_12Output = EffectValue<ReturnType<RawClient["server.session"]["session.shell"]>>
export type SessionRevertStageOperation<E = never> = (input: Endpoint4_14Input) => Effect.Effect<Endpoint4_14Output, E> export type SessionShellOperation<E = never> = (input: Endpoint4_12Input) => Effect.Effect<Endpoint4_12Output, E>
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0] type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
export type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] } export type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] }
export type Endpoint4_15Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.clear"]>> export type Endpoint4_13Output = EffectValue<ReturnType<RawClient["server.session"]["session.compact"]>>
export type SessionRevertClearOperation<E = never> = (input: Endpoint4_15Input) => Effect.Effect<Endpoint4_15Output, E> export type SessionCompactOperation<E = never> = (input: Endpoint4_13Input) => Effect.Effect<Endpoint4_13Output, E>
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0] type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
export type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] }
export type Endpoint4_14Output = EffectValue<ReturnType<RawClient["server.session"]["session.wait"]>>
export type SessionWaitOperation<E = never> = (input: Endpoint4_14Input) => Effect.Effect<Endpoint4_14Output, E>
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
export type Endpoint4_15Input = {
readonly sessionID: Endpoint4_15Request["params"]["sessionID"]
readonly messageID: Endpoint4_15Request["payload"]["messageID"]
readonly files?: Endpoint4_15Request["payload"]["files"]
}
export type Endpoint4_15Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.stage"]>>["data"]
export type SessionRevertStageOperation<E = never> = (input: Endpoint4_15Input) => Effect.Effect<Endpoint4_15Output, E>
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
export type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] } export type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] }
export type Endpoint4_16Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.commit"]>> export type Endpoint4_16Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.clear"]>>
export type SessionRevertCommitOperation<E = never> = (input: Endpoint4_16Input) => Effect.Effect<Endpoint4_16Output, E> export type SessionRevertClearOperation<E = never> = (input: Endpoint4_16Input) => Effect.Effect<Endpoint4_16Output, E>
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.context"]>[0] type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
export type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] } export type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] }
export type Endpoint4_17Output = EffectValue<ReturnType<RawClient["server.session"]["session.context"]>>["data"] export type Endpoint4_17Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.commit"]>>
export type SessionContextOperation<E = never> = (input: Endpoint4_17Input) => Effect.Effect<Endpoint4_17Output, E> export type SessionRevertCommitOperation<E = never> = (input: Endpoint4_17Input) => Effect.Effect<Endpoint4_17Output, E>
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.context.entry.list"]>[0] type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.context"]>[0]
export type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] } export type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] }
export type Endpoint4_18Output = EffectValue< export type Endpoint4_18Output = EffectValue<ReturnType<RawClient["server.session"]["session.context"]>>["data"]
export type SessionContextOperation<E = never> = (input: Endpoint4_18Input) => Effect.Effect<Endpoint4_18Output, E>
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.context.entry.list"]>[0]
export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
export type Endpoint4_19Output = EffectValue<
ReturnType<RawClient["server.session"]["session.context.entry.list"]> ReturnType<RawClient["server.session"]["session.context.entry.list"]>
>["data"] >["data"]
export type SessionListContextEntriesOperation<E = never> = ( export type SessionListContextEntriesOperation<E = never> = (
input: Endpoint4_18Input,
) => Effect.Effect<Endpoint4_18Output, E>
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.context.entry.put"]>[0]
export type Endpoint4_19Input = {
readonly sessionID: Endpoint4_19Request["params"]["sessionID"]
readonly key: Endpoint4_19Request["params"]["key"]
readonly value: Endpoint4_19Request["payload"]["value"]
}
export type Endpoint4_19Output = EffectValue<ReturnType<RawClient["server.session"]["session.context.entry.put"]>>
export type SessionPutContextEntryOperation<E = never> = (
input: Endpoint4_19Input, input: Endpoint4_19Input,
) => Effect.Effect<Endpoint4_19Output, E> ) => Effect.Effect<Endpoint4_19Output, E>
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.context.entry.remove"]>[0] type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.context.entry.put"]>[0]
export type Endpoint4_20Input = { export type Endpoint4_20Input = {
readonly sessionID: Endpoint4_20Request["params"]["sessionID"] readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
readonly key: Endpoint4_20Request["params"]["key"] readonly key: Endpoint4_20Request["params"]["key"]
readonly value: Endpoint4_20Request["payload"]["value"]
} }
export type Endpoint4_20Output = EffectValue<ReturnType<RawClient["server.session"]["session.context.entry.remove"]>> export type Endpoint4_20Output = EffectValue<ReturnType<RawClient["server.session"]["session.context.entry.put"]>>
export type SessionRemoveContextEntryOperation<E = never> = ( export type SessionPutContextEntryOperation<E = never> = (
input: Endpoint4_20Input, input: Endpoint4_20Input,
) => Effect.Effect<Endpoint4_20Output, E> ) => Effect.Effect<Endpoint4_20Output, E>
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.log"]>[0] type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.context.entry.remove"]>[0]
export type Endpoint4_21Input = { export type Endpoint4_21Input = {
readonly sessionID: Endpoint4_21Request["params"]["sessionID"] readonly sessionID: Endpoint4_21Request["params"]["sessionID"]
readonly after?: Endpoint4_21Request["query"]["after"] readonly key: Endpoint4_21Request["params"]["key"]
readonly follow?: Endpoint4_21Request["query"]["follow"]
} }
export type Endpoint4_21Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.log"]>>> export type Endpoint4_21Output = EffectValue<ReturnType<RawClient["server.session"]["session.context.entry.remove"]>>
export type SessionLogOperation<E = never> = (input: Endpoint4_21Input) => Stream.Stream<Endpoint4_21Output, E> export type SessionRemoveContextEntryOperation<E = never> = (
input: Endpoint4_21Input,
) => Effect.Effect<Endpoint4_21Output, E>
type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0] type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.log"]>[0]
export type Endpoint4_22Input = { readonly sessionID: Endpoint4_22Request["params"]["sessionID"] } export type Endpoint4_22Input = {
export type Endpoint4_22Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>> readonly sessionID: Endpoint4_22Request["params"]["sessionID"]
export type SessionInterruptOperation<E = never> = (input: Endpoint4_22Input) => Effect.Effect<Endpoint4_22Output, E> readonly after?: Endpoint4_22Request["query"]["after"]
readonly follow?: Endpoint4_22Request["query"]["follow"]
}
export type Endpoint4_22Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.log"]>>>
export type SessionLogOperation<E = never> = (input: Endpoint4_22Input) => Stream.Stream<Endpoint4_22Output, E>
type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.background"]>[0] type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
export type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] } export type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] }
export type Endpoint4_23Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>> export type Endpoint4_23Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
export type SessionBackgroundOperation<E = never> = (input: Endpoint4_23Input) => Effect.Effect<Endpoint4_23Output, E> export type SessionInterruptOperation<E = never> = (input: Endpoint4_23Input) => Effect.Effect<Endpoint4_23Output, E>
type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.message"]>[0] type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.background"]>[0]
export type Endpoint4_24Input = { export type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] }
readonly sessionID: Endpoint4_24Request["params"]["sessionID"] export type Endpoint4_24Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
readonly messageID: Endpoint4_24Request["params"]["messageID"] export type SessionBackgroundOperation<E = never> = (input: Endpoint4_24Input) => Effect.Effect<Endpoint4_24Output, E>
type Endpoint4_25Request = Parameters<RawClient["server.session"]["session.message"]>[0]
export type Endpoint4_25Input = {
readonly sessionID: Endpoint4_25Request["params"]["sessionID"]
readonly messageID: Endpoint4_25Request["params"]["messageID"]
} }
export type Endpoint4_24Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"] export type Endpoint4_25Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
export type SessionMessageOperation<E = never> = (input: Endpoint4_24Input) => Effect.Effect<Endpoint4_24Output, E> export type SessionMessageOperation<E = never> = (input: Endpoint4_25Input) => Effect.Effect<Endpoint4_25Output, E>
export interface SessionApi<E = never> { export interface SessionApi<E = never> {
readonly list: SessionListOperation<E> readonly list: SessionListOperation<E>
@@ -257,6 +266,7 @@ export interface SessionApi<E = never> {
readonly command: SessionCommandOperation<E> readonly command: SessionCommandOperation<E>
readonly skill: SessionSkillOperation<E> readonly skill: SessionSkillOperation<E>
readonly synthetic: SessionSyntheticOperation<E> readonly synthetic: SessionSyntheticOperation<E>
readonly shell: SessionShellOperation<E>
readonly compact: SessionCompactOperation<E> readonly compact: SessionCompactOperation<E>
readonly wait: SessionWaitOperation<E> readonly wait: SessionWaitOperation<E>
readonly revertStage: SessionRevertStageOperation<E> readonly revertStage: SessionRevertStageOperation<E>
+75 -62
View File
@@ -208,23 +208,35 @@ const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11I
payload: { text: input["text"], description: input["description"], metadata: input["metadata"] }, payload: { text: input["text"], description: input["description"], metadata: input["metadata"] },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.compact"]>[0] type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.shell"]>[0]
type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] } type Endpoint4_12Input = {
readonly sessionID: Endpoint4_12Request["params"]["sessionID"]
readonly id?: Endpoint4_12Request["payload"]["id"]
readonly command: Endpoint4_12Request["payload"]["command"]
}
const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) => const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) =>
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) raw["session.shell"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], command: input["command"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.wait"]>[0] type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] } type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] }
const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) => const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) =>
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] }
const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) =>
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0] type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
type Endpoint4_14Input = { type Endpoint4_15Input = {
readonly sessionID: Endpoint4_14Request["params"]["sessionID"] readonly sessionID: Endpoint4_15Request["params"]["sessionID"]
readonly messageID: Endpoint4_14Request["payload"]["messageID"] readonly messageID: Endpoint4_15Request["payload"]["messageID"]
readonly files?: Endpoint4_14Request["payload"]["files"] readonly files?: Endpoint4_15Request["payload"]["files"]
} }
const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) => const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) =>
raw["session.revert.stage"]({ raw["session.revert.stage"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
payload: { messageID: input["messageID"], files: input["files"] }, payload: { messageID: input["messageID"], files: input["files"] },
@@ -233,61 +245,61 @@ const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14I
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0] type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] }
const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) =>
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] } type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] }
const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) => const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) =>
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.context"]>[0] type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] } type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] }
const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) => const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) =>
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] }
const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) =>
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe( raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.context.entry.list"]>[0] type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.context.entry.list"]>[0]
type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] } type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) => const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) =>
raw["session.context.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe( raw["session.context.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.context.entry.put"]>[0] type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.context.entry.put"]>[0]
type Endpoint4_19Input = { type Endpoint4_20Input = {
readonly sessionID: Endpoint4_19Request["params"]["sessionID"] readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
readonly key: Endpoint4_19Request["params"]["key"] readonly key: Endpoint4_20Request["params"]["key"]
readonly value: Endpoint4_19Request["payload"]["value"] readonly value: Endpoint4_20Request["payload"]["value"]
} }
const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) => const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) =>
raw["session.context.entry.put"]({ raw["session.context.entry.put"]({
params: { sessionID: input["sessionID"], key: input["key"] }, params: { sessionID: input["sessionID"], key: input["key"] },
payload: { value: input["value"] }, payload: { value: input["value"] },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.context.entry.remove"]>[0] type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.context.entry.remove"]>[0]
type Endpoint4_20Input = { type Endpoint4_21Input = {
readonly sessionID: Endpoint4_20Request["params"]["sessionID"] readonly sessionID: Endpoint4_21Request["params"]["sessionID"]
readonly key: Endpoint4_20Request["params"]["key"] readonly key: Endpoint4_21Request["params"]["key"]
} }
const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) => const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) =>
raw["session.context.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe( raw["session.context.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
) )
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.log"]>[0] type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.log"]>[0]
type Endpoint4_21Input = { type Endpoint4_22Input = {
readonly sessionID: Endpoint4_21Request["params"]["sessionID"] readonly sessionID: Endpoint4_22Request["params"]["sessionID"]
readonly after?: Endpoint4_21Request["query"]["after"] readonly after?: Endpoint4_22Request["query"]["after"]
readonly follow?: Endpoint4_21Request["query"]["follow"] readonly follow?: Endpoint4_22Request["query"]["follow"]
} }
const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) => const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) =>
Stream.unwrap( Stream.unwrap(
raw["session.log"]({ raw["session.log"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
@@ -298,22 +310,22 @@ const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21I
), ),
) )
type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0] type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint4_22Input = { readonly sessionID: Endpoint4_22Request["params"]["sessionID"] }
const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) =>
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.background"]>[0]
type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] } type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] }
const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23Input) => const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23Input) =>
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.background"]>[0]
type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] }
const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) =>
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.message"]>[0] type Endpoint4_25Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint4_24Input = { type Endpoint4_25Input = {
readonly sessionID: Endpoint4_24Request["params"]["sessionID"] readonly sessionID: Endpoint4_25Request["params"]["sessionID"]
readonly messageID: Endpoint4_24Request["params"]["messageID"] readonly messageID: Endpoint4_25Request["params"]["messageID"]
} }
const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) => const Endpoint4_25 = (raw: RawClient["server.session"]) => (input: Endpoint4_25Input) =>
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
@@ -332,19 +344,20 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({
command: Endpoint4_9(raw), command: Endpoint4_9(raw),
skill: Endpoint4_10(raw), skill: Endpoint4_10(raw),
synthetic: Endpoint4_11(raw), synthetic: Endpoint4_11(raw),
compact: Endpoint4_12(raw), shell: Endpoint4_12(raw),
wait: Endpoint4_13(raw), compact: Endpoint4_13(raw),
revertStage: Endpoint4_14(raw), wait: Endpoint4_14(raw),
revertClear: Endpoint4_15(raw), revertStage: Endpoint4_15(raw),
revertCommit: Endpoint4_16(raw), revertClear: Endpoint4_16(raw),
context: Endpoint4_17(raw), revertCommit: Endpoint4_17(raw),
listContextEntries: Endpoint4_18(raw), context: Endpoint4_18(raw),
putContextEntry: Endpoint4_19(raw), listContextEntries: Endpoint4_19(raw),
removeContextEntry: Endpoint4_20(raw), putContextEntry: Endpoint4_20(raw),
log: Endpoint4_21(raw), removeContextEntry: Endpoint4_21(raw),
interrupt: Endpoint4_22(raw), log: Endpoint4_22(raw),
background: Endpoint4_23(raw), interrupt: Endpoint4_23(raw),
message: Endpoint4_24(raw), background: Endpoint4_24(raw),
message: Endpoint4_25(raw),
}) })
type Endpoint5_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0] type Endpoint5_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
+17 -1
View File
@@ -1,8 +1,23 @@
// 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 type { Transport, ServiceOptions } 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"
export { Credential } from "@opencode-ai/schema/credential" export { Credential } from "@opencode-ai/schema/credential"
@@ -28,3 +43,4 @@ export { SessionMessage } from "@opencode-ai/schema/session-message"
export { Skill } from "@opencode-ai/schema/skill" export { 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>>
+27 -27
View File
@@ -16,7 +16,7 @@ export type Transport = {
readonly headers?: RequestInit["headers"] readonly headers?: RequestInit["headers"]
} }
export type ServiceOptions = { export type Options = {
// Absolute path to the service registration file. Defaults to // Absolute path to the service registration file. Defaults to
// opencode/service.json in the XDG state directory. // opencode/service.json in the XDG state directory.
readonly file?: string readonly file?: string
@@ -30,21 +30,21 @@ export type ServiceOptions = {
// Read-only lookup: registration file plus health check and version gate. // Read-only lookup: registration file plus health check and version gate.
// Never spawns; escalation to start() is the caller's policy. // Never spawns; escalation to start() is the caller's policy.
export const discover = Effect.fn("service.discover")(function* (options: ServiceOptions = {}) { export const discover = Effect.fn("service.discover")(function* (options: Options = {}) {
const registration = yield* read(options.file) const info = yield* read(options.file)
if (registration === undefined) return undefined if (info === undefined) return undefined
if (options.version !== undefined && registration.version !== options.version) return undefined if (options.version !== undefined && info.version !== options.version) return undefined
const found = yield* probe(registration) const found = yield* probe(info)
return found?.transport return found?.transport
}) })
// Idempotent ensure-running: reuses a healthy compatible server, replaces a // Idempotent ensure-running: reuses a healthy compatible server, replaces a
// version-mismatched one, and otherwise spawns the service command detached. // version-mismatched one, and otherwise spawns the service command detached.
export const start = Effect.fn("service.start")(function* (options: ServiceOptions = {}) { export const start = Effect.fn("service.start")(function* (options: Options = {}) {
const compatible = yield* discover(options) const compatible = yield* discover(options)
if (compatible !== undefined) return compatible if (compatible !== undefined) return compatible
const mismatched = yield* find(options) const mismatched = yield* find(options)
if (mismatched !== undefined) yield* kill(mismatched.registration, options).pipe(Effect.ignore) if (mismatched !== undefined) yield* kill(mismatched.info, options).pipe(Effect.ignore)
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"] const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
if (command === undefined) return yield* Effect.fail(new Error("Missing service command")) if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
@@ -64,10 +64,10 @@ export const start = Effect.fn("service.start")(function* (options: ServiceOptio
) )
}) })
export const stop = Effect.fn("service.stop")(function* (options: ServiceOptions = {}) { export const stop = Effect.fn("service.stop")(function* (options: Options = {}) {
const fs = yield* FileSystem.FileSystem const fs = yield* FileSystem.FileSystem
const existing = yield* find(options) const existing = yield* find(options)
if (existing !== undefined) yield* kill(existing.registration, options) if (existing !== undefined) yield* kill(existing.info, options)
yield* fs.remove(options.file ?? fallback()).pipe(Effect.ignore) yield* fs.remove(options.file ?? fallback()).pipe(Effect.ignore)
}) })
@@ -80,18 +80,18 @@ function auth(password: string): RequestInit["headers"] {
return { authorization: "Basic " + btoa("opencode:" + password) } return { authorization: "Basic " + btoa("opencode:" + password) }
} }
const Registration = Schema.Struct({ export const Info = Schema.Struct({
id: Schema.optional(Schema.String), id: Schema.optional(Schema.String),
version: Schema.optional(Schema.String), version: Schema.optional(Schema.String),
url: Schema.String, url: Schema.String,
pid: Schema.Int.check(Schema.isGreaterThan(0)), pid: Schema.Int.check(Schema.isGreaterThan(0)),
password: Schema.optional(Schema.String), password: Schema.optional(Schema.String),
}) })
type Registration = typeof Registration.Type export type Info = typeof Info.Type
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration)) const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
// A missing or corrupt file means no valid registration; callers treat both // A missing or corrupt file means no valid info; callers treat both
// the same (the registering server self-evicts, clients rediscover). // the same (the registering server self-evicts, clients rediscover).
const read = Effect.fnUntraced(function* (file?: string) { const read = Effect.fnUntraced(function* (file?: string) {
const fs = yield* FileSystem.FileSystem const fs = yield* FileSystem.FileSystem
@@ -101,14 +101,14 @@ const read = Effect.fnUntraced(function* (file?: string) {
}) })
type LocalService = { type LocalService = {
readonly registration: Registration readonly info: Info
readonly transport: Transport readonly transport: Transport
} }
const probe = Effect.fnUntraced(function* (registration: Registration) { const probe = Effect.fnUntraced(function* (info: Info) {
const headers = registration.password === undefined ? undefined : auth(registration.password) const headers = info.password === undefined ? undefined : auth(info.password)
const healthy = yield* Effect.tryPromise(() => const healthy = yield* Effect.tryPromise(() =>
fetch(new URL("/api/health", registration.url), { fetch(new URL("/api/health", info.url), {
headers, headers,
signal: AbortSignal.timeout(2_000), signal: AbortSignal.timeout(2_000),
}), }),
@@ -117,15 +117,15 @@ const probe = Effect.fnUntraced(function* (registration: Registration) {
Effect.orElseSucceed(() => false), Effect.orElseSucceed(() => false),
) )
if (!healthy) return undefined if (!healthy) return undefined
return { registration, transport: { url: registration.url, headers } } satisfies LocalService return { info, transport: { url: info.url, headers } } satisfies LocalService
}) })
// Health-checked lookup without the version gate: lifecycle operations must be // Health-checked lookup without the version gate: lifecycle operations must be
// able to see (and replace or stop) a server from a different version. // able to see (and replace or stop) a server from a different version.
const find = Effect.fnUntraced(function* (options: ServiceOptions) { const find = Effect.fnUntraced(function* (options: Options) {
const registration = yield* read(options.file) const info = yield* read(options.file)
if (registration === undefined) return undefined if (info === undefined) return undefined
return yield* probe(registration) return yield* probe(info)
}) })
// 50ms cadence bounded at ~5s, shared by stop escalation and start readiness. // 50ms cadence bounded at ~5s, shared by stop escalation and start readiness.
@@ -142,22 +142,22 @@ const stopped = Effect.fnUntraced(function* (pid: number) {
return yield* Effect.fail(new Error(`Server process ${pid} is still running`)) return yield* Effect.fail(new Error(`Server process ${pid} is still running`))
}) })
function same(left: Registration, right: Registration) { function same(left: Info, right: Info) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
} }
const kill = Effect.fnUntraced(function* (info: Registration, options: ServiceOptions) { const kill = Effect.fnUntraced(function* (info: Info, options: Options) {
// A stale registration may point at a PID that has since been reused by // A stale registration may point at a PID that has since been reused by
// another process. Only signal the PID after authenticating the server. // another process. Only signal the PID after authenticating the server.
const current = yield* find(options) const current = yield* find(options)
if (current === undefined || !same(current.registration, info)) return if (current === undefined || !same(current.info, info)) return
yield* signal(info.pid, "SIGTERM") yield* signal(info.pid, "SIGTERM")
const done = yield* stopped(info.pid).pipe(Effect.retry(poll), Effect.option) const done = yield* stopped(info.pid).pipe(Effect.retry(poll), Effect.option)
if (Option.isSome(done)) return if (Option.isSome(done)) return
const latest = yield* find(options) const latest = yield* find(options)
if (latest === undefined || !same(latest.registration, info)) return if (latest === undefined || !same(latest.info, info)) return
yield* signal(info.pid, "SIGKILL") yield* signal(info.pid, "SIGKILL")
yield* stopped(info.pid).pipe(Effect.retry(poll)) yield* stopped(info.pid).pipe(Effect.retry(poll))
}) })
+41
View File
@@ -0,0 +1,41 @@
import type {
AgentApi as EffectAgentApi,
CommandApi as EffectCommandApi,
EventApi as EffectEventApi,
IntegrationApi as EffectIntegrationApi,
ModelApi as EffectModelApi,
PluginApi as EffectPluginApi,
ProviderApi as EffectProviderApi,
ReferenceApi as EffectReferenceApi,
SessionApi as EffectSessionApi,
SkillApi as EffectSkillApi,
} from "../effect/api/api.js"
import type { Effect, Stream } from "effect"
type PromisifyOperation<Operation> = Operation extends (
...args: infer Args
) => Effect.Effect<infer Success, unknown, unknown>
? (...args: Args) => Promise<Success>
: Operation extends (...args: infer Args) => Stream.Stream<infer Success, unknown, unknown>
? (...args: Args) => AsyncIterable<Success>
: Operation
type PromisifyApi<Api> = {
readonly [Name in keyof Api]: PromisifyOperation<Api[Name]>
}
export type AgentApi = PromisifyApi<EffectAgentApi<unknown>>
export type CommandApi = PromisifyApi<EffectCommandApi<unknown>>
export type EventApi = PromisifyApi<EffectEventApi<unknown>>
export type IntegrationApi = PromisifyApi<EffectIntegrationApi<unknown>>
export type ModelApi = PromisifyApi<EffectModelApi<unknown>>
export type PluginApi = PromisifyApi<EffectPluginApi<unknown>>
export type ProviderApi = PromisifyApi<EffectProviderApi<unknown>>
export type ReferenceApi = PromisifyApi<EffectReferenceApi<unknown>>
export type SessionApi = PromisifyApi<EffectSessionApi<unknown>>
export type SkillApi = PromisifyApi<EffectSkillApi<unknown>>
export interface CatalogApi {
readonly provider: ProviderApi
readonly model: ModelApi
}
@@ -29,6 +29,8 @@ import type {
SessionSkillOutput, SessionSkillOutput,
SessionSyntheticInput, SessionSyntheticInput,
SessionSyntheticOutput, SessionSyntheticOutput,
SessionShellInput,
SessionShellOutput,
SessionCompactInput, SessionCompactInput,
SessionCompactOutput, SessionCompactOutput,
SessionWaitInput, SessionWaitInput,
@@ -525,6 +527,18 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
), ),
shell: (input: SessionShellInput, requestOptions?: RequestOptions) =>
request<SessionShellOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/shell`,
body: { id: input["id"], command: input["command"] },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
compact: (input: SessionCompactInput, requestOptions?: RequestOptions) => compact: (input: SessionCompactInput, requestOptions?: RequestOptions) =>
request<SessionCompactOutput>( request<SessionCompactOutput>(
{ {
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -1,4 +1,16 @@
export * from "./generated/index" export * from "./generated/index"
export type { Transport } from "../effect/service.js" export type {
AgentApi,
CatalogApi,
CommandApi,
EventApi,
IntegrationApi,
ModelApi,
PluginApi,
ProviderApi,
ReferenceApi,
SessionApi,
SkillApi,
} from "./api.js"
export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types" export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types"
export type OpenCodeClient = ReturnType<typeof import("./generated/client").make> export type OpenCodeClient = ReturnType<typeof import("./generated/client").make>
+10
View File
@@ -0,0 +1,10 @@
import { Effect } from "effect"
import { OpenCode as EffectOpenCode, type AppApi as EffectApi } from "../src/effect"
type EffectClient = Effect.Success<ReturnType<typeof EffectOpenCode.make>>
declare const effectClient: EffectClient
const effectApi: EffectApi<unknown> = effectClient
void effectApi
+19 -10
View File
@@ -1,7 +1,17 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { DateTime, Effect, Stream } from "effect" import { DateTime, Effect, Stream } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AbsolutePath, Agent, Event, Location, Model, OpenCode, Prompt, Session, SessionMessage } from "../src/effect/index" import {
AbsolutePath,
Agent,
Event,
Location,
Model,
OpenCode,
Prompt,
Session,
SessionMessage,
} from "../src/effect/index"
const synced = { type: "log.synced" as const, aggregateID: "ses_test", seq: Event.Seq.make(1) } const synced = { type: "log.synced" as const, aggregateID: "ses_test", seq: Event.Seq.make(1) }
@@ -23,7 +33,7 @@ test("event.subscribe exposes and decodes the native Effect event stream", async
HttpClientResponse.fromWeb( HttpClientResponse.fromWeb(
request, request,
new Response( new Response(
`data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` + `data: ${JSON.stringify({ id: "evt_connected", created: 0, type: "server.connected", data: {} })}\n\n` +
`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, `data: ${JSON.stringify(modelSwitchedEvent)}\n\n`,
{ headers: { "content-type": "text/event-stream" } }, { headers: { "content-type": "text/event-stream" } },
), ),
@@ -35,10 +45,10 @@ test("event.subscribe exposes and decodes the native Effect event stream", async
return yield* client.event.subscribe().pipe(Stream.runCollect) 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", "session.next.model.switched"]) 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 !== "session.next.model.switched") throw new Error("Expected model event") if (durable?.type !== "session.model.selected") throw new Error("Expected model event")
expect(DateTime.toEpochMillis(durable.data.timestamp)).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 })
}) })
@@ -149,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(["session.next.model.switched", "log.synced"]) expect(logged.map((item) => item.type)).toEqual(["session.model.selected", "log.synced"])
expect(logged[0]?.type === "session.next.model.switched" && DateTime.toEpochMillis(logged[0].data.timestamp)).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)
@@ -217,12 +227,11 @@ const modelSwitchedMessage = {
const modelSwitchedEvent = { const modelSwitchedEvent = {
id: "evt_model", id: "evt_model",
type: "session.next.model.switched", created: 1_717_171_717_000,
type: "session.model.selected",
durable: { aggregateID: "ses_test", seq: 1, version: 1 }, durable: { aggregateID: "ses_test", seq: 1, version: 1 },
data: { data: {
timestamp: 1_717_171_717_000,
sessionID: "ses_test", sessionID: "ses_test",
messageID: "msg_model",
model: { id: "claude", providerID: "anthropic" }, model: { id: "claude", providerID: "anthropic" },
}, },
} }
+5 -6
View File
@@ -151,7 +151,7 @@ test("event.subscribe exposes the Promise event stream wire projection", async (
baseUrl: "http://localhost:3000", baseUrl: "http://localhost:3000",
fetch: async () => fetch: async () =>
new Response( new Response(
`: heartbeat\n\ndata: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` + `: heartbeat\n\ndata: ${JSON.stringify({ id: "evt_connected", created: 0, type: "server.connected", data: {} })}\n\n` +
`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, `data: ${JSON.stringify(modelSwitchedEvent)}\n\n`,
{ headers: { "content-type": "text/event-stream" } }, { headers: { "content-type": "text/event-stream" } },
), ),
@@ -159,8 +159,8 @@ test("event.subscribe exposes the Promise event stream wire projection", async (
const events = [] const events = []
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", type: "server.connected", data: {} }, modelSwitchedEvent]) expect(events).toEqual([{ id: "evt_connected", created: 0, type: "server.connected", data: {} }, modelSwitchedEvent])
expect(events[1]?.type === "session.next.model.switched" && events[1].data.timestamp).toBe(1_717_171_717_000) expect(events[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 () => {
@@ -328,12 +328,11 @@ const synced = { type: "log.synced", aggregateID: "ses_test", seq: 1 }
const modelSwitchedEvent = { const modelSwitchedEvent = {
id: "evt_model", id: "evt_model",
type: "session.next.model.switched", created: 1_717_171_717_000,
type: "session.model.selected",
durable: { aggregateID: "ses_test", seq: 1, version: 1 }, durable: { aggregateID: "ses_test", seq: 1, version: 1 },
data: { data: {
timestamp: 1_717_171_717_000,
sessionID: "ses_test", sessionID: "ses_test",
messageID: "msg_model",
model: { id: "claude", providerID: "anthropic" }, model: { id: "claude", providerID: "anthropic" },
}, },
} }
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"noEmit": false,
"declaration": true
},
"include": ["src"]
}
+2
View File
@@ -3,6 +3,8 @@
"extends": "@tsconfig/bun/tsconfig.json", "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"]
+7 -6
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
? [] ? []
+5 -5
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`",
+40
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: {')
})
})
+12 -2
View File
@@ -1,9 +1,9 @@
{ {
"version": "7", "version": "7",
"dialect": "sqlite", "dialect": "sqlite",
"id": "22e57fed-b9b8-4e94-a3b4-f94bece680a8", "id": "96e9fe64-d810-4102-8f79-3317a88bb6d2",
"prevIds": [ "prevIds": [
"f14a9b18-8207-487e-a3d3-227e629ba9ad" "22e57fed-b9b8-4e94-a3b4-f94bece680a8"
], ],
"ddl": [ "ddl": [
{ {
@@ -526,6 +526,16 @@
"entityType": "columns", "entityType": "columns",
"table": "event" "table": "event"
}, },
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created",
"entityType": "columns",
"table": "event"
},
{ {
"type": "text", "type": "text",
"notNull": true, "notNull": true,
+1 -1
View File
@@ -1,7 +1,7 @@
export * as AccountV2 from "./account" export * as AccountV2 from "./account"
import { Schema } from "effect" import { Schema } from "effect"
import type * as HttpClientError from "effect/unstable/http/HttpClientError" import type { HttpClientError } from "effect/unstable/http"
export const ID = Schema.String.pipe(Schema.brand("AccountID")) export const ID = Schema.String.pipe(Schema.brand("AccountID"))
export type ID = Schema.Schema.Type<typeof ID> export type ID = Schema.Schema.Type<typeof ID>
+2 -13
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] })
+57 -21
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,10 +173,9 @@ const layer = Layer.effect(
] ]
}) })
const discover = Effect.fn("Config.discover")(function* () {
const globalDirectory = AbsolutePath.make(global.config) const globalDirectory = AbsolutePath.make(global.config)
const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config) const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config)
// Read configuration once when this location opens. Later calls reuse these
// values until the location is reopened.
const discovered = locationIsGlobal const discovered = locationIsGlobal
? [] ? []
: yield* fs : yield* fs
@@ -190,25 +192,59 @@ const layer = Layer.effect(
.toReversed() .toReversed()
.map((directory) => AbsolutePath.make(directory)), .map((directory) => AbsolutePath.make(directory)),
] ]
// A config closer to the opened directory should win over one higher up.
// Search starts nearby, so reverse the results before applying them.
const directPaths = discovered.filter((item) => path.basename(item) !== ".opencode").toReversed() const directPaths = discovered.filter((item) => path.basename(item) !== ".opencode").toReversed()
const direct = yield* Effect.forEach(directPaths, loadFile).pipe( const direct = yield* Effect.forEach(directPaths, loadFile).pipe(
Effect.orDie, Effect.orDie,
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)), Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
) )
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie) const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
// Apply general settings first and more specific settings last: return {
// global config, project files, then `.opencode` files. entries: [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()],
const configs = [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()] directories,
// Rules use the opposite order so a user-global rule can override a files: directPaths,
// repository rule. Statement order inside each file stays unchanged. }
yield* policy.load( })
configs
.filter((config): config is Document => config.type === "document") const initial = yield* discover()
.toReversed() let configs = initial.entries
.flatMap((config) => config.info.experimental?.policies ?? []), 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 })),
]
const reconcile = Effect.fn("Config.reconcileWatches")(function* (snapshot: typeof initial) {
const next = new Map(targets(snapshot).map((target) => [JSON.stringify(target), target]))
for (const [key, stop] of subscriptions) {
if (next.has(key)) continue
yield* stop
subscriptions.delete(key)
}
for (const [key, target] of next) {
if (subscriptions.has(key)) continue
const fiber = yield* watcher.subscribe(target).pipe(
Stream.runForEach((update) => PubSub.publish(updates, update)),
Effect.forkScoped({ startImmediately: true }),
) )
subscriptions.set(key, Fiber.interrupt(fiber))
}
})
yield* Stream.fromPubSub(updates).pipe(
Stream.debounce("100 millis"),
Stream.runForEach((update) =>
Effect.gen(function* () {
const next = yield* discover()
configs = next.entries
yield* reconcile(next)
yield* events.publish(ConfigSchema.Event.Updated, {})
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause }))),
),
Effect.forkScoped({ startImmediately: true }),
)
yield* reconcile(initial)
return Service.of({ return Service.of({
entries: Effect.fn("Config.entries")(function* () { entries: Effect.fn("Config.entries")(function* () {
@@ -221,5 +257,5 @@ 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],
}) })
-18
View File
@@ -1,18 +0,0 @@
export * as ConfigExperimental from "./experimental"
import { Schema } from "effect"
import { Catalog } from "../catalog"
import { Policy as PolicyV2 } from "../policy"
// Each core domain exports the policy actions it supports. Adding an action to
// this union makes it valid in authored config while keeping Policy generic.
export const PolicyAction = Schema.Union([Catalog.PolicyActions])
export class Policy extends Schema.Class<Policy>("ConfigV2.Experimental.Policy")({
...PolicyV2.Info.fields,
action: PolicyAction,
}) {}
export class Experimental extends Schema.Class<Experimental>("ConfigV2.Experimental")({
policies: Policy.pipe(Schema.Array, Schema.optional),
}) {}
+19 -8
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,9 +38,8 @@ 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)
@@ -56,14 +55,17 @@ export const Plugin = define({
) )
}) })
}).pipe(Effect.map((documents) => documents.flat())) }).pipe(Effect.map((documents) => documents.flat()))
const global = documents.flatMap((document) => document.info.permissions ?? []) })
const configuredDefault = Config.latest(documents, "default_agent") const loaded = { documents: yield* load() }
yield* ctx.agent.transform((draft) => {
const global = loaded.documents.flatMap((document) => document.info.permissions ?? [])
const configuredDefault = Config.latest(loaded.documents, "default_agent")
if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault)) if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault))
for (const current of draft.list()) { for (const current of draft.list()) {
draft.update(current.id, (agent) => agent.permissions.push(...global)) 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) {
@@ -95,7 +97,16 @@ export const Plugin = define({
}) })
} }
} }
}), })
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 }),
) )
}), }),
}) })
+17 -6
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,9 +17,8 @@ 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) => [
@@ -27,7 +26,10 @@ export const Plugin = define({
]), ]),
) )
}).pipe(Effect.map((documents) => documents.flat())) }).pipe(Effect.map((documents) => documents.flat()))
for (const document of documents) { })
const loaded = { documents: yield* load() }
yield* ctx.command.transform((draft) => {
for (const document of loaded.documents) {
for (const [name, command] of Object.entries(document.commands ?? {})) { for (const [name, command] of Object.entries(document.commands ?? {})) {
draft.update(name, (item) => { draft.update(name, (item) => {
item.template = command.template item.template = command.template
@@ -44,7 +46,16 @@ export const Plugin = define({
}) })
} }
} }
}), })
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 }),
) )
}), }),
}) })
+10 -8
View File
@@ -42,8 +42,8 @@ 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 load = Effect.fn("ConfigExternalPlugin.load")(function* () {
const configured: { package: string; options?: Record<string, any> }[] = [] 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,8 +98,8 @@ 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
@@ -108,13 +108,15 @@ export const Plugin = define({
const mod = yield* Effect.promise(() => import(entrypoint)) const mod = yield* Effect.promise(() => import(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(() => Effect.succeed(undefined))),
).pipe(Effect.map((plugins) => plugins.filter((plugin) => plugin !== undefined)))
}) })
for (const plugin of yield* load()) yield* ctx.plugin.add(plugin)
}), }),
}) })
+19 -12
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,9 +9,9 @@ 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]) =>
@@ -34,14 +34,11 @@ export const Plugin = define({
} }
} }
} }
}), })
)
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")
const configuredDefault = Config.latest(entries, "model")
if (configuredDefault !== undefined) { if (configuredDefault !== undefined) {
const model = ModelV2.parse(configuredDefault) const model = ModelV2.parse(configuredDefault)
catalog.model.default.set(model.providerID, model.modelID) catalog.model.default.set(model.providerID, model.modelID)
@@ -110,7 +107,17 @@ export const Plugin = define({
} }
} }
} }
}), })
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 }),
) )
}), }),
}) })
+14 -7
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,12 +16,10 @@ 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 const directory = doc.path ? path.dirname(doc.path) : location.directory
for (const [name, entry] of Object.entries(doc.info.references ?? {})) { for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
if (!validAlias(name)) continue if (!validAlias(name)) continue
@@ -49,7 +47,16 @@ export const Plugin = define({
} }
} }
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 }),
) )
}), }),
}) })
+15 -7
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,11 +15,10 @@ 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")) }),
@@ -44,7 +43,16 @@ export const Plugin = define({
}), }),
) )
} }
}), })
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 }),
) )
}), }),
}) })
+85
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)
})
@@ -106,8 +106,7 @@ const layer = Layer.effect(
yield* events.publish(SessionEvent.Moved, { yield* events.publish(SessionEvent.Moved, {
sessionID: input.sessionID, sessionID: input.sessionID,
location: Location.Ref.make({ directory }), location: Location.Ref.make({ directory }),
subdirectory: RelativePath.make(path.relative(destination.directory, directory).replaceAll("\\", "/")), subpath: RelativePath.make(path.relative(destination.directory, directory).replaceAll("\\", "/")),
timestamp: yield* DateTime.now,
}) })
if (patch) { if (patch) {
+12 -21
View File
@@ -1,26 +1,17 @@
import type * as Arr from "effect/Array" import type { NonEmptyReadonlyArray } from "effect/Array"
import { NodeFileSystem, NodeSink, NodeStream } from "@effect/platform-node" import { NodeFileSystem, NodePath, NodeSink, NodeStream } from "@effect/platform-node"
import * as NodePath from "@effect/platform-node/NodePath" import { Deferred, Effect, Exit, FileSystem, Layer, Path, PlatformError, Predicate, Sink, Stream } from "effect"
import * as Deferred from "effect/Deferred" import type { Scope } from "effect"
import * as Effect from "effect/Effect" import { ChildProcess } from "effect/unstable/process"
import * as Exit from "effect/Exit"
import * as FileSystem from "effect/FileSystem"
import * as Layer from "effect/Layer"
import * as Path from "effect/Path"
import * as PlatformError from "effect/PlatformError"
import * as Predicate from "effect/Predicate"
import type * as Scope from "effect/Scope"
import * as Sink from "effect/Sink"
import * as Stream from "effect/Stream"
import * as ChildProcess from "effect/unstable/process/ChildProcess"
import type { ChildProcessHandle } from "effect/unstable/process/ChildProcessSpawner"
import { import {
ChildProcessSpawner, ChildProcessSpawner,
ExitCode, ExitCode,
make as makeSpawner, make,
makeHandle, makeHandle,
ProcessId, ProcessId,
type ChildProcessHandle,
} from "effect/unstable/process/ChildProcessSpawner" } from "effect/unstable/process/ChildProcessSpawner"
// ast-grep-ignore: no-star-import
import * as NodeChildProcess from "node:child_process" import * as NodeChildProcess from "node:child_process"
import { PassThrough } from "node:stream" import { PassThrough } from "node:stream"
import launch from "cross-spawn" import launch from "cross-spawn"
@@ -71,7 +62,7 @@ const flatten = (command: ChildProcess.Command) => {
if (commands.length === 0) throw new Error("flatten produced empty commands array") if (commands.length === 0) throw new Error("flatten produced empty commands array")
const [head, ...tail] = commands const [head, ...tail] = commands
return { return {
commands: [head, ...tail] as Arr.NonEmptyReadonlyArray<ChildProcess.StandardCommand>, commands: [head, ...tail] as NonEmptyReadonlyArray<ChildProcess.StandardCommand>,
opts, opts,
} }
} }
@@ -96,7 +87,7 @@ const toPlatformError = (
type ExitSignal = Deferred.Deferred<readonly [code: number | null, signal: NodeJS.Signals | null]> type ExitSignal = Deferred.Deferred<readonly [code: number | null, signal: NodeJS.Signals | null]>
export const make = Effect.gen(function* () { const makeCrossSpawnSpawner = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path const path = yield* Path.Path
@@ -494,12 +485,12 @@ export const make = Effect.gen(function* () {
}, },
) )
return makeSpawner(spawnCommand) return make(spawnCommand)
}) })
const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSystem | Path.Path> = Layer.effect( const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSystem | Path.Path> = Layer.effect(
ChildProcessSpawner, ChildProcessSpawner,
make, makeCrossSpawnSpawner,
) )
export const node = makeGlobalNode({ service: ChildProcessSpawner, layer, deps: [filesystem, path] }) export const node = makeGlobalNode({ service: ChildProcessSpawner, layer, deps: [filesystem, path] })
+3 -3
View File
@@ -1,7 +1,7 @@
export * as Database from "./database" export * as Database from "./database"
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { layer as sqliteLayer } from "#sqlite" import { layer } from "#sqlite"
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import { Global } from "../global" import { Global } from "../global"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
@@ -19,7 +19,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/storage/Database") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/storage/Database") {}
const layer = Layer.effect( const databaseLayer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const db = yield* makeDatabase const db = yield* makeDatabase
@@ -37,7 +37,7 @@ const layer = Layer.effect(
) )
export function layerFromPath(filename: string) { export function layerFromPath(filename: string) {
return layer.pipe(Layer.provide(sqliteLayer({ filename }))) return databaseLayer.pipe(Layer.provide(layer({ filename })))
} }
export function path() { export function path() {
+3
View File
@@ -41,5 +41,8 @@ export const migrations = (
import("./migration/20260622170816_reset_v2_session_state"), import("./migration/20260622170816_reset_v2_session_state"),
import("./migration/20260622202450_simplify_session_input"), import("./migration/20260622202450_simplify_session_input"),
import("./migration/20260702134641_add_session_context_entry"), import("./migration/20260702134641_add_session_context_entry"),
import("./migration/20260703090000_reset_v2_event_rename_sweep"),
import("./migration/20260703181610_event_created_column"),
import("./migration/20260703190000_reset_v2_shell_event_payloads"),
]) ])
).map((module) => module.default) satisfies DatabaseMigration.Migration[] ).map((module) => module.default) satisfies DatabaseMigration.Migration[]
+1 -1
View File
@@ -22,7 +22,7 @@ export function apply(db: Database) {
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`, sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`,
) )
if (tables.some((table) => table.name === "session")) return yield* applyOnly(db, migrations) if (tables.some((table) => table.name === "session")) return yield* applyOnly(db, migrations)
if (tables.length > 0) return yield* Effect.die("Database is not empty and has no session table") if (tables.length > 0) return yield* Effect.die(new Error("Database is not empty and has no session table"))
yield* db.transaction((tx) => yield* db.transaction((tx) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* schema.up(tx) yield* schema.up(tx)
@@ -0,0 +1,17 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260703090000_reset_v2_event_rename_sweep",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DELETE FROM \`session_input\`;`)
yield* tx.run(`DELETE FROM \`session_message\`;`)
yield* tx.run(`DELETE FROM \`event\`;`)
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
// `created` column is added by the generated 20260703181610_event_created_column
// migration, which runs after this wipe (NOT NULL without default is safe on the
// emptied table).
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260703181610_event_created_column",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`event\` ADD \`created\` integer NOT NULL;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -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
+4 -1
View File
@@ -1,5 +1,6 @@
import nodePath from "path" import nodePath from "path"
import { customType } from "drizzle-orm/sqlite-core" import { customType } from "drizzle-orm/sqlite-core"
import { Schema } from "effect"
import { AbsolutePath } from "../schema" import { AbsolutePath } from "../schema"
function storagePath(input: string) { function storagePath(input: string) {
@@ -74,6 +75,8 @@ export const pathColumn = customType<{
}, },
}) })
const decodeAbsoluteArray = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Array(Schema.String)))
export const absoluteArrayColumn = customType<{ export const absoluteArrayColumn = customType<{
data: AbsolutePath[] data: AbsolutePath[]
driverData: string driverData: string
@@ -86,6 +89,6 @@ export const absoluteArrayColumn = customType<{
return JSON.stringify(input.map(absolute)) return JSON.stringify(input.map(absolute))
}, },
fromDriver(input) { fromDriver(input) {
return (JSON.parse(input) as string[]).map((item) => AbsolutePath.make(toPlatform(absolute(item)))) return decodeAbsoluteArray(input).map((item) => AbsolutePath.make(toPlatform(absolute(item))))
}, },
}) })
+1
View File
@@ -81,6 +81,7 @@ export default {
\`id\` text PRIMARY KEY, \`id\` text PRIMARY KEY,
\`aggregate_id\` text NOT NULL, \`aggregate_id\` text NOT NULL,
\`seq\` integer NOT NULL, \`seq\` integer NOT NULL,
\`created\` integer NOT NULL,
\`type\` text NOT NULL, \`type\` text NOT NULL,
\`data\` text NOT NULL, \`data\` text NOT NULL,
CONSTRAINT \`fk_event_aggregate_id_event_sequence_aggregate_id_fk\` FOREIGN KEY (\`aggregate_id\`) REFERENCES \`event_sequence\`(\`aggregate_id\`) ON DELETE CASCADE CONSTRAINT \`fk_event_aggregate_id_event_sequence_aggregate_id_fk\` FOREIGN KEY (\`aggregate_id\`) REFERENCES \`event_sequence\`(\`aggregate_id\`) ON DELETE CASCADE
+8 -15
View File
@@ -1,18 +1,11 @@
import { Database } from "bun:sqlite" import { Database } from "bun:sqlite"
import { drizzle } from "drizzle-orm/bun-sqlite" import { drizzle } from "drizzle-orm/bun-sqlite"
import * as Context from "effect/Context" import { Context, Effect, Fiber, Layer, Scope, Semaphore, Stream } from "effect"
import * as Effect from "effect/Effect"
import * as Fiber from "effect/Fiber"
import { identity } from "effect/Function" import { identity } from "effect/Function"
import * as Layer from "effect/Layer" import { Reactivity } from "effect/unstable/reactivity"
import * as Scope from "effect/Scope" import { SqlClient, Statement } from "effect/unstable/sql"
import * as Semaphore from "effect/Semaphore"
import * as Stream from "effect/Stream"
import * as Reactivity from "effect/unstable/reactivity/Reactivity"
import * as Client from "effect/unstable/sql/SqlClient"
import type { Connection } from "effect/unstable/sql/SqlConnection" import type { Connection } from "effect/unstable/sql/SqlConnection"
import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError" import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"
import * as Statement from "effect/unstable/sql/Statement"
import { Sqlite } from "./sqlite" import { Sqlite } from "./sqlite"
const ATTR_DB_SYSTEM_NAME = "db.system.name" const ATTR_DB_SYSTEM_NAME = "db.system.name"
@@ -20,7 +13,7 @@ const ATTR_DB_SYSTEM_NAME = "db.system.name"
const TypeId = "~@opencode-ai/core/database/SqliteBun" as const const TypeId = "~@opencode-ai/core/database/SqliteBun" as const
type TypeId = typeof TypeId type TypeId = typeof TypeId
interface SqliteClient extends Client.SqlClient { interface SqliteClient extends SqlClient.SqlClient {
readonly [TypeId]: TypeId readonly [TypeId]: TypeId
readonly config: Config readonly config: Config
readonly export: Effect.Effect<Uint8Array, SqlError> readonly export: Effect.Effect<Uint8Array, SqlError>
@@ -57,7 +50,7 @@ const make = (options: Config) =>
Effect.withFiber<Array<Record<string, unknown>>, SqlError>((fiber) => { Effect.withFiber<Array<Record<string, unknown>>, SqlError>((fiber) => {
const statement = native.query(query) const statement = native.query(query)
// @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627 // @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627
statement.safeIntegers(Context.get(fiber.context, Client.SafeIntegers)) statement.safeIntegers(Context.get(fiber.context, SqlClient.SafeIntegers))
try { try {
return Effect.succeed((statement.all(...(params as any)) ?? []) as Array<Record<string, unknown>>) return Effect.succeed((statement.all(...(params as any)) ?? []) as Array<Record<string, unknown>>)
} catch (cause) { } catch (cause) {
@@ -73,7 +66,7 @@ const make = (options: Config) =>
Effect.withFiber<Array<unknown[]>, SqlError>((fiber) => { Effect.withFiber<Array<unknown[]>, SqlError>((fiber) => {
const statement = native.query(query) const statement = native.query(query)
// @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627 // @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627
statement.safeIntegers(Context.get(fiber.context, Client.SafeIntegers)) statement.safeIntegers(Context.get(fiber.context, SqlClient.SafeIntegers))
try { try {
return Effect.succeed((statement.values(...(params as any)) ?? []) as Array<unknown[]>) return Effect.succeed((statement.values(...(params as any)) ?? []) as Array<unknown[]>)
} catch (cause) { } catch (cause) {
@@ -130,7 +123,7 @@ const make = (options: Config) =>
}) })
const client = Object.assign( const client = Object.assign(
(yield* Client.make({ (yield* SqlClient.make({
acquirer, acquirer,
compiler, compiler,
transactionAcquirer, transactionAcquirer,
@@ -166,7 +159,7 @@ const nativeLayer = (config: Config) =>
}), }),
) )
const sqliteLayer = (config: Config) => Layer.effect(Client.SqlClient, make(config)) const sqliteLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
const drizzleLayer = Layer.effect( const drizzleLayer = Layer.effect(
Sqlite.Drizzle, Sqlite.Drizzle,
+8 -15
View File
@@ -1,18 +1,11 @@
import { DatabaseSync, type SQLInputValue } from "node:sqlite" import { DatabaseSync, type SQLInputValue } from "node:sqlite"
import { drizzle } from "drizzle-orm/node-sqlite" import { drizzle } from "drizzle-orm/node-sqlite"
import * as Context from "effect/Context" import { Context, Effect, Fiber, Layer, Scope, Semaphore, Stream } from "effect"
import * as Effect from "effect/Effect"
import * as Fiber from "effect/Fiber"
import { identity } from "effect/Function" import { identity } from "effect/Function"
import * as Layer from "effect/Layer" import { Reactivity } from "effect/unstable/reactivity"
import * as Scope from "effect/Scope" import { SqlClient, Statement } from "effect/unstable/sql"
import * as Semaphore from "effect/Semaphore"
import * as Stream from "effect/Stream"
import * as Reactivity from "effect/unstable/reactivity/Reactivity"
import * as Client from "effect/unstable/sql/SqlClient"
import type { Connection } from "effect/unstable/sql/SqlConnection" import type { Connection } from "effect/unstable/sql/SqlConnection"
import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError" import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"
import * as Statement from "effect/unstable/sql/Statement"
import { Sqlite } from "./sqlite" import { Sqlite } from "./sqlite"
const ATTR_DB_SYSTEM_NAME = "db.system.name" const ATTR_DB_SYSTEM_NAME = "db.system.name"
@@ -20,7 +13,7 @@ const ATTR_DB_SYSTEM_NAME = "db.system.name"
const TypeId = "~@opencode-ai/core/database/SqliteNode" as const const TypeId = "~@opencode-ai/core/database/SqliteNode" as const
type TypeId = typeof TypeId type TypeId = typeof TypeId
interface SqliteClient extends Client.SqlClient { interface SqliteClient extends SqlClient.SqlClient {
readonly [TypeId]: TypeId readonly [TypeId]: TypeId
readonly config: Config readonly config: Config
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError> readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
@@ -56,7 +49,7 @@ const make = (options: Config) =>
const run = (query: string, params: ReadonlyArray<unknown> = []) => const run = (query: string, params: ReadonlyArray<unknown> = []) =>
Effect.withFiber<Array<Record<string, unknown>>, SqlError>((fiber) => { Effect.withFiber<Array<Record<string, unknown>>, SqlError>((fiber) => {
const statement = native.prepare(query) const statement = native.prepare(query)
statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers)) statement.setReadBigInts(Context.get(fiber.context, SqlClient.SafeIntegers))
try { try {
return Effect.succeed(statement.all(...(params as SQLInputValue[])) as Array<Record<string, unknown>>) return Effect.succeed(statement.all(...(params as SQLInputValue[])) as Array<Record<string, unknown>>)
} catch (cause) { } catch (cause) {
@@ -71,7 +64,7 @@ const make = (options: Config) =>
const runValues = (query: string, params: ReadonlyArray<unknown> = []) => const runValues = (query: string, params: ReadonlyArray<unknown> = []) =>
Effect.withFiber<ReadonlyArray<ReadonlyArray<unknown>>, SqlError>((fiber) => { Effect.withFiber<ReadonlyArray<ReadonlyArray<unknown>>, SqlError>((fiber) => {
const statement = native.prepare(query) const statement = native.prepare(query)
statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers)) statement.setReadBigInts(Context.get(fiber.context, SqlClient.SafeIntegers))
statement.setReturnArrays(true) statement.setReturnArrays(true)
try { try {
return Effect.succeed( return Effect.succeed(
@@ -124,7 +117,7 @@ const make = (options: Config) =>
}) })
const client = Object.assign( const client = Object.assign(
(yield* Client.make({ (yield* SqlClient.make({
acquirer, acquirer,
compiler, compiler,
transactionAcquirer, transactionAcquirer,
@@ -161,7 +154,7 @@ const nativeLayer = (config: Config) =>
}), }),
) )
const sqliteLayer = (config: Config) => Layer.effect(Client.SqlClient, make(config)) const sqliteLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
const drizzleLayer = Layer.effect( const drizzleLayer = Layer.effect(
Sqlite.Drizzle, Sqlite.Drizzle,
+1 -1
View File
@@ -227,7 +227,7 @@ export function hoist<A, E, T extends Tag, const Items extends Replacements = re
} }
if (node.tag === tag) { if (node.tag === tag) {
const existing = hoisted.get(node.name) const existing = hoisted.get(node.name)
if (existing && existing !== node) { if (existing && existing.implementation !== node.implementation) {
throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`) throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`)
} }
hoisted.set(node.name, rewriteReplacementDependencies(node, replacementMap)) hoisted.set(node.name, rewriteReplacementDependencies(node, replacementMap))
+10 -2
View File
@@ -1,6 +1,6 @@
export * as EventV2 from "./event" export * as EventV2 from "./event"
import { Cause, Context, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect" import { Cause, Context, DateTime, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect"
import { Event } from "@opencode-ai/schema/event" import { Event } from "@opencode-ai/schema/event"
import type { Data, Definition, Payload } from "@opencode-ai/schema/event" import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
import type { EventLog } from "@opencode-ai/schema/event-log" import type { EventLog } from "@opencode-ai/schema/event-log"
@@ -55,6 +55,7 @@ export const reserveSequence = Effect.fn("EventV2.reserveSequence")(function* (
export type SerializedEvent = { export type SerializedEvent = {
readonly id: ID readonly id: ID
readonly type: string readonly type: string
readonly created?: DateTime.Utc
readonly seq: number readonly seq: number
readonly aggregateID: string readonly aggregateID: string
readonly data: Record<string, unknown> readonly data: Record<string, unknown>
@@ -81,6 +82,7 @@ const decodeSerializedEvent = (event: SerializedEvent): Payload => {
} }
return { return {
id: event.id, id: event.id,
created: event.created ?? DateTime.makeUnsafe(0),
type: definition.type, type: definition.type,
durable: envelope(event.aggregateID, event.seq, definition.durable.version), durable: envelope(event.aggregateID, event.seq, definition.durable.version),
data: Schema.decodeUnknownSync(definition.data)(event.data), data: Schema.decodeUnknownSync(definition.data)(event.data),
@@ -92,8 +94,9 @@ export class SubscriberOverflowError extends Schema.TaggedErrorClass<SubscriberO
{ capacity: Schema.Int }, { capacity: Schema.Int },
) {} ) {}
export const define = Event.define
export const versionedType = Event.versionedType export const versionedType = Event.versionedType
export const durable = Event.durable
export const ephemeral = Event.ephemeral
export interface PublishOptions { export interface PublishOptions {
readonly id?: ID readonly id?: ID
@@ -294,6 +297,7 @@ export const layerWith = (options?: LayerOptions) =>
if ( if (
stored?.id === event.id && stored?.id === event.id &&
stored.type === versionedType(definition.type, durable.version) && stored.type === versionedType(definition.type, durable.version) &&
stored.created === DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(0)) &&
isDeepStrictEqual(stored.data, encoded) isDeepStrictEqual(stored.data, encoded)
) { ) {
if (input.ownerID && row?.ownerID == null) { if (input.ownerID && row?.ownerID == null) {
@@ -365,6 +369,7 @@ export const layerWith = (options?: LayerOptions) =>
id: event.id, id: event.id,
aggregate_id: aggregateID, aggregate_id: aggregateID,
seq, seq,
created: DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(0)),
type: versionedType(definition.type, durable.version), type: versionedType(definition.type, durable.version),
data: encoded, data: encoded,
}, },
@@ -470,6 +475,7 @@ export const layerWith = (options?: LayerOptions) =>
definition, definition,
{ {
id: options?.id ?? ID.create(), id: options?.id ?? ID.create(),
created: yield* DateTime.now,
...(options?.metadata ? { metadata: options.metadata } : {}), ...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type, type: definition.type,
...(location ? { location } : {}), ...(location ? { location } : {}),
@@ -493,6 +499,7 @@ export const layerWith = (options?: LayerOptions) =>
} else { } else {
const payload = { const payload = {
id: event.id, id: event.id,
created: event.created ?? DateTime.makeUnsafe(0),
type: definition.type, type: definition.type,
data: Schema.decodeUnknownSync(definition.data)(event.data), data: Schema.decodeUnknownSync(definition.data)(event.data),
} as Payload } as Payload
@@ -609,6 +616,7 @@ export const layerWith = (options?: LayerOptions) =>
return [ return [
decodeSerializedEvent({ decodeSerializedEvent({
id: event.id, id: event.id,
created: DateTime.makeUnsafe(event.created),
aggregateID: event.aggregate_id, aggregateID: event.aggregate_id,
seq: event.seq, seq: event.seq,
type: event.type, type: event.type,
+1
View File
@@ -15,6 +15,7 @@ export const EventTable = sqliteTable(
.notNull() .notNull()
.references(() => EventSequenceTable.aggregate_id, { onDelete: "cascade" }), .references(() => EventSequenceTable.aggregate_id, { onDelete: "cascade" }),
seq: integer().notNull(), seq: integer().notNull(),
created: integer().notNull(),
type: text().notNull(), type: text().notNull(),
data: text({ mode: "json" }).$type<Record<string, unknown>>().notNull(), data: text({ mode: "json" }).$type<Record<string, unknown>>().notNull(),
}, },
+1 -1
View File
@@ -45,7 +45,7 @@ const FILES = [
"**/.nyc_output/**", "**/.nyc_output/**",
] ]
export const PATTERNS = [...FILES, ...FOLDERS] export const PATTERNS = [...FILES, ...FOLDERS, `**/{${Array.from(FOLDERS).join(",")}}/**`]
export function match(filepath: string, opts?: { extra?: string[]; whitelist?: string[] }) { export function match(filepath: string, opts?: { extra?: string[]; whitelist?: string[] }) {
for (const pattern of opts?.whitelist || []) { for (const pattern of opts?.whitelist || []) {
@@ -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],
})
+118 -97
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,107 +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", { type Entry = {
directory: location.directory, readonly pubsub: PubSub.PubSub<Update>
platform: process.platform, readonly subscription: { readonly unsubscribe: () => Promise<void> }
refs: number
}
const entries = new Map<string, Entry>()
const locks = KeyedMutex.makeUnsafe<string>()
const acquire = Effect.fn("Watcher.acquire")(function* (input: WatchInput) {
const scope = yield* Scope.Scope
const target = path.resolve(input.path)
const directory = input.type === "file" ? path.dirname(target) : target
const ignore = [...new Set(input.type === "directory" ? (input.ignore ?? []) : [])].toSorted()
const id = JSON.stringify([input.type, target, ignore])
const pubsub = yield* locks.withLock(id)(
Effect.gen(function* () {
const existing = entries.get(id)
if (existing) {
existing.refs++
return existing.pubsub
}
const pubsub = yield* PubSub.unbounded<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)
}) })
return Service.of({}) 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()) }
const w = watcher() })
if (!w) return Service.of({}) : subscribeDirectory(native, backend, directory, ignore, pubsub)
if (subscription) {
yield* Effect.logInfo("watcher backend", { directory: location.directory, platform: process.platform, backend }) entries.set(id, { pubsub, subscription, refs: 1 })
const events = yield* EventV2.Service yield* Effect.logInfo("watcher started", {
const fs = yield* FSUtil.Service path: target,
const git = yield* Git.Service type: input.type,
const context = yield* Effect.context() backend: input.type === "file" ? "node" : backend,
const runFork = Effect.runForkWith(context) ignores: ignore.length,
const subscriptions: ParcelWatcher.AsyncSubscription[] = [] })
yield* Effect.addFinalizer(() => return pubsub
Effect.promise(() => Promise.allSettled(subscriptions.map((subscription) => subscription.unsubscribe()))), }
yield* PubSub.shutdown(pubsub)
return pubsub
}),
) )
const callback: ParcelWatcher.SubscribeCallback = (_error, updates) => { yield* Scope.addFinalizer(
if (_error) runFork(Effect.logError("watcher callback failed", { error: _error })) scope,
for (const update of updates) { locks.withLock(id)(
if (update.type === "create") runFork(events.publish(Event.Updated, { file: update.path, event: "add" })) Effect.gen(function* () {
if (update.type === "update") runFork(events.publish(Event.Updated, { file: update.path, event: "change" })) const entry = entries.get(id)
if (update.type === "delete") runFork(events.publish(Event.Updated, { file: update.path, event: "unlink" })) if (!entry) return
} entry.refs--
} if (entry.refs > 0) return
entries.delete(id)
yield* Effect.promise(() => entry.subscription.unsubscribe()).pipe(Effect.ignore)
yield* PubSub.shutdown(entry.pubsub)
yield* Effect.logInfo("watcher stopped", { path: target, type: input.type })
}),
),
)
return pubsub
})
const subscribe = (directory: string, ignore: string[]) => { const subscribe = (input: WatchInput) =>
const pending = w.subscribe(directory, callback, { ignore, backend }) Stream.unwrap(acquire(input).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub))))
return Service.of({ subscribe })
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
function subscribeDirectory(
native: typeof import("@parcel/watcher") | undefined,
backend: ParcelWatcher.BackendType | undefined,
directory: string,
ignore: string[],
pubsub: PubSub.PubSub<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( return Effect.promise(() => pending).pipe(
Effect.tap((subscription) =>
Effect.sync(() => subscriptions.push(subscription)).pipe(
Effect.andThen(Effect.logInfo("watcher subscribed", { directory, backend, ignores: ignore.length })),
),
),
Effect.timeout(SUBSCRIBE_TIMEOUT_MS), Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) => { Effect.catchCause((cause) => {
pending.then((subscription) => subscription.unsubscribe()).catch(() => {}) pending.then((subscription) => subscription.unsubscribe()).catch(() => {})
return Effect.logError("failed to subscribe", { directory, cause: Cause.pretty(cause) }) return Effect.logError("failed to subscribe", {
directory,
cause: Cause.pretty(cause),
}).pipe(Effect.as(undefined))
}), }),
) )
} }
const config = (yield* (yield* Config.Service).entries())
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
yield* Effect.forkScoped(
subscribe(location.directory, [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)]),
)
if (location.vcs?.type === "git") {
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
(entry) => (entry.name === "HEAD" ? [] : [entry.name]),
)
yield* Effect.forkScoped(subscribe(vcs, ignore))
}
}
return Service.of({})
}).pipe(
Effect.catchCause((cause) => {
return Effect.logError("failed to init watcher service", { cause: Cause.pretty(cause) }).pipe(
Effect.as(Service.of({})),
)
}),
),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [FSUtil.node, Location.node, Config.node, Git.node, EventV2.node],
})
+19 -8
View File
@@ -98,10 +98,14 @@ export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const events = yield* EventV2.Service const events = yield* EventV2.Service
const forms = yield* Cache.makeWith<ID, Entry>(() => Effect.die("Form cache must be used via set/getSuccess, never get"), { const forms = yield* Cache.makeWith<ID, Entry>(
() => Effect.die(new Error("Form cache must be used via set/getSuccess, never get")),
{
capacity: Number.MAX_SAFE_INTEGER, capacity: Number.MAX_SAFE_INTEGER,
timeToLive: (exit) => (Exit.isSuccess(exit) && exit.value.state.status === "pending" ? Duration.infinity : RETENTION), timeToLive: (exit) =>
}) Exit.isSuccess(exit) && exit.value.state.status === "pending" ? Duration.infinity : RETENTION,
},
)
const find = Effect.fn("Form.find")(function* (id: ID) { const find = Effect.fn("Form.find")(function* (id: ID) {
return yield* Cache.getSuccess(forms, id).pipe( return yield* Cache.getSuccess(forms, id).pipe(
@@ -131,7 +135,9 @@ export const layer = Layer.effect(
...(input.metadata === undefined ? {} : { metadata: input.metadata }), ...(input.metadata === undefined ? {} : { metadata: input.metadata }),
} }
const form: Info = const form: Info =
input.mode === "form" ? { ...base, mode: "form", fields: input.fields } : { ...base, mode: "url", url: input.url } input.mode === "form"
? { ...base, mode: "form", fields: input.fields }
: { ...base, mode: "url", url: input.url }
const entry: Entry = { const entry: Entry = {
form, form,
state: { status: "pending" }, state: { status: "pending" },
@@ -149,7 +155,9 @@ export const layer = Layer.effect(
Effect.gen(function* () { Effect.gen(function* () {
const form = yield* create(input) const form = yield* create(input)
const entry = yield* find(form.id).pipe(Effect.orDie) const entry = yield* find(form.id).pipe(Effect.orDie)
return yield* restore(Deferred.await(entry.deferred)).pipe(Effect.onInterrupt(() => Effect.ignore(cancel(form.id)))) return yield* restore(Deferred.await(entry.deferred)).pipe(
Effect.onInterrupt(() => Effect.ignore(cancel(form.id))),
)
}), }),
), ),
) )
@@ -301,7 +309,8 @@ function validateField(field: Form.Field, value: Form.Value): string | undefined
return `Form field has invalid pattern: ${field.key}` return `Form field has invalid pattern: ${field.key}`
} }
} }
if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return `Expected email for form field: ${field.key}` if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
return `Expected email for form field: ${field.key}`
if (field.format === "uri" && !isUri(value)) return `Expected URI for form field: ${field.key}` if (field.format === "uri" && !isUri(value)) return `Expected URI for form field: ${field.key}`
if (field.format === "date" && !isDate(value)) return `Expected date for form field: ${field.key}` if (field.format === "date" && !isDate(value)) return `Expected date for form field: ${field.key}`
if (field.format === "date-time" && !isDateTime(value)) return `Expected date-time for form field: ${field.key}` if (field.format === "date-time" && !isDateTime(value)) return `Expected date-time for form field: ${field.key}`
@@ -324,8 +333,10 @@ function validateField(field: Form.Field, value: Form.Value): string | undefined
if (field.type === "multiselect") { if (field.type === "multiselect") {
if (!isStringArray(value)) return `Expected string array for form field: ${field.key}` if (!isStringArray(value)) return `Expected string array for form field: ${field.key}`
if (field.required && value.length === 0) return `Missing required form field: ${field.key}` if (field.required && value.length === 0) return `Missing required form field: ${field.key}`
if (field.minItems !== undefined && value.length < field.minItems) return `Too few selections for form field: ${field.key}` if (field.minItems !== undefined && value.length < field.minItems)
if (field.maxItems !== undefined && value.length > field.maxItems) return `Too many selections for form field: ${field.key}` return `Too few selections for form field: ${field.key}`
if (field.maxItems !== undefined && value.length > field.maxItems)
return `Too many selections for form field: ${field.key}`
if (!field.custom && value.some((item) => !field.options.some((option) => option.value === item))) { if (!field.custom && value.some((item) => !field.options.some((option) => option.value === item))) {
return `Invalid option for form field: ${field.key}` return `Invalid option for form field: ${field.key}`
} }
+18 -6
View File
@@ -1,7 +1,7 @@
import { NodeFileSystem } from "@effect/platform-node" import { NodeFileSystem } from "@effect/platform-node"
import { dirname, isAbsolute, join, relative, resolve as pathResolve, sep } from "path" import path, { dirname, isAbsolute, join, relative, sep } from "path"
import { realpathSync } from "fs" import { realpathSync } from "fs"
import * as NFS from "fs/promises" import { readdir } from "fs/promises"
import { lookup } from "mime-types" import { lookup } from "mime-types"
import { Context, Effect, FileSystem, Layer, Schema } from "effect" import { Context, Effect, FileSystem, Layer, Schema } from "effect"
import type { PlatformError } from "effect/PlatformError" import type { PlatformError } from "effect/PlatformError"
@@ -38,6 +38,7 @@ export namespace FSUtil {
readonly ensureDir: (path: string) => Effect.Effect<void, Error> readonly ensureDir: (path: string) => Effect.Effect<void, Error>
readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error> readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error>
readonly readDirectoryEntries: (path: string) => Effect.Effect<DirEntry[], Error> readonly readDirectoryEntries: (path: string) => Effect.Effect<DirEntry[], Error>
readonly resolve: (path: string) => Effect.Effect<string>
readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error> readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error> readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error> readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
@@ -49,7 +50,9 @@ export namespace FSUtil {
export const use = serviceUse(Service) export const use = serviceUse(Service)
const layer = Layer.effect( // Exported so simulation can wrap this layer and override the methods that
// bypass the injected FileSystem (readDirectoryEntries, glob, globUp).
export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem const fs = yield* FileSystem.FileSystem
@@ -77,7 +80,7 @@ export namespace FSUtil {
const readDirectoryEntries = Effect.fn("FileSystem.readDirectoryEntries")(function* (dirPath: string) { const readDirectoryEntries = Effect.fn("FileSystem.readDirectoryEntries")(function* (dirPath: string) {
return yield* Effect.tryPromise({ return yield* Effect.tryPromise({
try: async () => { try: async () => {
const entries = await NFS.readdir(dirPath, { withFileTypes: true }) const entries = await readdir(dirPath, { withFileTypes: true })
return entries.map( return entries.map(
(e): DirEntry => ({ (e): DirEntry => ({
name: e.name, name: e.name,
@@ -89,6 +92,14 @@ export namespace FSUtil {
}) })
}) })
const resolve = Effect.fn("FileSystem.resolve")(function* (input: string) {
const resolved = path.resolve(windowsPath(input))
return yield* fs.realPath(resolved).pipe(
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(resolved)),
Effect.orDie,
)
})
const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) { const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
const text = yield* fs.readFileString(path) const text = yield* fs.readFileString(path)
return yield* Effect.try({ return yield* Effect.try({
@@ -187,6 +198,7 @@ export namespace FSUtil {
isDir, isDir,
isFile, isFile,
readDirectoryEntries, readDirectoryEntries,
resolve,
readJson, readJson,
writeJson, writeJson,
ensureDir, ensureDir,
@@ -209,7 +221,7 @@ export namespace FSUtil {
export function normalizePath(p: string): string { export function normalizePath(p: string): string {
if (process.platform !== "win32") return p if (process.platform !== "win32") return p
const resolved = pathResolve(windowsPath(p)) const resolved = path.resolve(windowsPath(p))
try { try {
return realpathSync.native(resolved) return realpathSync.native(resolved)
} catch { } catch {
@@ -227,7 +239,7 @@ export namespace FSUtil {
} }
export function resolve(p: string): string { export function resolve(p: string): string {
const resolved = pathResolve(windowsPath(p)) const resolved = path.resolve(windowsPath(p))
try { try {
return normalizePath(realpathSync(resolved)) return normalizePath(realpathSync(resolved))
} catch (e: any) { } catch (e: any) {
+6 -4
View File
@@ -1,4 +1,4 @@
import { create as createIdentifier } from "@opencode-ai/schema/identifier" import { create } from "@opencode-ai/schema/identifier"
const prefixes = { const prefixes = {
job: "job", job: "job",
@@ -23,7 +23,7 @@ export function descending(prefix: keyof typeof prefixes, given?: string) {
function generateID(prefix: keyof typeof prefixes, direction: "descending" | "ascending", given?: string): string { function generateID(prefix: keyof typeof prefixes, direction: "descending" | "ascending", given?: string): string {
if (!given) { if (!given) {
return create(prefixes[prefix], direction) return createID(prefixes[prefix], direction)
} }
if (!given.startsWith(prefixes[prefix])) { if (!given.startsWith(prefixes[prefix])) {
@@ -32,10 +32,12 @@ function generateID(prefix: keyof typeof prefixes, direction: "descending" | "as
return given return given
} }
export function create(prefix: string, direction: "descending" | "ascending", timestamp?: number): string { function createID(prefix: string, direction: "descending" | "ascending", timestamp?: number): string {
return prefix + "_" + createIdentifier(direction === "descending", timestamp) return prefix + "_" + create(direction === "descending", timestamp)
} }
export { createID as create }
/** Extract timestamp from an ascending ID. Does not work with descending IDs. */ /** Extract timestamp from an ascending ID. Does not work with descending IDs. */
export function timestamp(id: string): number { export function timestamp(id: string): number {
const prefix = id.split("_")[0] const prefix = id.split("_")[0]
+8 -6
View File
@@ -43,22 +43,24 @@ const layer = Layer.effect(
}) })
const observe = Effect.fn("InstructionContext.observe")(function* () { const observe = Effect.fn("InstructionContext.observe")(function* () {
const start = FSUtil.resolve(location.directory) const start = yield* fs.resolve(location.directory)
const stop = FSUtil.resolve(location.project.directory) const stop = yield* fs.resolve(location.project.directory)
const fromProject = relative(stop, start) const fromProject = relative(stop, start)
const insideProject = const insideProject =
fromProject === "" || (fromProject !== ".." && !fromProject.startsWith(`..${sep}`) && !isAbsolute(fromProject)) fromProject === "" || (fromProject !== ".." && !fromProject.startsWith(`..${sep}`) && !isAbsolute(fromProject))
const discovered = new Set( const discovered = new Set(
(Flag.OPENCODE_DISABLE_PROJECT_CONFIG || !insideProject yield* Effect.forEach(
Flag.OPENCODE_DISABLE_PROJECT_CONFIG || !insideProject
? [] ? []
: yield* fs.up({ : yield* fs.up({
targets: ["AGENTS.md"], targets: ["AGENTS.md"],
start, start,
stop, stop,
}) }),
).map(FSUtil.resolve), fs.resolve,
),
) )
const paths = Array.dedupe([FSUtil.resolve(join(global.config, "AGENTS.md")), ...discovered]) const paths = Array.dedupe([yield* fs.resolve(join(global.config, "AGENTS.md")), ...discovered])
const files = yield* Effect.forEach( const files = yield* Effect.forEach(
paths, paths,
(path) => (path) =>
+6 -5
View File
@@ -406,7 +406,7 @@ const layer = Layer.effect(
.get() .get()
.integrations.get(input.integrationID) .integrations.get(input.integrationID)
?.methods.some((method) => method.type === "key") ?.methods.some((method) => method.type === "key")
if (!method) return yield* Effect.die(`Key method not found: ${input.integrationID}`) if (!method) return yield* Effect.die(new Error(`Key method not found: ${input.integrationID}`))
yield* credentials.create({ yield* credentials.create({
integrationID: input.integrationID, integrationID: input.integrationID,
label: input.label, label: input.label,
@@ -418,7 +418,7 @@ const layer = Layer.effect(
oauth: Effect.fn("Integration.connection.oauth")(function* (input) { oauth: Effect.fn("Integration.connection.oauth")(function* (input) {
const method = state.get().integrations.get(input.integrationID)?.implementations.get(input.methodID) const method = state.get().integrations.get(input.integrationID)?.implementations.get(input.methodID)
if (!method) { if (!method) {
return yield* Effect.die(`OAuth method not found: ${input.integrationID}/${input.methodID}`) return yield* Effect.die(new Error(`OAuth method not found: ${input.integrationID}/${input.methodID}`))
} }
const attemptScope = yield* Scope.fork(scope) const attemptScope = yield* Scope.fork(scope)
const authorization = yield* authorize(method.authorize(input.inputs)).pipe( const authorization = yield* authorize(method.authorize(input.inputs)).pipe(
@@ -475,7 +475,7 @@ const layer = Layer.effect(
attempt: { attempt: {
status: Effect.fn("Integration.attempt.status")(function* (attemptID) { status: Effect.fn("Integration.attempt.status")(function* (attemptID) {
const attempt = (yield* SynchronizedRef.get(attempts)).get(attemptID) const attempt = (yield* SynchronizedRef.get(attempts)).get(attemptID)
if (!attempt) return yield* Effect.die(`OAuth attempt not found: ${attemptID}`) if (!attempt) return yield* Effect.die(new Error(`OAuth attempt not found: ${attemptID}`))
if (attempt.status === "failed") { if (attempt.status === "failed") {
return { status: attempt.status, message: attempt.message ?? "Authorization failed", time: attempt.time } return { status: attempt.status, message: attempt.message ?? "Authorization failed", time: attempt.time }
} }
@@ -488,12 +488,13 @@ const layer = Layer.effect(
if (match.authorization.mode === "code" && input.code === undefined) return [match, current] if (match.authorization.mode === "code" && input.code === undefined) return [match, current]
return [match, new Map(current).set(input.attemptID, { ...match, completing: true })] return [match, new Map(current).set(input.attemptID, { ...match, completing: true })]
}) })
if (!attempt) return yield* Effect.die(`OAuth attempt not found: ${input.attemptID}`) if (!attempt) return yield* Effect.die(new Error(`OAuth attempt not found: ${input.attemptID}`))
if (attempt.status !== "pending") return if (attempt.status !== "pending") return
if (attempt.authorization.mode === "code" && input.code === undefined) { if (attempt.authorization.mode === "code" && input.code === undefined) {
return yield* new CodeRequiredError({ attemptID: input.attemptID }) return yield* new CodeRequiredError({ attemptID: input.attemptID })
} }
if (attempt.completing) return yield* Effect.die(`OAuth attempt already completing: ${input.attemptID}`) if (attempt.completing)
return yield* Effect.die(new Error(`OAuth attempt already completing: ${input.attemptID}`))
const callback = const callback =
attempt.authorization.mode === "auto" attempt.authorization.mode === "auto"
? attempt.authorization.callback ? attempt.authorization.callback
+7 -9
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,15 +21,13 @@ 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"
import { Shell } from "./shell" import { Shell } from "./shell"
import { Reference } from "./reference" import { Reference } from "./reference"
import { ReferenceGuidance } from "./reference/guidance" import { ReferenceGuidance } from "./reference/guidance"
import * as SessionRunnerLLM from "./session/runner/llm" import { SessionRunnerLLM } from "./session/runner/llm"
import { SessionRunnerModel } from "./session/runner/model" import { SessionRunnerModel } from "./session/runner/model"
import { SessionCompaction } from "./session/compaction" import { SessionCompaction } from "./session/compaction"
import { SessionTitle } from "./session/title" import { SessionTitle } from "./session/title"
@@ -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>
+7 -4
View File
@@ -32,7 +32,6 @@ import { InstallationVersion } from "../installation/version"
const DEFAULT_STARTUP_TIMEOUT = 30_000 const DEFAULT_STARTUP_TIMEOUT = 30_000
const DEFAULT_REQUEST_TIMEOUT = 30_000 const DEFAULT_REQUEST_TIMEOUT = 30_000
const DEFAULT_TOOL_CALL_TIMEOUT = 100_000_000
type Transport = StdioClientTransport | StreamableHTTPClientTransport type Transport = StdioClientTransport | StreamableHTTPClientTransport
@@ -47,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,
@@ -282,8 +285,8 @@ export const connect = Effect.fnUntraced(function* (
client.callTool( client.callTool(
{ name: input.name, arguments: input.args ?? {} }, { name: input.name, arguments: input.args ?? {} },
CallToolResultSchema, CallToolResultSchema,
// Human-driven tools can exceed the SDK default; use an effectively infinite timeout. // Keep progress tokens available without imposing a client timeout on tool execution.
{ signal, timeout: DEFAULT_TOOL_CALL_TIMEOUT, resetTimeoutOnProgress: true, onprogress: () => {} }, { signal, resetTimeoutOnProgress: true, onprogress: () => {} },
), ),
catch: (error) => (error instanceof Error ? error : new Error(String(error))), catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}).pipe( }).pipe(
+6 -2
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>(),
}) })
} }
+15 -4
View File
@@ -47,7 +47,7 @@ const Cost = Schema.Struct({
const ReasoningOption = Schema.Union([ const ReasoningOption = Schema.Union([
Schema.Struct({ Schema.Struct({
type: Schema.Literal("effort"), type: Schema.Literal("effort"),
values: Schema.Array(Schema.String), values: Schema.Array(Schema.Union([Schema.String, Schema.Null])),
}), }),
Schema.Struct({ Schema.Struct({
type: Schema.Literal("toggle"), type: Schema.Literal("toggle"),
@@ -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([
@@ -125,6 +125,10 @@ export const Provider = Schema.Struct({
export type Provider = Schema.Schema.Type<typeof Provider> export type Provider = Schema.Schema.Type<typeof Provider>
const Providers = Schema.Record(Schema.String, Provider)
const decodeProviders = Schema.decodeUnknownEffect(Schema.fromJsonString(Providers))
const decodeProvidersUnknown = Schema.decodeUnknownEffect(Providers)
export const Event = ModelsDev.Event export const Event = ModelsDev.Event
declare const OPENCODE_MODELS_DEV: Record<string, Provider> | undefined declare const OPENCODE_MODELS_DEV: Record<string, Provider> | undefined
@@ -176,6 +180,7 @@ const layer = Layer.effect(
}) })
const loadFromDisk = fs.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).pipe( const loadFromDisk = fs.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).pipe(
Effect.flatMap(decodeProvidersUnknown),
Effect.catch((error) => { Effect.catch((error) => {
if ( if (
Flag.OPENCODE_MODELS_PATH === undefined && Flag.OPENCODE_MODELS_PATH === undefined &&
@@ -186,11 +191,17 @@ const layer = Layer.effect(
} }
return Effect.succeed(undefined) return Effect.succeed(undefined)
}), }),
Effect.map((v) => v as Record<string, Provider> | undefined),
) )
const loadSnapshot = Effect.sync(() => const loadSnapshot = Effect.sync(() =>
typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV, typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV,
).pipe(
Effect.flatMap((snapshot) =>
snapshot === undefined ? Effect.succeed(undefined) : decodeProvidersUnknown(snapshot),
),
Effect.catch((cause) =>
Effect.logWarning("bundled models snapshot failed schema decode", { cause }).pipe(Effect.as(undefined)),
),
) )
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () { const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
@@ -221,7 +232,7 @@ const layer = Layer.effect(
return yield* fetchAndWrite() return yield* fetchAndWrite()
}), }),
) )
return JSON.parse(text) as Record<string, Provider> return yield* decodeProviders(text)
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie) }).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)
const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity) const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)
+36 -36
View File
@@ -1,7 +1,7 @@
export * as PermissionV2 from "./permission" export * as PermissionV2 from "./permission"
import { makeLocationNode } from "./effect/app-node" import { makeLocationNode } from "./effect/app-node"
import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect" import { Context, Deferred, Effect, Layer, Schema } from "effect"
import { Permission } from "@opencode-ai/schema/permission" import { Permission } from "@opencode-ai/schema/permission"
import { EventV2 } from "./event" import { EventV2 } from "./event"
import { Location } from "./location" import { Location } from "./location"
@@ -11,7 +11,9 @@ import { SessionStore } from "./session/store"
import { Wildcard } from "./util/wildcard" import { Wildcard } from "./util/wildcard"
import { PermissionSaved } from "./permission/saved" import { PermissionSaved } from "./permission/saved"
export { Effect, Rule, Ruleset } from "@opencode-ai/schema/permission" const PermissionEffect = Permission.Effect
export { PermissionEffect as Effect }
export { Rule, Ruleset } from "@opencode-ai/schema/permission"
const missingAgentPermissions: Permission.Ruleset = [{ action: "*", resource: "*", effect: "deny" }] const missingAgentPermissions: Permission.Ruleset = [{ action: "*", resource: "*", effect: "deny" }]
export const ID = Permission.ID export const ID = Permission.ID
@@ -90,12 +92,12 @@ export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
} }
export interface Interface { export interface Interface {
readonly ask: (input: AssertInput) => EffectRuntime.Effect<AskResult, SessionV2.NotFoundError> readonly ask: (input: AssertInput) => Effect.Effect<AskResult, SessionV2.NotFoundError>
readonly assert: (input: AssertInput) => EffectRuntime.Effect<void, Error | SessionV2.NotFoundError> readonly assert: (input: AssertInput) => Effect.Effect<void, Error | SessionV2.NotFoundError>
readonly reply: (input: ReplyInput) => EffectRuntime.Effect<void, NotFoundError> readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
readonly get: (id: ID) => EffectRuntime.Effect<Request | undefined> readonly get: (id: ID) => Effect.Effect<Request | undefined>
readonly forSession: (sessionID: SessionV2.ID) => EffectRuntime.Effect<ReadonlyArray<Request>> readonly forSession: (sessionID: SessionV2.ID) => Effect.Effect<ReadonlyArray<Request>>
readonly list: () => EffectRuntime.Effect<ReadonlyArray<Request>> readonly list: () => Effect.Effect<ReadonlyArray<Request>>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Permission") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Permission") {}
@@ -108,7 +110,7 @@ interface Pending {
const layer = Layer.effect( const layer = Layer.effect(
Service, Service,
EffectRuntime.gen(function* () { Effect.gen(function* () {
const events = yield* EventV2.Service const events = yield* EventV2.Service
const location = yield* Location.Service const location = yield* Location.Service
const agents = yield* AgentV2.Service const agents = yield* AgentV2.Service
@@ -116,28 +118,25 @@ const layer = Layer.effect(
const saved = yield* PermissionSaved.Service const saved = yield* PermissionSaved.Service
const pending = new Map<ID, Pending>() const pending = new Map<ID, Pending>()
yield* EffectRuntime.addFinalizer(() => yield* Effect.addFinalizer(() =>
EffectRuntime.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new RejectedError()), { Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new RejectedError()), {
discard: true, discard: true,
}).pipe( }).pipe(
EffectRuntime.ensuring( Effect.ensuring(
EffectRuntime.sync(() => { Effect.sync(() => {
pending.clear() pending.clear()
}), }),
), ),
), ),
) )
const savedRules = EffectRuntime.fnUntraced(function* () { const savedRules = Effect.fnUntraced(function* () {
return (yield* saved.list({ projectID: location.project.id })).map( return (yield* saved.list({ projectID: location.project.id })).map(
(item): Permission.Rule => ({ action: item.action, resource: item.resource, effect: "allow" }), (item): Permission.Rule => ({ action: item.action, resource: item.resource, effect: "allow" }),
) )
}) })
const configured = EffectRuntime.fn("PermissionV2.configured")(function* ( const configured = Effect.fn("PermissionV2.configured")(function* (sessionID: SessionV2.ID, agentID?: AgentV2.ID) {
sessionID: SessionV2.ID,
agentID?: AgentV2.ID,
) {
const session = yield* sessions.get(sessionID) const session = yield* sessions.get(sessionID)
if (!session) return yield* new SessionV2.NotFoundError({ sessionID }) if (!session) return yield* new SessionV2.NotFoundError({ sessionID })
const agent = yield* agents.resolve(agentID ?? session.agent) const agent = yield* agents.resolve(agentID ?? session.agent)
@@ -152,7 +151,7 @@ const layer = Layer.effect(
return rules.filter((rule) => Wildcard.match(input.action, rule.action)) return rules.filter((rule) => Wildcard.match(input.action, rule.action))
} }
const evaluateInput = EffectRuntime.fnUntraced(function* (input: AssertInput) { const evaluateInput = Effect.fnUntraced(function* (input: AssertInput) {
const rules = yield* configured(input.sessionID, input.agent) const rules = yield* configured(input.sessionID, input.agent)
if (denied(input, rules)) return { effect: "deny" as const, rules } if (denied(input, rules)) return { effect: "deny" as const, rules }
const all = [...rules, ...(yield* savedRules())] const all = [...rules, ...(yield* savedRules())]
@@ -174,29 +173,30 @@ const layer = Layer.effect(
} }
const create = (request: Request, agent?: AgentV2.ID) => const create = (request: Request, agent?: AgentV2.ID) =>
EffectRuntime.uninterruptible( Effect.uninterruptible(
EffectRuntime.gen(function* () { Effect.gen(function* () {
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>() const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
const item = { request, agent, deferred } const item = { request, agent, deferred }
if (pending.has(request.id)) return yield* EffectRuntime.die(`Duplicate pending permission ID: ${request.id}`) if (pending.has(request.id))
return yield* Effect.die(new Error(`Duplicate pending permission ID: ${request.id}`))
pending.set(request.id, item) pending.set(request.id, item)
yield* events yield* events
.publish(Event.Asked, request) .publish(Event.Asked, request)
.pipe(EffectRuntime.onError(() => EffectRuntime.sync(() => pending.delete(request.id)))) .pipe(Effect.onError(() => Effect.sync(() => pending.delete(request.id))))
return item return item
}), }),
) )
const ask = EffectRuntime.fn("PermissionV2.ask")(function* (input: AssertInput) { const ask = Effect.fn("PermissionV2.ask")(function* (input: AssertInput) {
const result = yield* evaluateInput(input) const result = yield* evaluateInput(input)
const value = request(input) const value = request(input)
if (result.effect === "ask") yield* create(value, input.agent) if (result.effect === "ask") yield* create(value, input.agent)
return { id: value.id, effect: result.effect } return { id: value.id, effect: result.effect }
}) })
const assert = EffectRuntime.fn("PermissionV2.assert")((input: AssertInput) => const assert = Effect.fn("PermissionV2.assert")((input: AssertInput) =>
EffectRuntime.uninterruptibleMask((restore) => Effect.uninterruptibleMask((restore) =>
EffectRuntime.gen(function* () { Effect.gen(function* () {
const result = yield* evaluateInput(input) const result = yield* evaluateInput(input)
if (result.effect === "deny") { if (result.effect === "deny") {
return yield* new DeniedError({ return yield* new DeniedError({
@@ -206,8 +206,8 @@ const layer = Layer.effect(
if (result.effect === "allow") return if (result.effect === "allow") return
const item = yield* create(request(input), input.agent) const item = yield* create(request(input), input.agent)
return yield* restore(Deferred.await(item.deferred)).pipe( return yield* restore(Deferred.await(item.deferred)).pipe(
EffectRuntime.ensuring( Effect.ensuring(
EffectRuntime.sync(() => { Effect.sync(() => {
pending.delete(item.request.id) pending.delete(item.request.id)
}), }),
), ),
@@ -216,9 +216,9 @@ const layer = Layer.effect(
), ),
) )
const reply = EffectRuntime.fn("PermissionV2.reply")((input: ReplyInput) => const reply = Effect.fn("PermissionV2.reply")((input: ReplyInput) =>
EffectRuntime.uninterruptible( Effect.uninterruptible(
EffectRuntime.gen(function* () { Effect.gen(function* () {
const existing = pending.get(input.requestID) const existing = pending.get(input.requestID)
if (!existing) return yield* new NotFoundError({ requestID: input.requestID }) if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
yield* events.publish(Event.Replied, { yield* events.publish(Event.Replied, {
@@ -261,7 +261,7 @@ const layer = Layer.effect(
for (const [id, item] of pending) { for (const [id, item] of pending) {
const input = { ...item.request } const input = { ...item.request }
const rules = yield* configured(item.request.sessionID, item.agent).pipe( const rules = yield* configured(item.request.sessionID, item.agent).pipe(
EffectRuntime.catchTag("Session.NotFoundError", () => EffectRuntime.succeed(undefined)), Effect.catchTag("Session.NotFoundError", () => Effect.succeed(undefined)),
) )
if (!rules) continue if (!rules) continue
if (denied(input, rules)) continue if (denied(input, rules)) continue
@@ -284,15 +284,15 @@ const layer = Layer.effect(
), ),
) )
const list = EffectRuntime.fn("PermissionV2.list")(function* () { const list = Effect.fn("PermissionV2.list")(function* () {
return Array.from(pending.values(), (item) => item.request) return Array.from(pending.values(), (item) => item.request)
}) })
const get = EffectRuntime.fn("PermissionV2.get")(function* (id: ID) { const get = Effect.fn("PermissionV2.get")(function* (id: ID) {
return pending.get(id)?.request return pending.get(id)?.request
}) })
const forSession = EffectRuntime.fn("PermissionV2.forSession")(function* (sessionID: SessionV2.ID) { const forSession = Effect.fn("PermissionV2.forSession")(function* (sessionID: SessionV2.ID) {
return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID) return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID)
}) })
+2 -2
View File
@@ -48,7 +48,7 @@ const layer = Layer.effect(
let host: Parameters<PluginDefinition["effect"]>[0] let host: Parameters<PluginDefinition["effect"]>[0]
const add = Effect.fn("Plugin.add")(function* (id: ID, effect: PluginDefinition["effect"]) { const add = Effect.fn("Plugin.add")(function* (id: ID, effect: PluginDefinition["effect"]) {
if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${id}`) if (loading.has(id)) return yield* Effect.die(new Error(`Plugin load cycle detected for ${id}`))
yield* locks.withLock(id)( yield* locks.withLock(id)(
Effect.sync(() => { Effect.sync(() => {
@@ -90,7 +90,7 @@ const layer = Layer.effect(
}) })
const remove = Effect.fn("Plugin.remove")(function* (id: ID) { const remove = Effect.fn("Plugin.remove")(function* (id: ID) {
if (loading.has(id)) return yield* Effect.die(`Cannot remove plugin ${id} while it is loading`) if (loading.has(id)) return yield* Effect.die(new Error(`Cannot remove plugin ${id} while it is loading`))
yield* locks.withLock(id)( yield* locks.withLock(id)(
State.batch( State.batch(
+70 -16
View File
@@ -1,12 +1,14 @@
export * as PluginHost from "./host" export * as PluginHost from "./host"
import type { PluginContext as Interface } from "@opencode-ai/plugin/v2/effect" import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { Effect, Schema } from "effect" import { 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
@@ -40,7 +44,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
workspaceID: location.workspaceID, workspaceID: location.workspaceID,
project: location.project, project: location.project,
}) })
const locationRef = (input?: Parameters<Interface["agent"]["list"]>[0]) => const locationRef = (input?: Parameters<PluginContext["agent"]["list"]>[0]) =>
input?.location === undefined input?.location === undefined
? undefined ? undefined
: Location.Ref.make({ : Location.Ref.make({
@@ -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,36 +270,39 @@ 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, options) => tools.register(input, options),
execute: { execute: {
before: (callback) => before: (callback) =>
toolHooks.hook.before((event) => { toolHooks.hook.before((event) => {
@@ -305,5 +359,5 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
command: runtime.session.command, command: runtime.session.command,
interrupt: (input) => runtime.session.interrupt(input.sessionID), interrupt: (input) => runtime.session.interrupt(input.sessionID),
}, },
} satisfies Interface } satisfies PluginContext
}) })
+14 -13
View File
@@ -201,10 +201,9 @@ 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))
@@ -217,12 +216,9 @@ export const ModelsDevPlugin = define({
method: { type: "env", names: [...item.env] }, method: { type: "env", names: [...item.env] },
}) })
} }
}), })
) yield* ctx.catalog.transform((catalog) => {
yield* ctx.catalog.transform( for (const item of Object.values(loaded.data)) {
Effect.fn(function* (catalog) {
const data = yield* modelsDev.get()
for (const item of Object.values(data)) {
const providerID = ProviderV2.ID.make(item.id) const providerID = ProviderV2.ID.make(item.id)
catalog.provider.update(providerID, (provider) => { catalog.provider.update(providerID, (provider) => {
provider.name = item.name provider.name = item.name
@@ -255,10 +251,15 @@ export const ModelsDevPlugin = define({
} }
} }
} }
}), })
)
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 }),
) )
}), }),
+41 -10
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)))
@@ -62,8 +62,7 @@ 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
@@ -76,8 +75,7 @@ export const AmazonBedrockPlugin = define({
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
@@ -4,8 +4,7 @@ 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
@@ -14,8 +13,7 @@ export const AnthropicPlugin = define({
"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

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