Compare commits

...

89 Commits

Author SHA1 Message Date
Brendan Allan 05778c111c Merge branch 'mobile-home-layout' into mobile-session-layout 2026-06-23 14:39:36 +08:00
Brendan Allan 43000a15d3 Merge remote-tracking branch 'origin/dev' into mobile-home-layout 2026-06-23 14:39:24 +08:00
opencode-agent[bot] 09757c605a chore: generate 2026-06-23 06:20:50 +00:00
Brendan Allan 0c4f508c50 feat(app): add server-keyed session routes (#32570) 2026-06-23 06:19:18 +00:00
opencode-agent[bot] 40db33c415 chore: generate 2026-06-23 04:57:55 +00:00
Dax Raad 9b01f15f1d test(opencode): retry Windows CLI cleanup 2026-06-23 00:56:14 -04:00
Dax Raad 81851ca6b9 test(opencode): stabilize Windows async readiness 2026-06-23 00:41:02 -04:00
Dax Raad 3c5632e110 test(opencode): stabilize Windows CLI subprocesses 2026-06-23 00:26:48 -04:00
Dax Raad 6ea3e6698a fix(opencode): scope reference readiness wait 2026-06-23 00:10:36 -04:00
Dax Raad ed75ce9ecc fix(core): prioritize reference plugin registration 2026-06-22 23:39:26 -04:00
Dax Raad af14fefc96 test(opencode): relax compaction readiness timeout 2026-06-22 23:01:05 -04:00
Dax Raad 4a710e4679 fix(core): await plugin readiness 2026-06-22 22:49:57 -04:00
Dax Raad d2c866bf70 test(core): speed up test setup 2026-06-22 22:49:57 -04:00
opencode-agent[bot] 237595e242 chore: generate 2026-06-23 02:19:31 +00:00
Aiden Cline d29f5eba92 refactor(core): remove shell description input (#32823) 2026-06-22 21:18:06 -05:00
Aiden Cline fbf889db83 fix(tui): preserve worker rejection handling (#33448)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-06-22 19:57:21 -05:00
Dax ef2357915e fix(tui): scope file autocomplete to session (#33458) 2026-06-22 20:31:36 -04:00
Dax Raad 975b1132f1 refactor(plugin): use direct runtime registry 2026-06-22 20:10:29 -04:00
Dax Raad cd97de7391 refactor(plugin): consolidate internal registration 2026-06-22 19:49:00 -04:00
Dax Raad 23fd5907be fix(opencode): normalize CLI test line endings 2026-06-22 19:15:50 -04:00
opencode-agent[bot] 49d3f86802 chore: generate 2026-06-22 23:08:26 +00:00
Dax 909a1a6d78 feat(plugin): add namespaced hook API (#33416) 2026-06-22 19:06:57 -04:00
Kit Langton dc468bdcfd fix(core): reset steps for promoted prompts (#33452) 2026-06-22 19:01:14 -04:00
Kit Langton f48f24ec4e refactor(core): simplify session input promotion (#33443) 2026-06-22 17:51:49 -04:00
Aiden Cline 34b3d59a23 ignore: update agents.md (#33446) 2026-06-22 15:52:43 -05:00
Frank a0a500316e zen: new inference 2026-06-22 15:40:14 -04:00
Kit Langton 1787fa4261 refactor(core): drop legacy compaction event (#33404) 2026-06-22 21:39:41 +02:00
Kit Langton 130957288e fix(llm): preserve structured tool errors (#33405) 2026-06-22 13:43:07 -04:00
Aiden Cline c5a4a8288c fix: dont show gpt-5.5-pro when using codex oauth (#33400)
Co-authored-by: Devin Oldenburg <devin@logicplanes.com>
2026-06-22 11:47:02 -05:00
Adam 39d7394ede fix(stats): hide unique users tooltip total 2026-06-22 11:40:00 -05:00
opencode-agent[bot] 494123a875 chore: generate 2026-06-22 16:18:36 +00:00
Kit Langton fe840d42b8 refactor(core): simplify session run coordination (#33388) 2026-06-22 12:16:30 -04:00
Kit Langton f50e4accf3 test(opencode): synchronize shell cancellation (#33386) 2026-06-22 11:55:04 -04:00
Kit Langton 9bceb8eb7d test(opencode): synchronize websocket retry failures (#33387) 2026-06-22 11:54:49 -04:00
Kit Langton adebb87191 test(opencode): use EventV2 location contract (#33383) 2026-06-22 11:37:43 -04:00
opencode-agent[bot] 4d5efba5c9 chore: generate 2026-06-22 15:36:00 +00:00
Kit Langton c6ee511485 refactor(core): simplify session context epochs (#33378) 2026-06-22 11:34:03 -04:00
Kit Langton f9f2280452 fix(core): defer session model validation (#33377) 2026-06-22 11:07:27 -04:00
Tommy D. Rossi d5980b47e9 feat(llm): add video and audio media support to Gemini protocol (#31889)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-06-22 10:03:19 -05:00
Kit Langton 41d1279b6f fix(opencode): preserve request logger context (#33381) 2026-06-22 10:58:22 -04:00
Simon Klee 0d32d1f293 cli: add --mini (#33353) 2026-06-22 16:27:56 +02:00
Dax Raad cf31029350 fix(core): batch plugin shutdown 2026-06-22 09:04:29 -04:00
Dax Raad 79b55d4db8 fix(tui): use bridged event stream for data 2026-06-22 08:50:46 -04:00
Adam 36264ccf90 fix(stats): format market share tokens 2026-06-22 07:46:11 -05:00
Dax Raad 7b750a8f20 fix(ci): preserve test log groups 2026-06-22 08:35:40 -04:00
Dax Raad e50261e524 fix(core): format generated migrations 2026-06-22 08:23:25 -04:00
opencode-agent[bot] 639c94a375 chore: generate 2026-06-22 12:22:43 +00:00
Dax Raad b13a2d712a fix(server): isolate event schema types 2026-06-22 08:20:39 -04:00
Dax Raad 1b91b5df34 fix(core): constrain event schema services 2026-06-22 08:14:49 -04:00
Dax Raad 595cc91ddc feat(server): stream events across locations 2026-06-22 08:12:43 -04:00
Dax 57ce1b9ca8 fix(cli): increment ports from default (#33282) 2026-06-22 08:05:49 -04:00
opencode-agent[bot] 4ecc3ac653 chore: generate 2026-06-22 11:22:22 +00:00
Simon Klee c7efbe6fc0 run: inline files for attached servers (#33317) 2026-06-22 13:20:43 +02:00
opencode-agent[bot] cd292a4ecb chore: generate 2026-06-22 04:17:10 +00:00
Dax 2bb4311042 refactor(core): simplify integration test fixtures (#33292) 2026-06-22 04:15:34 +00:00
opencode-agent[bot] cdc6d01c5a chore: generate 2026-06-22 04:00:59 +00:00
Dax Raad 35b3fc85d0 feat(core): expose session switching endpoints 2026-06-21 23:59:22 -04:00
Aiden Cline 9dadc2455f fix(tui): render skill load errors inline (#33298) 2026-06-21 22:40:12 -05:00
opencode-agent[bot] 06dae383f7 chore: generate 2026-06-21 23:42:07 +00:00
Adam 7d204b5b57 feat(stats): show model unique users 2026-06-21 18:40:22 -05:00
Kit Langton 49593c1ec4 fix(core): settle interrupted assistant steps (#33266) 2026-06-21 20:22:09 +00:00
Kit Langton ff837fe949 fix(core): handle read file failures (#33260) 2026-06-21 20:12:42 +00:00
Adam 4c6750d464 fix(stats): unblock stats sync 2026-06-21 14:53:46 -05:00
Kit Langton 69f1ec22e3 fix(core): bound web tool failures (#33259) 2026-06-21 15:37:25 -04:00
Kit Langton 823d327401 fix(core): handle missing read paths (#33255) 2026-06-21 13:50:14 -04:00
opencode-agent[bot] 82d9cab48d chore: generate 2026-06-21 14:36:19 +00:00
Dax fb43c15f88 refactor(core): simplify event model (#33238) 2026-06-21 10:34:57 -04:00
opencode-agent[bot] ca006a2d20 chore: update nix node_modules hashes 2026-06-21 12:21:25 +00:00
Dax Raad 8396395f17 fix(stats): update defect schemas 2026-06-21 08:08:31 -04:00
Dax Raad 1b8bab3e35 fix(ci): avoid Playwright Chromium install hang 2026-06-21 08:08:31 -04:00
opencode-agent[bot] 02687b6324 chore: generate 2026-06-21 12:07:16 +00:00
Dax c780d7cee7 feat(plugin): add v2 effect host (#33111) 2026-06-21 08:05:49 -04:00
opencode-agent[bot] 7a9337da8a chore: generate 2026-06-21 11:49:50 +00:00
Adam f96e6aa6ed fix(stats): bypass worker runtime crash 2026-06-21 06:48:25 -05:00
Adam c6f719e153 fix(stats): restore worker data exports 2026-06-21 06:39:49 -05:00
Adam 1a111be494 fix(stats): run worker effects directly 2026-06-21 06:30:15 -05:00
Adam a0aee82be9 fix(stats): inline worker runtime import 2026-06-21 06:22:26 -05:00
Adam c0dc6e50a7 fix(stats): restore worker runtime 2026-06-21 06:16:02 -05:00
Adam 418a9e4e66 fix(stats): restore model page rendering 2026-06-21 05:58:39 -05:00
Adam bd8ce5e6a9 chore(stats): remove deploy migrations 2026-06-21 05:41:10 -05:00
Adam a97c6de9db fix(stats): support planetscale cli variants 2026-06-21 05:17:22 -05:00
opencode-agent[bot] 24ea4dd008 chore: generate 2026-06-21 10:03:16 +00:00
Adam ffcb7542e1 fix(stats): run production migration safely 2026-06-21 05:01:43 -05:00
opencode-agent[bot] d4d841bafd chore: generate 2026-06-21 09:40:15 +00:00
Adam 6f0e934573 fix(stats): make unique users migration idempotent 2026-06-21 04:38:47 -05:00
Adam 233d065dd5 feat(stats): show model users metric 2026-06-21 04:30:25 -05:00
Dax f12ac6f234 fix(tui): reduce noisy MCP autocomplete matches (#33176) 2026-06-20 22:24:59 -04:00
Brendan Allan 1a4f428ef1 fix(app): refine mobile session layout 2026-06-18 09:35:48 +02:00
Brendan Allan 45923c0293 fix(app): improve mobile home layout 2026-06-18 02:22:59 +02:00
362 changed files with 18716 additions and 9581 deletions
+3 -2
View File
@@ -65,7 +65,7 @@ jobs:
- name: Run unit tests - name: Run unit tests
timeout-minutes: 20 timeout-minutes: 20
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=task run: GITHUB_ACTIONS=false bun turbo test
env: env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
@@ -99,7 +99,8 @@ jobs:
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version: "24" # Playwright 1.59 hangs while extracting Chromium with Node 24.16.
node-version: "24.15"
- name: Setup Bun - name: Setup Bun
uses: ./.github/actions/setup-bun uses: ./.github/actions/setup-bun
+3 -3
View File
@@ -137,7 +137,7 @@ const table = sqliteTable("session", {
## Testing ## Testing
- Avoid mocks as much as possible - Avoid mocks as much as possible, you shouldn't be using globalThis.\* at all unless it's the only option.
- Test actual implementation, do not duplicate logic into tests - Test actual implementation, do not duplicate logic into tests
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`. - Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
@@ -152,7 +152,7 @@ const table = sqliteTable("session", {
- 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 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.
- 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 activity recovery requires a separate explicit design before it may retry provider work. - 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 coalesce into the active activity at the next safe provider-turn boundary. Explicit `queue` inputs open FIFO future activities one at a time after the active activity settles. - 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 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, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned. - Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned.
+23 -9
View File
@@ -24,7 +24,7 @@ A durable chronological instruction that tells the model the newly effective sta
_Avoid_: System update, system notification, raw text diff _Avoid_: System update, system notification, raw text diff
**Context Epoch**: **Context Epoch**:
The span during which one effective agent's initially rendered **System Context** remains immutable, ending at compaction or another baseline-replacing transition. The span during which one initially rendered **System Context** remains the immutable provider-cache baseline, ending at completed compaction, Session movement, or an incompatible context transition that requires a fresh baseline.
**Baseline System Context**: **Baseline System Context**:
The full **System Context** rendered at the start of a **Context Epoch**. The full **System Context** rendered at the start of a **Context Epoch**.
@@ -39,6 +39,18 @@ An expected temporary inability to observe a **Context Source** value; the runti
**Safe Provider-Turn Boundary**: **Safe Provider-Turn Boundary**:
The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically. The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically.
**Admitted Prompt**:
A durable user input accepted into the Session inbox but not yet included in **Session History**.
**Prompt Promotion**:
The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**.
**Provider Turn**:
One request to a model provider and the response projected from that request.
**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.
**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.
@@ -67,6 +79,11 @@ The host-supplied environment overlay applied by the server when creating a PTY,
- 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 Provider-Turn Boundary**, never pushed asynchronously when their source changes.
- At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**. - At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**.
- An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**.
- **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message.
- Steering prompts promote at the next **Safe Provider-Turn Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's provider-turn allowance; multiple prompts promoted at one boundary reset it once.
- A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another.
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, provider attempts, and tool state rather than inventing an enclosing execution identity.
- The first provider turn renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the turn instead of persisting an incomplete baseline. - 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.
- 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.
@@ -75,31 +92,28 @@ The host-supplied environment overlay applied by the server when creating a PTY,
- 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**.
- `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked. - `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked.
- `SystemContext.replace(...)` represents an explicit baseline-replacing transition such as compaction or model/provider switch; it either produces a fresh generation or reports that replacement is blocked by unavailable admitted context. - `SystemContext.replace(...)` renders a fresh generation after completed compaction or another baseline-replacing transition; it reports replacement blocked while previously admitted context is unavailable.
- Context Epoch preparation retries until stable after optimistic revision mismatches so concurrent replacement requests cannot terminate an otherwise valid safe-boundary run.
- **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 Provider-Turn 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.
- Context Epoch initialization is fenced against the authoritative Session Location, so an old-Location runner cannot recreate source context after a concurrent move.
- 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 Provider-Turn 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.
- Switching the selected agent requests **Context Epoch** replacement. A switch admitted after the current **Safe Provider-Turn Boundary** applies to the next provider turn while leaving the already-prepared baseline durable. Epoch creation is fenced against the authoritative effective agent, and retries re-observe the current agent. - 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.
- A cross-agent replacement must complete before another provider turn; unavailable admitted context blocks that replacement instead of exposing the previous agent's privileged 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 provider turn that issued the call; a later agent switch cannot change that call's policy.
- Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily. - Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily.
- Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry. - Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider 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 **Context Epoch** durably records the effective agent that owns its **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.
- Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache. - Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history.
- A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history. - A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn.
- **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`.
+2
View File
@@ -276,6 +276,7 @@
"@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*", "@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/llm": "workspace:*", "@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@openrouter/ai-sdk-provider": "2.9.0", "@openrouter/ai-sdk-provider": "2.9.0",
"@opentelemetry/api": "1.9.0", "@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/context-async-hooks": "2.6.1",
@@ -625,6 +626,7 @@
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.17.9", "version": "1.17.9",
"dependencies": { "dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"effect": "catalog:", "effect": "catalog:",
"zod": "catalog:", "zod": "catalog:",
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-g0tDvRf7MErZ1PEeUazEYi492ZHiRT8kYv3bPdkss/I=", "x86_64-linux": "sha256-oWSGu+SP66Aquy/0Vaq7Bgp8404ZdOWbQX+O7h3jxHU=",
"aarch64-linux": "sha256-6sKgf3ftbIqlPxlFkoPzoWPsJp3IwXD+H3Y6g874xmk=", "aarch64-linux": "sha256-UsS0+c+GwtIukmWwQeFbY/3Oaz3t4Q7C6cFMGkmlyAY=",
"aarch64-darwin": "sha256-Se/Nls/KlkuK2ysDQ9DeAzSaX3NsL2iDdf/dsv2GIXc=", "aarch64-darwin": "sha256-CArz92ewPmXO+ORFCBkCH8LzMpU/DjyaO4ic7QL0UpI=",
"x86_64-darwin": "sha256-V9MCkqnvQ1nkD2PaaTfNFKkBZGymj6KxrSAK6+DTF8Y=" "x86_64-darwin": "sha256-rhnz9gmG6L06wIzfMhTaXDDEf6IbMD32CavqwXoqcUs="
} }
} }
@@ -21,6 +21,7 @@ const words = [
"vector", "vector",
] ]
const serverKey = "http://127.0.0.1:4096"
const sourceID = "ses_smoke_source" const sourceID = "ses_smoke_source"
const targetID = "ses_smoke_target" const targetID = "ses_smoke_target"
const directory = "C:/OpenCode/SmokeProject" const directory = "C:/OpenCode/SmokeProject"
@@ -139,7 +140,7 @@ function toolPart(
status: "completed", status: "completed",
input, input,
output: lorem(index * 23 + partIndex, outputLength), output: lorem(index * 23 + partIndex, outputLength),
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed", title: tool === "bash" ? input.command : input.filePath || input.path || input.pattern || "completed",
metadata, metadata,
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 }, time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
}, },
@@ -200,9 +201,7 @@ function turn(index: number): Message[] {
...(index % 8 === 0 ...(index % 8 === 0
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] ? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
: []), : []),
...(index % 7 === 0 ...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
: []),
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []), ...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []), ...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
...(index % 13 === 0 ...(index % 13 === 0
@@ -242,6 +241,7 @@ function orderedParts(message: Message) {
export const fixture = { export const fixture = {
directory, directory,
serverKey,
project: { project: {
id: projectID, id: projectID,
worktree: directory, worktree: directory,
@@ -295,6 +295,7 @@ export const fixture = {
.filter(renderable) .filter(renderable)
.map((part) => part.id), .map((part) => part.id),
), ),
expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!.id,
}, },
} }
@@ -327,6 +327,18 @@ test.describe("smoke: session timeline", () => {
const expectedMessageIDs = fixture.expected.targetMessageIDs const expectedMessageIDs = fixture.expected.targetMessageIDs
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors) await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors) await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors)
const shell = page.locator(`[data-timeline-part-id="${fixture.expected.expandedShellPartID}"]`)
const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]')
const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]')
await expect(shellSubtitle).toHaveCount(0)
await expect(shell.locator('[data-slot="bash-pre"]')).toContainText("$ bun typecheck")
await shellTrigger.click()
await expect(shellTrigger).toHaveAttribute("aria-expanded", "false")
await expect(shellSubtitle).toHaveText("bun typecheck")
await shellTrigger.click()
await expect(shellTrigger).toHaveAttribute("aria-expanded", "true")
await expect(shellSubtitle).toHaveCount(0)
}) })
}) })
@@ -706,7 +718,8 @@ async function navigateToSession(page: Page, directory: string, sessionId: strin
} }
async function switchTitlebarSession(page: Page, sessionID: string, title: string) { async function switchTitlebarSession(page: Page, sessionID: string, title: string) {
const href = `/${base64Encode(fixture.directory)}/session/${sessionID}` console.log(process.env)
const href = `/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first() const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
await expect(tab).toBeVisible() await expect(tab).toBeVisible()
await tab.click() await tab.click()
+182 -47
View File
@@ -10,7 +10,7 @@ import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router" import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { keepPreviousData, QueryClient, QueryClientProvider, useQuery } from "@tanstack/solid-query"
import { Effect } from "effect" import { Effect } from "effect"
import { import {
type Component, type Component,
@@ -30,7 +30,7 @@ import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/context/command" import { CommandProvider } from "@/context/command"
import { CommentsProvider } from "@/context/comments" import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file" import { FileProvider } from "@/context/file"
import { ServerSDKProvider } from "@/context/server-sdk" import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync" import { ServerSyncProvider } from "@/context/server-sync"
import { GlobalProvider } from "@/context/global" import { GlobalProvider } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights" import { HighlightsProvider } from "@/context/highlights"
@@ -47,11 +47,14 @@ import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { SDKProvider, useSDK } from "@/context/sdk" import { SDKProvider, useSDK } from "@/context/sdk"
import { WslServersProvider } from "@/wsl/context" import { WslServersProvider } from "@/wsl/context"
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout" import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
import Layout from "@/pages/layout" import LegacyLayout from "@/pages/layout"
import NewLayout from "@/pages/layout-new"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health" import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./utils/session-route"
const HomeRoute = lazy(() => import("@/pages/home")) const LegacyHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.LegacyHome })))
const NewHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.NewHome })))
const Session = lazy(() => import("@/pages/session")) const Session = lazy(() => import("@/pages/session"))
const NewSession = lazy(() => import("@/pages/new-session")) const NewSession = lazy(() => import("@/pages/new-session"))
@@ -64,6 +67,10 @@ const SessionRoute = Object.assign(
const server = useServer() const server = useServer()
const tabs = useTabs() const tabs = useTabs()
if (params.id && settings.general.newLayoutDesigns()) {
return <Navigate href={sessionHref(server.key, params.id)} />
}
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id) // When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
// is replaced by a draft at /new-session?draftId=… // is replaced by a draft at /new-session?draftId=…
createEffect(() => { createEffect(() => {
@@ -82,29 +89,55 @@ const SessionRoute = Object.assign(
{ preload: Session.preload }, { preload: Session.preload },
) )
const TargetSessionRoute = Object.assign(
() => {
const sdk = useSDK()
const serverSDK = useServerSDK()
return (
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
<SessionProviders>
<Session />
</SessionProviders>
</Show>
)
},
{ preload: Session.preload },
)
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected // Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/ // server via ServerKey, then provide the server-scoped shell (Permission/Layout/
// Notification/Models + the visual Layout) for that server. // Notification/Models + the visual Layout) for that server.
function SelectedServerLayout(props: ParentProps) { function SelectedServerProviders(props: ParentProps) {
return ( return (
<ServerKey> <ServerKey>
<ServerSDKProvider> <ServerSDKProvider>
<ServerSyncProvider> <ServerSyncProvider>{props.children}</ServerSyncProvider>
<ServerScopedShell>{props.children}</ServerScopedShell>
</ServerSyncProvider>
</ServerSDKProvider> </ServerSDKProvider>
</ServerKey> </ServerKey>
) )
} }
function LegacyServerLayout(props: ParentProps) {
return (
<SelectedServerProviders>
<LegacyServerScopedShell>{props.children}</LegacyServerScopedShell>
</SelectedServerProviders>
)
}
// Wraps /new-session. It resolves the draft's target server and provides the // Wraps /new-session. It resolves the draft's target server and provides the
// server-scoped shell for that server — without ServerKey, so the page never depends // server-scoped shell for that server — without ServerKey, so the page never depends
// on the globally "selected" server. // on the globally "selected" server.
function DraftServerLayout(props: ParentProps) { function TargetServerLayout(props: ParentProps) {
const server = useServer() const server = useServer()
const tabs = useTabs() const tabs = useTabs()
const params = useParams<{ serverKey?: string }>()
const [search] = useSearchParams<{ draftId?: string }>() const [search] = useSearchParams<{ draftId?: string }>()
const conn = createMemo(() => { const conn = createMemo(() => {
if (params.serverKey) {
const key = requireServerKey(params.serverKey)
return server.list.find((item) => ServerConnection.key(item) === key)
}
const id = search.draftId const id = search.draftId
if (!id) return undefined if (!id) return undefined
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id) const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id)
@@ -115,48 +148,98 @@ function DraftServerLayout(props: ParentProps) {
return ( return (
<ServerSDKProvider server={conn}> <ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}> <ServerSyncProvider server={conn}>
<ServerScopedShell>{props.children}</ServerScopedShell> <TargetDirectoryLayout>{props.children}</TargetDirectoryLayout>
</ServerSyncProvider> </ServerSyncProvider>
</ServerSDKProvider> </ServerSDKProvider>
) )
} }
function TargetDirectoryLayout(props: ParentProps) {
const params = useParams<{ serverKey?: string; id?: string }>()
const [search] = useSearchParams<{ draftId?: string }>()
const settings = useSettings()
const tabs = useTabs()
const serverSDK = useServerSDK()
const serverKey = createMemo(() => {
if (params.serverKey) return requireServerKey(params.serverKey)
if (!search.draftId) return undefined
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.server
})
const resolved = useQuery(() => ({
queryKey: [serverSDK().scope, "session-route", params.id] as const,
enabled: !!params.serverKey && !!params.id,
placeholderData: keepPreviousData,
queryFn: async () => {
const session = (await serverSDK().client.session.get({ sessionID: params.id! })).data!
const root = await rootSession(session, (sessionID) =>
serverSDK()
.client.session.get({ sessionID })
.then((result) => result.data!),
)
return { session, rootID: root.id }
},
}))
const resolvedDirectory = createMemo(() => {
if (params.serverKey) return resolved.data?.session.directory
if (!search.draftId) return undefined
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.directory
})
const directory = createMemo<string | undefined>((prev) => prev ?? resolvedDirectory())
const home = () => !params.serverKey && !search.draftId
const targetDirectory = () => directory()!
createEffect(() => {
const current = resolved.data
const key = serverKey()
if (!current || !key) return
tabs.addSessionTab({
server: key,
sessionId: current.rootID,
})
})
return (
<NewServerScopedShell directory={() => (home() ? undefined : directory())} sessionID={() => params.id}>
<Show when={!home()} fallback={props.children}>
<Show when={!resolved.error} fallback={<ErrorPage error={resolved.error} />}>
<Show when={directory()}>
<Show
when={!params.serverKey || settings.general.newLayoutDesigns()}
fallback={<Navigate href={legacySessionHref(directory()!, params.id!)} />}
>
<SDKProvider directory={targetDirectory}>
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
<Show when={!params.serverKey || (resolved.data && !resolved.isPlaceholderData)}>
{props.children}
</Show>
</DirectoryDataProvider>
</SDKProvider>
</Show>
</Show>
</Show>
</Show>
</NewServerScopedShell>
)
}
function DraftRoute() { function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>() const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs() const tabs = useTabs()
return ( return (
<Show when={tabs.ready()}> <Show when={tabs.ready()}>
<Show when={search.draftId} keyed fallback={<Navigate href="/" />}> <Show when={search.draftId} keyed fallback={<Navigate href="/" />}>
{(draftID) => <ResolvedDraftRoute draftID={draftID} />} <ResolvedDraftRoute />
</Show> </Show>
</Show> </Show>
) )
} }
function ResolvedDraftRoute(props: { draftID: string }) { function ResolvedDraftRoute() {
const tabs = useTabs()
const draft = createMemo(() =>
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID),
)
// Key on the directory so retargeting the draft's project re-instantiates the
// directory-scoped providers while keeping the same draft id. The draft's target
// server is provided by DraftServerLayout, so changing only the server updates the
// SDK/sync hooks without remounting the composer.
const directory = () => draft()?.directory
return ( return (
<Show when={directory()} keyed>
{(dir) => (
<SDKProvider directory={dir}>
<DirectoryDataProvider directory={dir} draftID={props.draftID}>
<DraftProviders> <DraftProviders>
<NewSession /> <NewSession />
</DraftProviders> </DraftProviders>
</DirectoryDataProvider>
</SDKProvider>
)}
</Show>
) )
} }
@@ -210,32 +293,51 @@ function BodyDesignClass() {
// shell (router root) so they stay mounted regardless of the active server/route. // shell (router root) so they stay mounted regardless of the active server/route.
function SharedProviders(props: ParentProps) { function SharedProviders(props: ParentProps) {
return ( return (
<SettingsProvider> <>
<BodyDesignClass /> <BodyDesignClass />
<CommandProvider> <CommandProvider>
<HighlightsProvider>{props.children}</HighlightsProvider> <HighlightsProvider>{props.children}</HighlightsProvider>
</CommandProvider> </CommandProvider>
</SettingsProvider> </>
) )
} }
// Server-scoped providers plus the visual Layout (tabs/sidebar). These live inside // Server-scoped providers plus the visual Layout (tabs/sidebar). These live inside
// each per-route server layout so they resolve to that route's server (selected vs // each per-route server layout so they resolve to that route's server (selected vs
// draft). The Layout remounts when crossing between those groups. // draft). The Layout remounts when crossing between those groups.
function ServerScopedShell(props: ParentProps) { type ServerScopedShellProps = ParentProps<{
directory?: () => string | undefined
sessionID?: () => string | undefined
}>
function ServerScopedProviders(props: ServerScopedShellProps) {
return ( return (
<PermissionProvider> <PermissionProvider directory={props.directory}>
<LayoutProvider> <LayoutProvider>
<NotificationProvider> <NotificationProvider directory={props.directory} sessionID={props.sessionID}>
<ModelsProvider> <ModelsProvider>{props.children}</ModelsProvider>
<Layout>{props.children}</Layout>
</ModelsProvider>
</NotificationProvider> </NotificationProvider>
</LayoutProvider> </LayoutProvider>
</PermissionProvider> </PermissionProvider>
) )
} }
function LegacyServerScopedShell(props: ServerScopedShellProps) {
return (
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
<LegacyLayout>{props.children}</LegacyLayout>
</ServerScopedProviders>
)
}
function NewServerScopedShell(props: ServerScopedShellProps) {
return (
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
<NewLayout>{props.children}</NewLayout>
</ServerScopedProviders>
)
}
function SessionProviders(props: ParentProps) { function SessionProviders(props: ParentProps) {
return ( return (
<TerminalProvider> <TerminalProvider>
@@ -439,7 +541,9 @@ export function AppInterface(props: {
servers={props.servers} servers={props.servers}
> >
<GlobalProvider> <GlobalProvider>
<SettingsProvider>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}> <ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
<Dynamic <Dynamic
component={props.router ?? Router} component={props.router ?? Router}
root={(routerProps) => ( root={(routerProps) => (
@@ -448,19 +552,50 @@ export function AppInterface(props: {
</TabsProvider> </TabsProvider>
)} )}
> >
<Route component={SelectedServerLayout}> <Routes />
<Route path="/" component={HomeRoute} /> </Dynamic>
</Show>
</ConnectionGate>
</SettingsProvider>
</GlobalProvider>
</ServerProvider>
)
}
function Routes() {
const settings = useSettings()
return (
<>
<Route component={LegacyServerLayout}>
<Show when={!settings.general.newLayoutDesigns()}>{<Route path="/" component={LegacyHome} />}</Show>
<Route path="/:dir" component={DirectoryLayout}> <Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} /> <Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} /> <Route path="/session/:id?" component={SessionRoute} />
</Route> </Route>
</Route> </Route>
<Route component={DraftServerLayout}> <Route component={TargetServerLayout}>
<Route path="/new-session" component={DraftRoute} /> <Show when={settings.general.newLayoutDesigns()}>
{
<>
<Route path="/" component={NewHome} />
<Route path="/:dir" component={DirectoryLayout}>
<Route
path="/session/:id"
component={() => {
const server = useServer()
const { id } = useParams()
return <Navigate href={`/server/${server.key}/session/${id}`} />
}}
/>
</Route> </Route>
</Dynamic> </>
</ConnectionGate> }
</GlobalProvider> </Show>
</ServerProvider> <Route path="/new-session" component={DraftRoute} />
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
</Route>
</>
) )
} }
@@ -388,12 +388,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
local.session.promote(sessionDirectory, session.id) local.session.promote(sessionDirectory, session.id)
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
const draftID = search.draftId const draftID = search.draftId
if (draftID) if (draftID) tabs.promoteDraft(draftID, { server: server.key, sessionId: session.id })
tabs.promoteDraft(draftID, {
server: server.key,
dirBase64: base64Encode(sessionDirectory),
sessionId: session.id,
})
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
} }
} }
@@ -10,6 +10,7 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js" import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { Portal } from "solid-js/web" import { Portal } from "solid-js/web"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -158,6 +159,7 @@ export function SessionHeader() {
const isV2 = settings.general.newLayoutDesigns const isV2 = settings.general.newLayoutDesigns
const search = settings.visibility.search const search = settings.visibility.search
const status = settings.visibility.status const status = settings.visibility.status
const isDesktop = createMediaQuery("(min-width: 768px)")
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({ const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true, finder: true,
@@ -236,6 +238,7 @@ export function SessionHeader() {
statusLabel: language.t("status.popover.trigger"), statusLabel: language.t("status.popover.trigger"),
reviewLabel: language.t("command.review.toggle"), reviewLabel: language.t("command.review.toggle"),
reviewKeybind: command.keybind("review.toggle"), reviewKeybind: command.keybind("review.toggle"),
reviewVisible: isDesktop(),
reviewOpened: view().reviewPanel.opened(), reviewOpened: view().reviewPanel.opened(),
onReviewToggle: () => view().reviewPanel.toggle(), onReviewToggle: () => view().reviewPanel.toggle(),
})) }))
@@ -518,6 +521,7 @@ type SessionHeaderV2ActionsState = {
statusLabel: string statusLabel: string
reviewLabel: string reviewLabel: string
reviewKeybind: string reviewKeybind: string
reviewVisible: boolean
reviewOpened: boolean reviewOpened: boolean
onReviewToggle: () => void onReviewToggle: () => void
} }
@@ -530,6 +534,7 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
<StatusPopoverV2 /> <StatusPopoverV2 />
</Tooltip> </Tooltip>
</Show> </Show>
<Show when={props.state.reviewVisible}>
<TooltipKeybind title={props.state.reviewLabel} keybind={props.state.reviewKeybind}> <TooltipKeybind title={props.state.reviewLabel} keybind={props.state.reviewKeybind}>
<IconButtonV2 <IconButtonV2
type="button" type="button"
@@ -544,6 +549,7 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
icon={<IconV2 name="sidebar-right" />} icon={<IconV2 name="sidebar-right" />}
/> />
</TooltipKeybind> </TooltipKeybind>
</Show>
</div> </div>
) )
} }
+40 -35
View File
@@ -29,7 +29,6 @@ import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { WindowsAppMenu } from "./windows-app-menu" import { WindowsAppMenu } from "./windows-app-menu"
import { applyPath, backPath, forwardPath } from "./titlebar-history" import { applyPath, backPath, forwardPath } from "./titlebar-history"
import { useServerSync } from "@/context/server-sync"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers" import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
@@ -38,10 +37,11 @@ import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer" import { createResizeObserver } from "@solid-primitives/resize-observer"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events" import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { decode64 } from "@/utils/base64"
import { ServerConnection, useServer } from "@/context/server" import { ServerConnection, useServer } from "@/context/server"
import { tabHref, useTabs, type Tab } from "@/context/tabs" import { tabHref, useTabs } from "@/context/tabs"
import "./titlebar.css" import "./titlebar.css"
import { useServerSDK } from "@/context/server-sdk"
import { Session } from "@opencode-ai/sdk/v2"
type TauriDesktopWindow = { type TauriDesktopWindow = {
startDragging?: () => Promise<void> startDragging?: () => Promise<void>
@@ -252,7 +252,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<Switch> <Switch>
<Match when={useV2Titlebar()}> <Match when={useV2Titlebar()}>
{(_) => { {(_) => {
const serverSync = useServerSync() const serverSdk = useServerSDK()
const navigate = useNavigate() const navigate = useNavigate()
const layout = useLayout() const layout = useLayout()
@@ -268,6 +268,17 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const tabs = useTabs() const tabs = useTabs()
const tabsStore = tabs.store const tabsStore = tabs.store
const tabsStoreActions = tabs const tabsStoreActions = tabs
const [session] = createResource(
() => {
const route = layout.route()
return route.type === "session" ? route : undefined
},
(route) =>
serverSdk()
.client.session.get({ sessionID: route.sessionId })
.then((x) => x.data)
.catch(() => {}),
)
const matchRoute = (route: LayoutRoute) => { const matchRoute = (route: LayoutRoute) => {
if (route.type === "home") return if (route.type === "home") return
@@ -280,10 +291,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
item.type === "session" && item.server === route.server && item.sessionId === route.sessionId, item.type === "session" && item.server === route.server && item.sessionId === route.sessionId,
) )
if (main) return main if (main) return main
const sync = serverSync().createDirSyncContext(route.dir) const s = session()
const session = sync.session.get(route.sessionId) if (s?.parentID) {
if (session?.parentID) { const parentID = s.parentID
const parentID = session.parentID
const parent = tabsStore.find( const parent = tabsStore.find(
(item) => item.type === "session" && item.server === route.server && item.sessionId === parentID, (item) => item.type === "session" && item.server === route.server && item.sessionId === parentID,
) )
@@ -304,15 +314,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
} }
if (route.type === "session") { if (route.type === "session") {
const sync = serverSync().createDirSyncContext(route.dir) const s = session()
const session = sync.session.get(route.sessionId) if (!s) return
if (!session) return const sessionId = s.parentID ?? s.id
const sessionId = session.parentID ?? session.id const next = { server: route.server ?? server.key, sessionId }
const next = {
server: route.server ?? server.key,
dirBase64: route.dirBase64,
sessionId,
}
tabsStoreActions.addSessionTab(next) tabsStoreActions.addSessionTab(next)
} }
}) })
@@ -495,15 +500,26 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
) )
} }
const [session] = createResource(
() => tab.sessionId,
(sessionID) =>
serverSdk()
.client.session.get({ sessionID })
.then((x) => x.data)
.catch(() => undefined),
)
return ( return (
<> <>
{divider()} {divider()}
<Show when={session()}>
{(session) => (
<TabNavItem <TabNavItem
ref={ref} ref={ref}
href={tabHref(tab)} href={tabHref(tab)}
server={tab.server} server={tab.server}
directory={decode64(tab.dirBase64)!}
sessionId={tab.sessionId} sessionId={tab.sessionId}
session={session()}
onNavigate={() => { onNavigate={() => {
tabs.select(tab) tabs.select(tab)
@@ -514,6 +530,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
activeServer={tab.server === server.key} activeServer={tab.server === server.key}
forceTruncate={tabsAreOverflowing()} forceTruncate={tabsAreOverflowing()}
/> />
)}
</Show>
</> </>
) )
}} }}
@@ -793,7 +811,6 @@ function TabNavItem(props: {
ref?: HTMLDivElement ref?: HTMLDivElement
href: string href: string
server: ServerConnection.Key server: ServerConnection.Key
directory: string
sessionId?: string sessionId?: string
hideClose?: boolean hideClose?: boolean
onClose: () => void onClose: () => void
@@ -801,31 +818,19 @@ function TabNavItem(props: {
active?: boolean active?: boolean
activeServer: boolean activeServer: boolean
forceTruncate?: boolean forceTruncate?: boolean
session: Session
}) { }) {
const closeTab = (event: MouseEvent) => { const closeTab = (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
props.onClose() props.onClose()
} }
const global = useGlobal() const global = useGlobal()
const serverCtx = createMemo(() => { const serverCtx = createMemo(() => {
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server) const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
if (conn) return global.createServerCtx(conn) if (conn) return global.createServerCtx(conn)
}) })
const dirSyncCtx = createMemo(() => serverCtx()?.sync.createDirSyncContext(props.directory))
const [session] = createResource(
() => {
const ctx = dirSyncCtx()
if (!ctx || !props.sessionId) return
return [props.sessionId, ctx] as const
},
async ([sessionId, dirSyncCtx]) => {
await dirSyncCtx.session.sync(sessionId).catch(() => {})
return dirSyncCtx.session.get(sessionId)
},
{ initialValue: props.sessionId ? dirSyncCtx()?.session.get(props.sessionId) : undefined },
)
return ( return (
<div <div
@@ -837,7 +842,7 @@ function TabNavItem(props: {
closeTab(event) closeTab(event)
}} }}
> >
<Show when={session.latest}> <Show when={props.session}>
{(session) => { {(session) => {
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? [])) const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
@@ -853,7 +858,7 @@ function TabNavItem(props: {
<span data-slot="project-avatar-slot"> <span data-slot="project-avatar-slot">
<ProjectTabAvatar <ProjectTabAvatar
project={project()} project={project()}
directory={props.directory} directory={session().directory}
sessionId={session().id} sessionId={session().id}
activeServer={props.activeServer} activeServer={props.activeServer}
/> />
+4 -1
View File
@@ -2,12 +2,14 @@ import { batch, createMemo, createRoot, onCleanup } from "solid-js"
import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "./server-sdk" import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
import { createScopedCache } from "@/utils/scoped-cache" import { createScopedCache } from "@/utils/scoped-cache"
import { uuid } from "@/utils/uuid" import { uuid } from "@/utils/uuid"
import type { SelectedLineRange } from "@/context/file" import type { SelectedLineRange } from "@/context/file"
import { useSDK } from "./sdk"
export type LineComment = { export type LineComment = {
id: string id: string
@@ -202,6 +204,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
gate: false, gate: false,
init: () => { init: () => {
const params = useParams() const params = useParams()
const sdk = useSDK()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const cache = createScopedCache( const cache = createScopedCache(
(key) => { (key) => {
@@ -228,7 +231,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
return cache.get(key).value return cache.get(key).value
} }
const session = createMemo(() => load(params.dir!, params.id)) const session = createMemo(() => load(base64Encode(sdk().directory), params.id))
return { return {
ready: () => session().ready(), ready: () => session().ready(),
+2 -1
View File
@@ -3,6 +3,7 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { useSync } from "./sync" import { useSync } from "./sync"
@@ -65,7 +66,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const scope = createMemo(() => sdk().directory) const scope = createMemo(() => sdk().directory)
const path = createPathHelpers(scope) const path = createPathHelpers(scope)
const tabs = layout.tabs(() => const tabs = layout.tabs(() =>
SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(params.dir, params.id)), SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(base64Encode(sdk().directory), params.id)),
) )
const inflight = new Map<string, Promise<void>>() const inflight = new Map<string, Promise<void>>()
+13 -3
View File
@@ -16,6 +16,7 @@ import { createPathHelpers } from "./file/path"
import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2" import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope" import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers" import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
import { requireServerKey } from "@/utils/session-route"
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
@@ -79,7 +80,7 @@ export type LayoutRoute =
| { type: "home" } | { type: "home" }
| { type: "draft"; draftID: string; server?: ServerConnection.Key } | { type: "draft"; draftID: string; server?: ServerConnection.Key }
| { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key } | { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
| { type: "session"; dir: string; dirBase64: string; sessionId: string; server?: ServerConnection.Key } | { type: "session"; sessionId: string; server?: ServerConnection.Key }
function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs { function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
const all = current?.all ?? [] const all = current?.all ?? []
@@ -131,6 +132,14 @@ const currentRoute = (pathname: string, search: string): LayoutRoute => {
return { type: "draft", draftID } return { type: "draft", draftID }
} }
if (parts[0] === "server" && parts[2] === "session" && parts[3]) {
return {
type: "session",
sessionId: parts[3],
server: requireServerKey(parts[1]),
}
}
const dirBase64 = parts[0] const dirBase64 = parts[0]
const dir = decode64(dirBase64) const dir = decode64(dirBase64)
if (!dir) return { type: "home" } if (!dir) return { type: "home" }
@@ -138,7 +147,7 @@ const currentRoute = (pathname: string, search: string): LayoutRoute => {
if (parts[1] !== "session") return { type: "home" } if (parts[1] !== "session") return { type: "home" }
const id = parts[2] const id = parts[2]
if (id) return { type: "session", dir, dirBase64, sessionId: id } if (id) return { type: "session", sessionId: id }
return { type: "dir-new-sesssion", dir, dirBase64 } return { type: "dir-new-sesssion", dir, dirBase64 }
} }
@@ -154,6 +163,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const route = createMemo(() => { const route = createMemo(() => {
const value = currentRoute(location.pathname, location.search) const value = currentRoute(location.pathname, location.search)
if (value.type === "home") return value if (value.type === "home") return value
if (value.server) return value
return { ...value, server: server.key } return { ...value, server: server.key }
}) })
@@ -572,7 +582,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
handoff: { handoff: {
tabs: createMemo(() => store.handoff?.tabs), tabs: createMemo(() => store.handoff?.tabs),
setTabs(dir: string, id: string) { setTabs(dir: string, id: string) {
setStore("handoff", "tabs", { scope: server.scope(), dir, id, at: Date.now() }) setStore("handoff", "tabs", { scope: serverSdk().scope, dir, id, at: Date.now() })
}, },
clearTabs() { clearTabs() {
if (!store.handoff?.tabs) return if (!store.handoff?.tabs) return
+4 -4
View File
@@ -1,5 +1,5 @@
import { createStore, reconcile } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { batch, createEffect, createMemo, onCleanup } from "solid-js" import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useServerSDK } from "./server-sdk" import { useServerSDK } from "./server-sdk"
@@ -108,7 +108,7 @@ function buildNotificationIndex(list: Notification[]) {
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({ export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
name: "Notification", name: "Notification",
gate: false, gate: false,
init: () => { init: (props: { directory?: Accessor<string | undefined>; sessionID?: Accessor<string | undefined> }) => {
const params = useParams() const params = useParams()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const serverSync = useServerSync() const serverSync = useServerSync()
@@ -119,10 +119,10 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const empty: Notification[] = [] const empty: Notification[] = []
const currentDirectory = createMemo(() => { const currentDirectory = createMemo(() => {
return decode64(params.dir) return props.directory?.() ?? decode64(params.dir)
}) })
const currentSession = createMemo(() => params.id) const currentSession = createMemo(() => props.sessionID?.() ?? params.id)
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]), Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
+4 -4
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, onCleanup } from "solid-js" import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client" import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
@@ -47,13 +47,13 @@ function hasPermissionPromptRules(permission: unknown) {
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({ export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
name: "Permission", name: "Permission",
gate: false, gate: false,
init: () => { init: (props: { directory?: Accessor<string | undefined> }) => {
const params = useParams() const params = useParams()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const serverSync = useServerSync() const serverSync = useServerSync()
const permissionsEnabled = createMemo(() => { const permissionsEnabled = createMemo(() => {
const directory = decode64(params.dir) const directory = props.directory?.() ?? decode64(params.dir)
if (!directory) return false if (!directory) return false
const [store] = serverSync().child(directory) const [store] = serverSync().child(directory)
return hasPermissionPromptRules(store.config.permission) return hasPermissionPromptRules(store.config.permission)
@@ -85,7 +85,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
// When config has permission: "allow", auto-enable directory-level auto-accept // When config has permission: "allow", auto-enable directory-level auto-accept
createEffect(() => { createEffect(() => {
if (!ready()) return if (!ready()) return
const directory = decode64(params.dir) const directory = props.directory?.() ?? decode64(params.dir)
if (!directory) return if (!directory) return
const [childStore] = serverSync().child(directory) const [childStore] = serverSync().child(directory)
const perm = childStore.config.permission const perm = childStore.config.permission
+4 -2
View File
@@ -1,5 +1,5 @@
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { checksum } from "@opencode-ai/core/util/encode" import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { useParams, useSearchParams } from "@solidjs/router" import { useParams, useSearchParams } from "@solidjs/router"
import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js" import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store" import { createStore, type SetStoreFunction } from "solid-js/store"
@@ -7,6 +7,7 @@ import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "./server-sdk" import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
import { useSDK } from "./sdk"
interface PartBase { interface PartBase {
content: string content: string
@@ -256,6 +257,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
gate: false, gate: false,
init: () => { init: () => {
const params = useParams() const params = useParams()
const sdk = useSDK()
const [search] = useSearchParams<{ draftId?: string }>() const [search] = useSearchParams<{ draftId?: string }>()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const cache = new Map<string, PromptCacheEntry>() const cache = new Map<string, PromptCacheEntry>()
@@ -303,7 +305,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
} }
const session = createMemo(() => const session = createMemo(() =>
load(search.draftId ? { draftID: search.draftId } : { dir: params.dir!, id: params.id }), load(search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }),
) )
const pick = (scope?: Scope) => (scope ? load(scope) : session()) const pick = (scope?: Scope) => (scope ? load(scope) : session())
+4 -23
View File
@@ -1,6 +1,5 @@
import type { Session } from "@opencode-ai/sdk/v2/client" import type { Session } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist" import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
import { ServerConnection, useServer } from "./server" import { ServerConnection, useServer } from "./server"
@@ -9,11 +8,11 @@ import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
import { uuid } from "@/utils/uuid" import { uuid } from "@/utils/uuid"
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events" import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
import { sessionHref } from "@/utils/session-route"
export type SessionTab = { export type SessionTab = {
type: "session" type: "session"
server: ServerConnection.Key server: ServerConnection.Key
dirBase64: string
sessionId: string sessionId: string
} }
@@ -34,16 +33,12 @@ type RecentTab = {
export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}` export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}`
export const tabHref = (tab: Tab) => export const tabHref = (tab: Tab) =>
tab.type === "draft" ? draftHref(tab.draftID) : `/${tab.dirBase64}/session/${tab.sessionId}` tab.type === "draft" ? draftHref(tab.draftID) : sessionHref(tab.server, tab.sessionId)
export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`) export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`)
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) { export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) {
const dirBase64 = base64Encode(session.directory) return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id)
return tabs.some(
(tab) =>
tab.type === "session" && tab.server === server && tab.dirBase64 === dirBase64 && tab.sessionId === session.id,
)
} }
export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
@@ -105,14 +100,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const navigateTab = (tab: Tab) => { const navigateTab = (tab: Tab) => {
const href = tabHref(tab) const href = tabHref(tab)
setRecentKey(tabKey(tab)) setRecentKey(tabKey(tab))
if (tab.server === server.key) {
navigate(href) navigate(href)
return
}
void startTransition(() => {
server.setActive(tab.server)
navigate(href)
})
} }
const actions = { const actions = {
@@ -195,11 +183,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
removeSessions: (input: SessionTabsRemovedDetail) => { removeSessions: (input: SessionTabsRemovedDetail) => {
const removed = store const removed = store
.filter( .filter(
(tab) => (tab) => tab.type === "session" && tab.server === server.key && input.sessionIDs.includes(tab.sessionId),
tab.type === "session" &&
tab.server === server.key &&
atob(tab.dirBase64) === input.directory &&
input.sessionIDs.includes(tab.sessionId),
) )
.map(tabKey) .map(tabKey)
void startTransition(() => { void startTransition(() => {
@@ -211,7 +195,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
? tabHref({ ? tabHref({
type: "session", type: "session",
server: server.key, server: server.key,
dirBase64: params.dir,
sessionId: params.id, sessionId: params.id,
}) })
: undefined : undefined
@@ -224,14 +207,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const removedCurrent = const removedCurrent =
currentTab?.type === "session" && currentTab?.type === "session" &&
currentTab.server === server.key && currentTab.server === server.key &&
atob(currentTab.dirBase64) === input.directory &&
sessionIDs.has(currentTab.sessionId) sessionIDs.has(currentTab.sessionId)
for (let i = tabs.length - 1; i >= 0; i--) { for (let i = tabs.length - 1; i >= 0; i--) {
const tab = tabs[i] const tab = tabs[i]
if (!tab || tab.type !== "session") continue if (!tab || tab.type !== "session") continue
if (tab.server !== server.key) continue if (tab.server !== server.key) continue
if (atob(tab.dirBase64) !== input.directory) continue
if (!sessionIDs.has(tab.sessionId)) continue if (!sessionIDs.has(tab.sessionId)) continue
tabs.splice(i, 1) tabs.splice(i, 1)
} }
+7 -5
View File
@@ -4,7 +4,8 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { useSDK, type DirectorySDK } from "./sdk" import { useSDK, type DirectorySDK } from "./sdk"
import type { Platform } from "./platform" import type { Platform } from "./platform"
import { useServer } from "./server" import { useServerSDK } from "./server-sdk"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { defaultTitle, titleNumber } from "./terminal-title" import { defaultTitle, titleNumber } from "./terminal-title"
import { Persist, persisted, removePersisted } from "@/utils/persist" import { Persist, persisted, removePersisted } from "@/utils/persist"
import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope" import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope"
@@ -374,10 +375,11 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
gate: false, gate: false,
init: () => { init: () => {
const sdk = useSDK() const sdk = useSDK()
const server = useServer() const serverSDK = useServerSDK()
const params = useParams() const params = useParams()
const cache = new Map<string, TerminalCacheEntry>() const cache = new Map<string, TerminalCacheEntry>()
const scope = server.scope() const scope = () => serverSDK().scope
const directory = createMemo(() => base64Encode(sdk().directory))
caches.add(cache) caches.add(cache)
onCleanup(() => caches.delete(cache)) onCleanup(() => caches.delete(cache))
@@ -421,11 +423,11 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
return entry.value return entry.value
} }
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope)) const workspace = createMemo(() => loadWorkspace(directory(), params.id, scope()))
createEffect( createEffect(
on( on(
() => ({ dir: params.dir, id: params.id, scope }), () => ({ dir: directory(), id: params.id, scope: scope() }),
(next, prev) => { (next, prev) => {
if (!prev?.dir) return if (!prev?.dir) return
if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return
+22 -8
View File
@@ -2,26 +2,40 @@ import { DataProvider } from "@opencode-ai/ui/context"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { useLocation, useNavigate, useParams } from "@solidjs/router" import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js" import { type Accessor, createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { LocalProvider } from "@/context/local" import { LocalProvider } from "@/context/local"
import { SDKProvider } from "@/context/sdk" import { SDKProvider } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { Schema } from "effect" import { Schema } from "effect"
import type { ServerConnection } from "@/context/server"
import { sessionHref } from "@/utils/session-route"
export function DirectoryDataProvider(props: ParentProps<{ directory: string; draftID?: string }>) { export function DirectoryDataProvider(
props: ParentProps<{
directory: string | Accessor<string>
draftID?: string
server?: Accessor<ServerConnection.Key | undefined>
}>,
) {
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const params = useParams() const params = useParams()
const sync = useSync() const sync = useSync()
const slug = createMemo(() => base64Encode(props.directory)) const directory = () => (typeof props.directory === "function" ? props.directory() : props.directory)
const slug = createMemo(() => base64Encode(directory()))
const href = (sessionID: string) => {
const server = props.server?.()
if (server) return sessionHref(server, sessionID)
return `/${slug()}/session/${sessionID}`
}
createEffect(() => { createEffect(() => {
// A draft lives at /new-session?draftId=… and has no directory segment to normalize. // A draft lives at /new-session?draftId=… and has no directory segment to normalize.
if (props.draftID) return if (props.draftID || props.server?.()) return
const next = sync().data.path.directory const next = sync().data.path.directory
if (!next || next === props.directory) return if (!next || next === directory()) return
const path = location.pathname.slice(slug().length + 1) const path = location.pathname.slice(slug().length + 1)
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
}) })
@@ -37,9 +51,9 @@ export function DirectoryDataProvider(props: ParentProps<{ directory: string; dr
return ( return (
<DataProvider <DataProvider
data={sync().data} data={sync().data}
directory={props.directory} directory={directory()}
onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)} onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`} onSessionHref={href}
> >
<LocalProvider>{props.children}</LocalProvider> <LocalProvider>{props.children}</LocalProvider>
</DataProvider> </DataProvider>
+33 -21
View File
@@ -43,7 +43,6 @@ import { sessionTitle } from "@/utils/session-title"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useSettings } from "@/context/settings"
import { ServerRowMenu } from "@/components/server/server-row-menu" import { ServerRowMenu } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row" import { ServerHealthIndicator } from "@/components/server/server-row"
import { type ServerHealth } from "@/utils/server-health" import { type ServerHealth } from "@/utils/server-health"
@@ -113,16 +112,7 @@ function homeSessionSearchKey(record: HomeSessionRecord) {
return `${pathKey(record.session.directory)}:${record.session.id}` return `${pathKey(record.session.directory)}:${record.session.id}`
} }
export default function Home() { export function NewHome() {
const settings = useSettings()
return (
<Show when={settings.general.newLayoutDesigns()} fallback={<LegacyHome />}>
<HomeDesign />
</Show>
)
}
function HomeDesign() {
const sync = useServerSync() const sync = useServerSync()
const layout = useLayout() const layout = useLayout()
const platform = usePlatform() const platform = usePlatform()
@@ -313,7 +303,7 @@ function HomeDesign() {
const ctx = global.createServerCtx(conn) const ctx = global.createServerCtx(conn)
ctx.projects.open(directory) ctx.projects.open(directory)
ctx.projects.touch(directory) ctx.projects.touch(directory)
navigateOnServer(conn, `/${base64Encode(session.directory)}/session/${session.id}`) navigateOnServer(conn, `/server/${base64Encode(ServerConnection.key(conn))}/session/${session.id}`)
} }
function chooseProject(conn: ServerConnection.Any) { function chooseProject(conn: ServerConnection.Any) {
@@ -339,7 +329,7 @@ function HomeDesign() {
return ( return (
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1"> <div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
<div class="mx-auto grid w-full h-full max-w-[1080px] gap-8 px-6 pb-16 lg:grid-cols-[280px_minmax(0,720px)]"> <div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 pb-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6 lg:pb-16">
<HomeProjectColumn <HomeProjectColumn
projects={projects()} projects={projects()}
selected={state.selection} selected={state.selection}
@@ -365,7 +355,7 @@ function HomeDesign() {
/> />
<section <section
class="min-h-0 min-w-0 flex-1 flex flex-col pt-12" class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12"
aria-label={language.t("sidebar.project.recentSessions")} aria-label={language.t("sidebar.project.recentSessions")}
> >
<HomeSessionSearch <HomeSessionSearch
@@ -416,7 +406,7 @@ function HomeDesign() {
record={record} record={record}
server={state.selection.server} server={state.selection.server}
activeServer={state.selection.server === server.key} activeServer={state.selection.server === server.key}
openSession={openSession} onClick={() => openSession(record.session)}
/> />
)} )}
</For> </For>
@@ -429,6 +419,12 @@ function HomeDesign() {
</div> </div>
</ScrollView> </ScrollView>
</section> </section>
<HomeUtilityNav
class="flex lg:hidden"
openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language}
/>
</div> </div>
</div> </div>
) )
@@ -453,7 +449,7 @@ function HomeProjectColumn(props: {
const dialog = useDialog() const dialog = useDialog()
const controller = useServerManagementController({ navigateOnAdd: false }) const controller = useServerManagementController({ navigateOnAdd: false })
return ( return (
<aside class="flex min-w-0 flex-col lg:pt-[52px] mt-14 gap-4" aria-label={props.language.t("home.projects")}> <aside class="mt-6 flex min-w-0 flex-col gap-4 lg:mt-14 lg:pt-[52px]" aria-label={props.language.t("home.projects")}>
<div class="flex h-7 min-w-0 items-center justify-between pl-1.5"> <div class="flex h-7 min-w-0 items-center justify-between pl-1.5">
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div> <div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
<Show when={global.servers.list().length === 1}> <Show when={global.servers.list().length === 1}>
@@ -499,7 +495,24 @@ function HomeProjectColumn(props: {
}} }}
</For> </For>
</Show> </Show>
<div class="mt-4 flex min-w-0 flex-col gap-1"> <HomeUtilityNav
class="mt-4 hidden lg:flex"
openSettings={props.openSettings}
openHelp={props.openHelp}
language={props.language}
/>
</aside>
)
}
function HomeUtilityNav(props: {
class?: string
openSettings: () => void
openHelp: () => void
language: ReturnType<typeof useLanguage>
}) {
return (
<div class={`${props.class ?? ""} min-w-0 flex-col gap-1`}>
<button <button
type="button" type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`} class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
@@ -517,7 +530,6 @@ function HomeProjectColumn(props: {
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.help")}</span> <span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.help")}</span>
</button> </button>
</div> </div>
</aside>
) )
} }
@@ -1024,7 +1036,7 @@ function HomeSessionRow(props: {
record: HomeSessionRecord record: HomeSessionRecord
server: ServerConnection.Key server: ServerConnection.Key
activeServer: boolean activeServer: boolean
openSession: (session: Session) => void onClick: () => void
}) { }) {
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
@@ -1033,7 +1045,7 @@ function HomeSessionRow(props: {
type="button" type="button"
data-component="home-session-row" data-component="home-session-row"
class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`} class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`}
onClick={() => props.openSession(props.record.session)} onClick={props.onClick}
> >
<HomeSessionLeading <HomeSessionLeading
project={props.record.project} project={props.record.project}
@@ -1093,7 +1105,7 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof
].filter((group) => group.sessions.length > 0) ].filter((group) => group.sessions.length > 0)
} }
function LegacyHome() { export function LegacyHome() {
const sync = useServerSync() const sync = useServerSync()
const platform = usePlatform() const platform = usePlatform()
const pickDirectory = useDirectoryPicker() const pickDirectory = useDirectoryPicker()
+38
View File
@@ -0,0 +1,38 @@
import { createEffect, type ParentProps } from "solid-js"
import { useNavigate } from "@solidjs/router"
import { DebugBar } from "@/components/debug-bar"
import { HelpButton } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { usePlatform } from "@/context/platform"
import { setNavigate } from "@/utils/notification-click"
import { setV2Toast, ToastRegion } from "@/utils/toast"
export default function NewLayout(props: ParentProps) {
const platform = usePlatform()
const navigate = useNavigate()
setNavigate(navigate)
createEffect(() => setV2Toast(true))
const update: TitlebarUpdate = {
version: () => {
const state = platform.updater?.state()
if (state?.status !== "ready") return
return state.version
},
installing: () => platform.updater?.state().status === "installing",
install: () => void platform.updater?.install(),
}
return (
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
<Titlebar update={update} />
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
{props.children}
</main>
{import.meta.env.DEV && <DebugBar />}
<HelpButton />
<ToastRegion v2 />
</div>
)
}
+7 -28
View File
@@ -13,7 +13,7 @@ import {
type Accessor, type Accessor,
} from "solid-js" } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { useLocation, useNavigate, useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { useLayout, LocalProject } from "@/context/layout" import { useLayout, LocalProject } from "@/context/layout"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
@@ -92,7 +92,7 @@ import {
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project" import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
import { SidebarContent } from "./layout/sidebar-shell" import { SidebarContent } from "./layout/sidebar-shell"
export default function Layout(props: ParentProps) { export default function LegacyLayout(props: ParentProps) {
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const [store, setStore, , ready] = persisted( const [store, setStore, , ready] = persisted(
Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]), Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]),
@@ -131,10 +131,8 @@ export default function Layout(props: ParentProps) {
const command = useCommand() const command = useCommand()
const theme = useTheme() const theme = useTheme()
const language = useLanguage() const language = useLanguage()
const newDesign = createMemo(() => settings.general.newLayoutDesigns()) createEffect(() => setV2Toast(false))
createEffect(() => setV2Toast(newDesign()))
const initialDirectory = decode64(params.dir) const initialDirectory = decode64(params.dir)
const location = useLocation()
const route = createMemo(() => { const route = createMemo(() => {
const slug = params.dir const slug = params.dir
if (!slug) return { slug, dir: "" } if (!slug) return { slug, dir: "" }
@@ -158,7 +156,7 @@ export default function Layout(props: ParentProps) {
const currentDir = createMemo(() => route().dir) const currentDir = createMemo(() => route().dir)
const [state, setState] = createStore({ const [state, setState] = createStore({
autoselect: !initialDirectory && !newDesign(), autoselect: !initialDirectory,
busyWorkspaces: {} as Record<string, boolean>, busyWorkspaces: {} as Record<string, boolean>,
hoverProject: undefined as string | undefined, hoverProject: undefined as string | undefined,
scrollSessionKey: undefined as string | undefined, scrollSessionKey: undefined as string | undefined,
@@ -996,7 +994,7 @@ export default function Layout(props: ParentProps) {
id: "sidebar.toggle", id: "sidebar.toggle",
title: language.t("command.sidebar.toggle"), title: language.t("command.sidebar.toggle"),
category: language.t("command.category.view"), category: language.t("command.category.view"),
keybind: newDesign() ? undefined : "mod+b", keybind: "mod+b",
onSelect: () => layout.sidebar.toggle(), onSelect: () => layout.sidebar.toggle(),
}, },
{ {
@@ -1134,7 +1132,6 @@ export default function Layout(props: ParentProps) {
}, },
] ]
if (!newDesign())
Array.from({ length: 9 }, (_, i) => { Array.from({ length: 9 }, (_, i) => {
const index = i const index = i
const number = index + 1 const number = index + 1
@@ -1812,7 +1809,7 @@ export default function Layout(props: ParentProps) {
createEffect(() => { createEffect(() => {
document.documentElement.style.setProperty( document.documentElement.style.setProperty(
"--dialog-left-margin", "--dialog-left-margin",
newDesign() ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`, `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
) )
}) })
@@ -2355,23 +2352,6 @@ export default function Layout(props: ParentProps) {
) )
return ( return (
<Show
when={!newDesign()}
fallback={
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{autoselecting() ?? ""}
<Titlebar update={titlebarUpdate} />
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
<HelpButton />
<ToastRegion v2={newDesign()} />
</div>
}
>
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"> <div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{autoselecting() ?? ""} {autoselecting() ?? ""}
<Titlebar update={titlebarUpdate} /> <Titlebar update={titlebarUpdate} />
@@ -2522,9 +2502,8 @@ export default function Layout(props: ParentProps) {
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />} {import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
</div> </div>
<HelpButton /> <HelpButton />
<ToastRegion v2={newDesign()} /> <ToastRegion v2={false} />
</div> </div>
</Show>
) )
} }
+37 -34
View File
@@ -28,7 +28,7 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { checksum } from "@opencode-ai/core/util/encode" import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { useLocation, useSearchParams } from "@solidjs/router" import { useLocation, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session" import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
@@ -56,7 +56,6 @@ import { MessageTimeline } from "@/pages/session/timeline/message-timeline"
import { createTimelineModel } from "@/pages/session/timeline/model" import { createTimelineModel } from "@/pages/session/timeline/model"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { useServer } from "@/context/server"
import { syncSessionModel } from "@/pages/session/session-model-helpers" import { syncSessionModel } from "@/pages/session/session-model-helpers"
import { SessionSidePanel } from "@/pages/session/session-side-panel" import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { TerminalPanel } from "@/pages/session/terminal-panel" import { TerminalPanel } from "@/pages/session/terminal-panel"
@@ -92,7 +91,6 @@ export default function Page() {
const prompt = usePrompt() const prompt = usePrompt()
const comments = useComments() const comments = useComments()
const terminal = useTerminal() const terminal = useTerminal()
const server = useServer()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const location = useLocation() const location = useLocation()
const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout() const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout()
@@ -137,11 +135,11 @@ export default function Page() {
layout.handoff.clearTabs() layout.handoff.clearTabs()
return return
} }
if (pending.scope !== server.scope()) return if (pending.scope !== serverSDK().scope) return
if (pending.id !== id) return if (pending.id !== id) return
layout.handoff.clearTabs() layout.handoff.clearTabs()
if (pending.dir !== (params.dir ?? "")) return if (pending.dir !== base64Encode(sdk().directory)) return
const from = workspaceTabs().tabs() const from = workspaceTabs().tabs()
if (from.all.length === 0 && !from.active) return if (from.all.length === 0 && !from.active) return
@@ -247,7 +245,7 @@ export default function Page() {
createEffect( createEffect(
on( on(
() => ({ dir: params.dir, id: params.id }), () => ({ dir: sdk().directory, id: params.id }),
(next, prev) => { (next, prev) => {
if (!prev) return if (!prev) return
if (next.dir === prev.dir && next.id === prev.id) return if (next.dir === prev.dir && next.id === prev.id) return
@@ -575,7 +573,7 @@ export default function Page() {
createEffect( createEffect(
on( on(
() => params.dir, () => sdk().directory,
(dir) => { (dir) => {
if (!dir) return if (!dir) return
setStore("newSessionWorktree", "main") setStore("newSessionWorktree", "main")
@@ -1570,6 +1568,31 @@ export default function Page() {
/> />
) )
const mobileTabs = (compact = false) => (
<Tabs value={store.mobileTab} class="h-auto">
<Tabs.List class={compact ? "!h-9" : undefined}>
<Tabs.Trigger
value="session"
class="!w-1/2 !max-w-none"
classes={{ button: compact ? "w-full !py-2" : "w-full" }}
onClick={() => setStore("mobileTab", "session")}
>
{language.t("session.tab.session")}
</Tabs.Trigger>
<Tabs.Trigger
value="changes"
class="!w-1/2 !max-w-none !border-r-0"
classes={{ button: compact ? "w-full !py-2" : "w-full" }}
onClick={() => setStore("mobileTab", "changes")}
>
{hasReview()
? language.t("session.review.filesChanged", { count: reviewCount() })
: language.t("session.review.change.other")}
</Tabs.Trigger>
</Tabs.List>
</Tabs>
)
return ( return (
<div class="relative size-full overflow-hidden flex flex-col"> <div class="relative size-full overflow-hidden flex flex-col">
{sessionSync() ?? ""} {sessionSync() ?? ""}
@@ -1580,30 +1603,7 @@ export default function Page() {
"gap-2 p-2": settings.general.newLayoutDesigns(), "gap-2 p-2": settings.general.newLayoutDesigns(),
}} }}
> >
<Show when={!isDesktop() && !!params.id}> <Show when={!isDesktop() && !!params.id && !settings.general.newLayoutDesigns()}>{mobileTabs()}</Show>
<Tabs value={store.mobileTab} class="h-auto">
<Tabs.List>
<Tabs.Trigger
value="session"
class="!w-1/2 !max-w-none"
classes={{ button: "w-full" }}
onClick={() => setStore("mobileTab", "session")}
>
{language.t("session.tab.session")}
</Tabs.Trigger>
<Tabs.Trigger
value="changes"
class="!w-1/2 !max-w-none !border-r-0"
classes={{ button: "w-full" }}
onClick={() => setStore("mobileTab", "changes")}
>
{hasReview()
? language.t("session.review.filesChanged", { count: reviewCount() })
: language.t("session.review.change.other")}
</Tabs.Trigger>
</Tabs.List>
</Tabs>
</Show>
<div <div
classList={{ classList={{
@@ -1622,6 +1622,9 @@ export default function Page() {
"shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id, "shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
}} }}
> >
<Show when={!isDesktop() && !!params.id && settings.general.newLayoutDesigns()}>
{mobileTabs(true)}
</Show>
<div class="flex-1 min-h-0 overflow-hidden"> <div class="flex-1 min-h-0 overflow-hidden">
<Switch> <Switch>
<Match when={params.id && mobileChanges()}> <Match when={params.id && mobileChanges()}>
@@ -1629,8 +1632,8 @@ export default function Page() {
{reviewContent({ {reviewContent({
diffStyle: "unified", diffStyle: "unified",
classes: { classes: {
root: "pb-8", root: "pb-8 [&_[data-slot=session-review-list]]:pb-0",
header: "px-4", header: "px-4 !h-16 !pb-4",
container: "px-4", container: "px-4",
}, },
loadingClass: "px-4 py-4 text-text-weak", loadingClass: "px-4 py-4 text-text-weak",
@@ -1686,7 +1689,7 @@ export default function Page() {
</Switch> </Switch>
</div> </div>
<Show when={params.id || !newSessionDesign()}>{composerRegion("dock")}</Show> <Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{composerRegion("dock")}</Show>
</div> </div>
<Show when={desktopReviewOpen()}> <Show when={desktopReviewOpen()}>
@@ -29,6 +29,7 @@ import { useServer } from "@/context/server"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { useDirectoryPicker } from "@/components/directory-picker" import { useDirectoryPicker } from "@/components/directory-picker"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
export function SessionComposerRegion(props: { export function SessionComposerRegion(props: {
state: SessionComposerState state: SessionComposerState
@@ -200,7 +201,11 @@ export function SessionComposerRegion(props: {
const openParent = () => { const openParent = () => {
const id = parentID() const id = parentID()
if (!id) return if (!id) return
navigate(`/${route.params.dir}/session/${id}`) navigate(
route.params.serverKey
? sessionHref(requireServerKey(route.params.serverKey), id)
: legacySessionHref(sdk().directory, id),
)
} }
createEffect(() => { createEffect(() => {
@@ -1,15 +1,19 @@
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useServer } from "@/context/server"
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope" import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { base64Encode } from "@opencode-ai/core/util/encode"
export const useSessionKey = () => { export const useSessionKey = () => {
const params = useParams() const params = useParams()
const server = useServer() const sdk = useSDK()
const scope = createMemo(() => server.scope()) const serverSDK = useServerSDK()
const workspaceKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(params.dir))) const scope = createMemo(() => serverSDK().scope)
const sessionKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(params.dir, params.id))) const directory = createMemo(() => base64Encode(sdk().directory))
const workspaceKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(directory())))
const sessionKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(directory(), params.id)))
return { params, sessionKey, workspaceKey } return { params, sessionKey, workspaceKey }
} }
@@ -16,6 +16,7 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useTerminal } from "@/context/terminal" import { useTerminal } from "@/context/terminal"
import { useSDK } from "@/context/sdk"
import { terminalTabLabel } from "@/pages/session/terminal-label" import { terminalTabLabel } from "@/pages/session/terminal-label"
import { createSizing, focusTerminalById } from "@/pages/session/helpers" import { createSizing, focusTerminalById } from "@/pages/session/helpers"
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff" import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
@@ -25,10 +26,11 @@ export function TerminalPanel() {
const delays = [120, 240] const delays = [120, 240]
const layout = useLayout() const layout = useLayout()
const terminal = useTerminal() const terminal = useTerminal()
const sdk = useSDK()
const language = useLanguage() const language = useLanguage()
const command = useCommand() const command = useCommand()
const settings = useSettings() const settings = useSettings()
const { params, workspaceKey, view } = useSessionLayout() const { workspaceKey, view } = useSessionLayout()
const opened = createMemo(() => view().terminal.opened()) const opened = createMemo(() => view().terminal.opened())
const size = createSizing() const size = createSizing()
@@ -122,7 +124,7 @@ export function TerminalPanel() {
}) })
createEffect(() => { createEffect(() => {
const dir = params.dir const dir = sdk().directory
if (!dir) return if (!dir) return
if (!terminal.ready()) return if (!terminal.ready()) return
language.locale() language.locale()
@@ -140,7 +142,7 @@ export function TerminalPanel() {
}) })
const handoff = createMemo(() => { const handoff = createMemo(() => {
const dir = params.dir const dir = sdk().directory
if (!dir) return [] if (!dir) return []
return getTerminalHandoff(workspaceKey()) ?? [] return getTerminalHandoff(workspaceKey()) ?? []
}) })
@@ -62,6 +62,8 @@ import { useSessionKey } from "@/pages/session/session-layout"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events" import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
@@ -260,6 +262,7 @@ export function MessageTimeline(props: {
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
const settings = useSettings() const settings = useSettings()
const tabs = useTabs()
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const { params, sessionKey } = useSessionKey() const { params, sessionKey } = useSessionKey()
@@ -757,12 +760,18 @@ export function MessageTimeline(props: {
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => { const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
if (params.id !== sessionID) return if (params.id !== sessionID) return
const href = (id: string) =>
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id)
if (parentID) { if (parentID) {
navigate(`/${params.dir}/session/${parentID}`) navigate(href(parentID))
return return
} }
if (nextSessionID) { if (nextSessionID) {
navigate(`/${params.dir}/session/${nextSessionID}`) navigate(href(nextSessionID))
return
}
if (params.serverKey) {
tabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
return return
} }
navigate(`/${params.dir}/session`) navigate(`/${params.dir}/session`)
@@ -864,7 +873,9 @@ export function MessageTimeline(props: {
const navigateParent = () => { const navigateParent = () => {
const id = parentID() const id = parentID()
if (!id) return if (!id) return
navigate(`/${params.dir}/session/${id}`) navigate(
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id),
)
} }
function DialogDeleteSession(props: { sessionID: string }) { function DialogDeleteSession(props: { sessionID: string }) {
@@ -1285,7 +1296,9 @@ export function MessageTimeline(props: {
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true, "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
"w-full": true, "w-full": true,
"pb-4": true, "pb-4": true,
"pl-2 pr-3 md:pl-4 md:pr-3": true, "pr-3": true,
"pl-4": settings.general.newLayoutDesigns(),
"pl-2 md:pl-4": !settings.general.newLayoutDesigns(),
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}} }}
> >
@@ -18,6 +18,8 @@ import { createSessionTabs } from "@/pages/session/helpers"
import { extractPromptFromParts } from "@/utils/prompt" import { extractPromptFromParts } from "@/utils/prompt"
import { UserMessage } from "@opencode-ai/sdk/v2" import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { useTabs } from "@/context/tabs"
import { requireServerKey } from "@/utils/session-route"
export type SessionCommandContext = { export type SessionCommandContext = {
navigateMessageByOffset: (offset: number) => void navigateMessageByOffset: (offset: number) => void
@@ -45,6 +47,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const settings = useSettings() const settings = useSettings()
const sync = useSync() const sync = useSync()
const terminal = useTerminal() const terminal = useTerminal()
const sessionTabs = useTabs()
const layout = useLayout() const layout = useLayout()
const navigate = useNavigate() const navigate = useNavigate()
const { params, tabs, view } = useSessionLayout() const { params, tabs, view } = useSessionLayout()
@@ -381,7 +384,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.new"), title: language.t("command.session.new"),
keybind: "mod+shift+s", keybind: "mod+shift+s",
slash: "new", slash: "new",
onSelect: () => navigate(`/${params.dir}/session`), onSelect: () => {
if (params.serverKey) {
sessionTabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
return
}
navigate(`/${params.dir}/session`)
},
}), }),
sessionCommand({ sessionCommand({
id: "session.undo", id: "session.undo",
@@ -0,0 +1,39 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/context/server"
import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./session-route"
describe("session routes", () => {
test("builds and decodes a server-keyed session route", () => {
const server = ServerConnection.Key.make("https://example.com:4096")
const href = sessionHref(server, "session-1")
expect(href).toBe("/server/aHR0cHM6Ly9leGFtcGxlLmNvbTo0MDk2/session/session-1")
expect(requireServerKey(href.split("/")[2])).toBe(server)
})
test("rejects malformed server keys", () => {
expect(() => requireServerKey("not-base64")).toThrow("Invalid server route")
})
test("builds the legacy directory-keyed route", () => {
expect(legacySessionHref("/Users/example/project", "session-1")).toBe(
"/L1VzZXJzL2V4YW1wbGUvcHJvamVjdA/session/session-1",
)
})
test("resolves the root session", async () => {
const sessions: Record<string, { id: string; parentID?: string }> = {
child: { id: "child", parentID: "parent" },
parent: { id: "parent", parentID: "root" },
root: { id: "root" },
}
expect(
await rootSession(sessions.child, async (id) => {
const session = sessions[id]
if (!session) throw new Error(`Missing session: ${id}`)
return session
}),
).toBe(sessions.root)
})
})
+25
View File
@@ -0,0 +1,25 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { ServerConnection } from "@/context/server"
import { decode64 } from "@/utils/base64"
export function sessionHref(server: ServerConnection.Key, sessionID: string) {
return `/server/${base64Encode(server)}/session/${sessionID}`
}
export function legacySessionHref(directory: string, sessionID: string) {
return `/${base64Encode(directory)}/session/${sessionID}`
}
export function requireServerKey(segment: string | undefined) {
const key = decode64(segment)
if (!key || base64Encode(key) !== segment) throw new Error("Invalid server route")
return ServerConnection.Key.make(key)
}
type SessionParent = { id: string; parentID?: string }
export async function rootSession(session: SessionParent, get: (sessionID: string) => Promise<SessionParent>) {
let current = session
while (current.parentID) current = await get(current.parentID)
return current
}
+5 -3
View File
@@ -27,9 +27,11 @@ export default Runtime.handler(
function listen(hostname: string, port: Option.Option<number>, password: string) { function listen(hostname: string, port: Option.Option<number>, password: string) {
if (Option.isSome(port)) return bind(hostname, port.value, password) if (Option.isSome(port)) return bind(hostname, port.value, password)
// Preserve the familiar default when available, but let the OS choose a free const next = (port: number): ReturnType<typeof bind> =>
// port when another local server already owns 4096. bind(hostname, port, password).pipe(
return bind(hostname, 4096, password).pipe(Effect.catch(() => bind(hostname, 0, password))) Effect.catch((error) => (port === 65_535 ? Effect.fail(error) : next(port + 1))),
)
return next(4096)
} }
function bind(hostname: string, port: number, password: string) { function bind(hostname: string, port: number, password: string) {
@@ -196,6 +196,13 @@ export async function handler(
Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => { Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
headers.set(k, headers.get(v)!) headers.set(k, headers.get(v)!)
}) })
Object.entries(providerInfo.headerModifier ?? {}).forEach(([k, v]) => {
if (v === "$ip") return headers.set(k, ip)
if (v === "$session") return headers.set(k, sessionId)
if (v === "$model") return headers.set(k, model)
if (v === "$request") return headers.set(k, requestId)
headers.set(k, v)
})
headers.delete("host") headers.delete("host")
headers.delete("content-length") headers.delete("content-length")
headers.delete("x-opencode-request") headers.delete("x-opencode-request")
+1
View File
@@ -53,6 +53,7 @@ export namespace ZenData {
apiKey: z.union([z.string(), z.record(z.string(), z.string())]), apiKey: z.union([z.string(), z.record(z.string(), z.string())]),
format: FormatSchema.optional(), format: FormatSchema.optional(),
headerMappings: z.record(z.string(), z.string()).optional(), headerMappings: z.record(z.string(), z.string()).optional(),
headerModifier: z.record(z.string(), z.any()).optional(),
payloadModifier: z.record(z.string(), z.any()).optional(), payloadModifier: z.record(z.string(), z.any()).optional(),
payloadMappings: z.record(z.string(), z.string()).optional(), payloadMappings: z.record(z.string(), z.string()).optional(),
adjustCacheUsage: z.boolean().optional(), adjustCacheUsage: z.boolean().optional(),
+2
View File
@@ -0,0 +1,2 @@
[test]
preload = ["./test/preload.ts"]
+1
View File
@@ -91,6 +91,7 @@
"@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*", "@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/llm": "workspace:*", "@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opentelemetry/api": "1.9.0", "@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/context-async-hooks": "2.6.1",
"@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/exporter-trace-otlp-http": "0.214.0",
+2 -32
View File
@@ -1,8 +1,8 @@
{ {
"version": "7", "version": "7",
"dialect": "sqlite", "dialect": "sqlite",
"id": "169a0f0f-d58f-479f-b024-fa1c7b9a09db", "id": "f14a9b18-8207-487e-a3d3-227e629ba9ad",
"prevIds": ["abd2f920-b822-49af-b8a7-2e48367d424f"], "prevIds": ["169a0f0f-d58f-479f-b024-fa1c7b9a09db"],
"ddl": [ "ddl": [
{ {
"name": "workspace", "name": "workspace",
@@ -900,16 +900,6 @@
"entityType": "columns", "entityType": "columns",
"table": "session_context_epoch" "table": "session_context_epoch"
}, },
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "'build'",
"generated": null,
"name": "agent",
"entityType": "columns",
"table": "session_context_epoch"
},
{ {
"type": "text", "type": "text",
"notNull": true, "notNull": true,
@@ -930,26 +920,6 @@
"entityType": "columns", "entityType": "columns",
"table": "session_context_epoch" "table": "session_context_epoch"
}, },
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "replacement_seq",
"entityType": "columns",
"table": "session_context_epoch"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "revision",
"entityType": "columns",
"table": "session_context_epoch"
},
{ {
"type": "text", "type": "text",
"notNull": false, "notNull": false,
+18 -4
View File
@@ -45,15 +45,17 @@ async function generate() {
if (await Bun.file(target).exists()) throw new Error(`Database migration already exists: ${name}`) if (await Bun.file(target).exists()) throw new Error(`Database migration already exists: ${name}`)
await Bun.write( await Bun.write(
target, target,
await formatTypescript(
renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()), renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()),
),
) )
await fs.copyFile(path.join(incremental, name, "snapshot.json"), snapshot) await fs.copyFile(path.join(incremental, name, "snapshot.json"), snapshot)
} }
await fs.mkdir(full) await fs.mkdir(full)
await drizzle(temporary, full, "schema") await drizzle(temporary, full, "schema")
await Bun.write(schema, renderSchema(await generatedSql(full))) await Bun.write(schema, await formatTypescript(renderSchema(await generatedSql(full))))
await Bun.write(registry, renderRegistry(await typescriptMigrations())) await Bun.write(registry, await formatTypescript(renderRegistry(await typescriptMigrations())))
} finally { } finally {
await fs.rm(temporary, { recursive: true, force: true }) await fs.rm(temporary, { recursive: true, force: true })
} }
@@ -76,12 +78,12 @@ async function check() {
await fs.mkdir(full) await fs.mkdir(full)
await drizzle(temporary, full, "schema") await drizzle(temporary, full, "schema")
if ((await Bun.file(schema).text()) !== renderSchema(await generatedSql(full))) { if ((await Bun.file(schema).text()) !== (await formatTypescript(renderSchema(await generatedSql(full))))) {
throw new Error("Current database schema is stale. Run `bun script/migration.ts` from packages/core.") throw new Error("Current database schema is stale. Run `bun script/migration.ts` from packages/core.")
} }
const migrations = await typescriptMigrations() const migrations = await typescriptMigrations()
if ((await Bun.file(registry).text()) !== renderRegistry(migrations)) { if ((await Bun.file(registry).text()) !== (await formatTypescript(renderRegistry(migrations)))) {
throw new Error("Database migration registry is stale. Run `bun script/migration.ts` from packages/core.") throw new Error("Database migration registry is stale. Run `bun script/migration.ts` from packages/core.")
} }
} finally { } finally {
@@ -170,6 +172,18 @@ function escapeTemplate(line: string) {
return line.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${") return line.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${")
} }
async function formatTypescript(input: string) {
const prettier = await import("prettier")
const typescript = await import("prettier/plugins/typescript")
const estree = await import("prettier/plugins/estree")
return prettier.format(input, {
parser: "typescript",
plugins: [typescript.default, estree.default],
semi: false,
printWidth: 120,
})
}
function renderRegistry(names: string[]) { function renderRegistry(names: string[]) {
return `import type { DatabaseMigration } from "./migration" return `import type { DatabaseMigration } from "./migration"
+9 -14
View File
@@ -1,7 +1,6 @@
export * as AgentV2 from "./agent" export * as AgentV2 from "./agent"
import { Array, Context, Effect, Layer, Schema, Scope } from "effect" import { Array, Context, Effect, Layer, Schema, Scope, Types } from "effect"
import { castDraft, enableMapSet, type Draft } from "immer"
import { ModelV2 } from "./model" import { ModelV2 } from "./model"
import { PermissionSchema } from "./permission/schema" import { PermissionSchema } from "./permission/schema"
import { ProviderV2 } from "./provider" import { ProviderV2 } from "./provider"
@@ -49,21 +48,19 @@ export interface Selection {
} }
type Data = { type Data = {
agents: Map<ID, Info> agents: Map<ID, Types.DeepMutable<Info>>
default?: ID default?: ID
} }
export type Editor = { export type Draft = {
list: () => readonly Info[] list: () => readonly Info[]
get: (id: ID) => Info | undefined get: (id: ID) => Info | undefined
default: (id: ID | undefined) => void default: (id: ID | undefined) => void
update: (id: ID, fn: (agent: Draft<Info>) => void) => void update: (id: ID, fn: (agent: Types.DeepMutable<Info>) => void) => void
remove: (id: ID) => void remove: (id: ID) => void
} }
export interface Interface { export interface Interface extends State.Transformable<Draft> {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly update: State.Interface<Data, Editor>["update"]
readonly get: (id: ID) => Effect.Effect<Info | undefined> readonly get: (id: ID) => Effect.Effect<Info | undefined>
readonly default: () => Effect.Effect<Info | undefined> readonly default: () => Effect.Effect<Info | undefined>
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined> readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
@@ -73,21 +70,19 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {}
enableMapSet()
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const state = State.create<Data, Editor>({ const state = State.create<Data, Draft>({
initial: () => ({ agents: new Map() }), initial: () => ({ agents: new Map() }),
editor: (draft) => ({ draft: (draft) => ({
list: () => Array.fromIterable(draft.agents.values()) as Info[], list: () => Array.fromIterable(draft.agents.values()) as Info[],
get: (id) => draft.agents.get(id), get: (id) => draft.agents.get(id),
default: (id) => { default: (id) => {
draft.default = id draft.default = id
}, },
update: (id, fn) => { update: (id, fn) => {
const current = draft.agents.get(id) ?? castDraft(Info.empty(id)) const current = draft.agents.get(id) ?? (Info.empty(id) as Types.DeepMutable<Info>)
if (!draft.agents.has(id)) draft.agents.set(id, current) if (!draft.agents.has(id)) draft.agents.set(id, current)
fn(current) fn(current)
current.id = id current.id = id
@@ -113,7 +108,7 @@ export const layer = Layer.effect(
return Service.of({ return Service.of({
transform: state.transform, transform: state.transform,
update: state.update, reload: state.reload,
get: Effect.fn("AgentV2.get")(function* (id) { get: Effect.fn("AgentV2.get")(function* (id) {
return state.get().agents.get(id) return state.get().agents.get(id)
}), }),
+74 -21
View File
@@ -1,14 +1,27 @@
export * as AISDK from "./aisdk" export * as AISDK from "./aisdk"
import type { LanguageModelV3 } from "@ai-sdk/provider" import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Cause, Context, Effect, Layer, Schema } from "effect" import { Cause, Context, Effect, Layer, Schema, Scope } from "effect"
import { ModelV2 } from "./model" import { ModelV2 } from "./model"
import { EventV2 } from "./event"
import { PluginV2 } from "./plugin"
import { ProviderV2 } from "./provider" import { ProviderV2 } from "./provider"
import { State } from "./state"
type SDK = any type SDK = any
export interface SDKEvent {
readonly model: ModelV2.Info
readonly package: string
readonly options: Record<string, any>
sdk?: SDK
}
export interface LanguageEvent {
readonly model: ModelV2.Info
readonly sdk: SDK
readonly options: Record<string, any>
language?: LanguageModelV3
}
function wrapSSE(res: Response, ms: number, ctl: AbortController) { function wrapSSE(res: Response, ms: number, ctl: AbortController) {
if (typeof ms !== "number" || ms <= 0) return res if (typeof ms !== "number" || ms <= 0) return res
if (!res.body) return res if (!res.body) return res
@@ -117,19 +130,70 @@ function initError(providerID: ProviderV2.ID) {
} }
export interface Interface { export interface Interface {
readonly hook: {
readonly sdk: (
callback: (event: SDKEvent) => Effect.Effect<void> | void,
) => Effect.Effect<State.Registration, never, Scope.Scope>
readonly language: (
callback: (event: LanguageEvent) => Effect.Effect<void> | void,
) => Effect.Effect<State.Registration, never, Scope.Scope>
}
readonly runSDK: (event: SDKEvent) => Effect.Effect<SDKEvent>
readonly runLanguage: (event: LanguageEvent) => Effect.Effect<LanguageEvent>
readonly language: (model: ModelV2.Info) => Effect.Effect<LanguageModelV3, InitError> readonly language: (model: ModelV2.Info) => Effect.Effect<LanguageModelV3, InitError>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/AISDK") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/AISDK") {}
export const layer = Layer.effect( export const locationLayer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service let sdkHooks: ((event: SDKEvent) => Effect.Effect<void> | void)[] = []
let languageHooks: ((event: LanguageEvent) => Effect.Effect<void> | void)[] = []
const languages = new Map<string, LanguageModelV3>() const languages = new Map<string, LanguageModelV3>()
const sdks = new Map<string, SDK>() const sdks = new Map<string, SDK>()
return Service.of({ const register = <Event>(
hooks: () => ((event: Event) => Effect.Effect<void> | void)[],
update: (hooks: ((event: Event) => Effect.Effect<void> | void)[]) => void,
) =>
Effect.fn("AISDK.hook")(function* (callback: (event: Event) => Effect.Effect<void> | void) {
const scope = yield* Scope.Scope
let active = true
update([...hooks(), callback])
const dispose = Effect.sync(() => {
if (!active) return
active = false
update(hooks().filter((item) => item !== callback))
})
yield* Scope.addFinalizer(scope, dispose)
return { dispose }
})
const run = Effect.fnUntraced(function* <Event>(
hooks: readonly ((event: Event) => Effect.Effect<void> | void)[],
event: Event,
) {
for (const hook of hooks) {
const result = hook(event)
if (Effect.isEffect(result)) yield* result
}
return event
})
const service = Service.of({
hook: {
sdk: register(
() => sdkHooks,
(next) => (sdkHooks = next),
),
language: register(
() => languageHooks,
(next) => (languageHooks = next),
),
},
runSDK: (event) => run(sdkHooks, event),
runLanguage: (event) => run(languageHooks, event),
language: Effect.fn("AISDK.language")(function* (model) { language: Effect.fn("AISDK.language")(function* (model) {
const key = `${model.providerID}/${model.id}/${model.request.variant ?? "default"}` const key = `${model.providerID}/${model.id}/${model.request.variant ?? "default"}`
const existing = languages.get(key) const existing = languages.get(key)
@@ -148,26 +212,14 @@ export const layer = Layer.effect(
}) })
const sdk = const sdk =
sdks.get(sdkKey) ?? sdks.get(sdkKey) ??
(yield* plugin (yield* service.runSDK({ model, package: model.api.package, options }).pipe(initError(model.providerID))).sdk
.trigger("aisdk.sdk", { model, package: model.api.package, options }, {})
.pipe(initError(model.providerID))).sdk
if (!sdk) if (!sdk)
return yield* new InitError({ return yield* new InitError({
providerID: model.providerID, providerID: model.providerID,
cause: new Error("No AISDK provider plugin returned an SDK"), cause: new Error("No AISDK provider plugin returned an SDK"),
}) })
sdks.set(sdkKey, sdk) sdks.set(sdkKey, sdk)
const result = yield* plugin const result = yield* service.runLanguage({ model, sdk, options }).pipe(initError(model.providerID))
.trigger(
"aisdk.language",
{
model,
sdk,
options,
},
{},
)
.pipe(initError(model.providerID))
const language = yield* Effect.sync(() => result.language ?? sdk.languageModel(model.api.id)).pipe( const language = yield* Effect.sync(() => result.language ?? sdk.languageModel(model.api.id)).pipe(
initError(model.providerID), initError(model.providerID),
) )
@@ -175,7 +227,8 @@ export const layer = Layer.effect(
return language return language
}), }),
}) })
return service
}), }),
) )
export const defaultLayer = layer.pipe(Layer.provide(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))) export const defaultLayer = locationLayer
+49 -96
View File
@@ -1,36 +1,21 @@
export * as Catalog from "./catalog" export * as Catalog from "./catalog"
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema, Scope, Stream } from "effect" import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect"
import { castDraft, enableMapSet, type Draft } from "immer"
import { ModelV2 } from "./model" import { ModelV2 } from "./model"
import { ModelRequest } from "./model-request" import { ModelRequest } from "./model-request"
import { PluginV2 } from "./plugin"
import { ProviderV2 } from "./provider" import { ProviderV2 } from "./provider"
import { Location } from "./location"
import { EventV2 } from "./event" import { EventV2 } from "./event"
import { Policy } from "./policy" import { Policy } from "./policy"
import { State } from "./state" import { State } from "./state"
import { Integration } from "./integration" import { Integration } from "./integration"
export type ProviderRecord = { export type ProviderRecord = {
provider: ProviderV2.Info provider: ProviderV2.MutableInfo
models: Map<ModelV2.ID, ModelV2.Info> models: Map<ModelV2.ID, ModelV2.MutableInfo>
} }
export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID } export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID }
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
"CatalogV2.ProviderNotFound",
{
providerID: ProviderV2.ID,
},
) {}
export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundError>()("CatalogV2.ModelNotFound", {
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
}) {}
export const PolicyActions = Schema.Literals(["provider.use"]) export const PolicyActions = Schema.Literals(["provider.use"])
export const Event = { export const Event = {
@@ -42,16 +27,16 @@ type Data = {
defaultModel?: DefaultModel defaultModel?: DefaultModel
} }
export type Editor = { export type Draft = {
provider: { provider: {
list: () => readonly ProviderRecord[] list: () => readonly ProviderRecord[]
get: (providerID: ProviderV2.ID) => ProviderRecord | undefined get: (providerID: ProviderV2.ID) => ProviderRecord | undefined
update: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => void update: (providerID: ProviderV2.ID, fn: (provider: ProviderV2.MutableInfo) => void) => void
remove: (providerID: ProviderV2.ID) => void remove: (providerID: ProviderV2.ID) => void
} }
model: { model: {
get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => ModelV2.Info | undefined get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => ModelV2.Info | undefined
update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft<ModelV2.Info>) => void) => void update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: ModelV2.MutableInfo) => void) => void
remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
default: { default: {
get: () => DefaultModel | undefined get: () => DefaultModel | undefined
@@ -60,43 +45,34 @@ export type Editor = {
} }
} }
export interface Interface { export interface Interface extends State.Transformable<Draft> {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly provider: { readonly provider: {
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info, ProviderNotFoundError> readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info | undefined>
readonly all: () => Effect.Effect<ProviderV2.Info[]> readonly all: () => Effect.Effect<ProviderV2.Info[]>
readonly available: () => Effect.Effect<ProviderV2.Info[]> readonly available: () => Effect.Effect<ProviderV2.Info[]>
} }
readonly model: { readonly model: {
readonly get: ( readonly get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => Effect.Effect<ModelV2.Info | undefined>
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
) => Effect.Effect<ModelV2.Info, ProviderNotFoundError | ModelNotFoundError>
readonly all: () => Effect.Effect<ModelV2.Info[]> readonly all: () => Effect.Effect<ModelV2.Info[]>
readonly available: () => Effect.Effect<ModelV2.Info[]> readonly available: () => Effect.Effect<ModelV2.Info[]>
readonly default: () => Effect.Effect<Option.Option<ModelV2.Info>> readonly default: () => Effect.Effect<ModelV2.Info | undefined>
readonly small: (providerID: ProviderV2.ID) => Effect.Effect<Option.Option<ModelV2.Info>> readonly small: (providerID: ProviderV2.ID) => Effect.Effect<ModelV2.Info | undefined>
} }
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Catalog") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Catalog") {}
enableMapSet()
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const location = yield* Location.Service
const plugin = yield* PluginV2.Service
const events = yield* EventV2.Service const events = yield* EventV2.Service
const policy = yield* Policy.Service const policy = yield* Policy.Service
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
const scope = yield* Scope.Scope
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined, connected: boolean) => { const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => {
if (provider.disabled) return false if (provider.disabled) return false
if (typeof provider.request.body.apiKey === "string") return true if (typeof provider.request.body.apiKey === "string") return true
if (connected) return true if (integration?.connections.length) return true
return !integration return !integration
} }
@@ -120,32 +96,26 @@ export const layer = Layer.effect(
}) })
} }
function* getRecord(providerID: ProviderV2.ID) { const normalizeApi = (item: ProviderV2.MutableInfo | ModelV2.MutableInfo) => {
const match = state.get().providers.get(providerID)
if (!match) return yield* new ProviderNotFoundError({ providerID })
return match
}
const normalizeApi = (item: Draft<ProviderV2.Info> | Draft<ModelV2.Info>) => {
if (typeof item.request.body.baseURL !== "string") return if (typeof item.request.body.baseURL !== "string") return
item.api.url = item.request.body.baseURL item.api.url = item.request.body.baseURL
delete item.request.body.baseURL delete item.request.body.baseURL
} }
const state = State.create<Data, Editor>({ const state = State.create<Data, Draft>({
initial: () => ({ providers: new Map() }), initial: () => ({ providers: new Map() }),
editor: (draft) => { draft: (draft) => {
const result: Editor = { const result: Draft = {
provider: { provider: {
list: () => Array.fromIterable(draft.providers.values()) as ProviderRecord[], list: () => Array.fromIterable(draft.providers.values()) as ProviderRecord[],
get: (providerID) => draft.providers.get(providerID), get: (providerID) => draft.providers.get(providerID),
update: (providerID, fn) => { update: (providerID, fn) => {
let current = draft.providers.get(providerID) let current = draft.providers.get(providerID)
if (!current) { if (!current) {
current = castDraft({ current = {
provider: ProviderV2.Info.empty(providerID), provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo,
models: new Map<ModelV2.ID, ModelV2.Info>(), models: new Map<ModelV2.ID, ModelV2.MutableInfo>(),
}) }
draft.providers.set(providerID, current) draft.providers.set(providerID, current)
} }
fn(current.provider) fn(current.provider)
@@ -160,13 +130,14 @@ export const layer = Layer.effect(
update: (providerID, modelID, fn) => { update: (providerID, modelID, fn) => {
let record = draft.providers.get(providerID) let record = draft.providers.get(providerID)
if (!record) { if (!record) {
record = castDraft({ record = {
provider: ProviderV2.Info.empty(providerID), provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo,
models: new Map<ModelV2.ID, ModelV2.Info>(), models: new Map<ModelV2.ID, ModelV2.MutableInfo>(),
}) }
draft.providers.set(providerID, record) draft.providers.set(providerID, record)
} }
const model = record.models.get(modelID) ?? castDraft(ModelV2.Info.empty(providerID, modelID)) const model =
record.models.get(modelID) ?? (ModelV2.Info.empty(providerID, modelID) as ModelV2.MutableInfo)
if (!record.models.has(modelID)) record.models.set(modelID, model) if (!record.models.has(modelID)) record.models.set(modelID, model)
fn(model) fn(model)
model.id = modelID model.id = modelID
@@ -186,8 +157,7 @@ export const layer = Layer.effect(
} }
return result return result
}, },
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) { finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) {
if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid)
if (policy.hasStatements()) { if (policy.hasStatements()) {
for (const record of [...catalog.provider.list()]) { for (const record of [...catalog.provider.list()]) {
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") { if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
@@ -198,25 +168,13 @@ export const layer = Layer.effect(
yield* events.publish(Event.Updated, {}) yield* events.publish(Event.Updated, {})
}), }),
}) })
yield* events.subscribe(PluginV2.Event.Added).pipe(
// Plugin registries are location scoped even though the event bus is process scoped.
Stream.filter(
(event) =>
event.location?.directory === location.directory && event.location.workspaceID === location.workspaceID,
),
Stream.runForEach((event) =>
state.mutate((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"),
),
Effect.forkIn(scope, { startImmediately: true }),
)
const result: Interface = { const result: Interface = {
transform: state.transform, transform: state.transform,
reload: state.reload,
provider: { provider: {
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) { get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
const record = yield* getRecord(providerID) return state.get().providers.get(providerID)?.provider
return record.provider
}), }),
all: Effect.fn("CatalogV2.provider.all")(function* () { all: Effect.fn("CatalogV2.provider.all")(function* () {
@@ -225,23 +183,18 @@ export const layer = Layer.effect(
available: Effect.fn("CatalogV2.provider.available")(function* () { available: Effect.fn("CatalogV2.provider.available")(function* () {
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration])) const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
const connections = yield* integrations.connection.list()
return (yield* result.provider.all()).filter((provider) => return (yield* result.provider.all()).filter((provider) =>
available( available(provider, active.get(Integration.ID.make(provider.id))),
provider,
active.get(Integration.ID.make(provider.id)),
connections.has(Integration.ID.make(provider.id)),
),
) )
}), }),
}, },
model: { model: {
get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) { get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) {
const record = yield* getRecord(providerID) const record = state.get().providers.get(providerID)
if (!record) return
const model = record.models.get(modelID) const model = record.models.get(modelID)
if (!model) return yield* new ModelNotFoundError({ providerID, modelID }) return model && projectModel(model, record.provider)
return projectModel(model, record.provider)
}), }),
all: Effect.fn("CatalogV2.model.all")(function* () { all: Effect.fn("CatalogV2.model.all")(function* () {
@@ -250,7 +203,7 @@ export const layer = Layer.effect(
Array.flatMap((record) => { Array.flatMap((record) => {
return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider)) return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider))
}), }),
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), Array.sortWith((item) => item.time.released, Order.flip(Order.Number)),
) )
}), }),
@@ -262,31 +215,30 @@ export const layer = Layer.effect(
default: Effect.fn("CatalogV2.model.default")(function* () { default: Effect.fn("CatalogV2.model.default")(function* () {
const defaultModel = state.get().defaultModel const defaultModel = state.get().defaultModel
if (defaultModel) { if (defaultModel) {
const provider = yield* result.provider.get(defaultModel.providerID).pipe(Effect.option) const provider = yield* result.provider.get(defaultModel.providerID)
if ( if (provider && (yield* result.provider.available()).some((item) => item.id === provider.id)) {
Option.isSome(provider) && const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID)
(yield* result.provider.available()).some((item) => item.id === provider.value.id) if (model?.enabled) return model
) {
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option)
if (Option.isSome(model) && model.value.enabled) return model
} }
} }
return pipe( return Option.getOrUndefined(
pipe(
yield* result.model.available(), yield* result.model.available(),
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), Array.sortWith((item) => item.time.released, Order.flip(Order.Number)),
Array.head, Array.head,
),
) )
}), }),
small: Effect.fn("CatalogV2.model.small")(function* (providerID) { small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
const record = state.get().providers.get(providerID) const record = state.get().providers.get(providerID)
if (!record) return Option.none<ModelV2.Info>() if (!record) return
const provider = record.provider const provider = record.provider
if (providerID === ProviderV2.ID.opencode) { if (providerID === ProviderV2.ID.opencode) {
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano")) const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(projectModel(gpt5Nano, provider)) if (gpt5Nano?.enabled && gpt5Nano.status === "active") return projectModel(gpt5Nano, provider)
} }
const candidates = pipe( const candidates = pipe(
@@ -302,7 +254,7 @@ export const layer = Layer.effect(
Array.map((model) => ({ Array.map((model) => ({
model, model,
cost: model.cost[0] ? model.cost[0].input + model.cost[0].output : 999, cost: model.cost[0] ? model.cost[0].input + model.cost[0].output : 999,
age: (Date.now() - model.time.released.epochMilliseconds) / (1000 * 60 * 60 * 24 * 30), age: (Date.now() - model.time.released) / (1000 * 60 * 60 * 24 * 30),
small: SMALL_MODEL_RE.test(`${model.id} ${model.family ?? ""} ${model.name}`.toLowerCase()), small: SMALL_MODEL_RE.test(`${model.id} ${model.family ?? ""} ${model.name}`.toLowerCase()),
})), })),
Array.filter((item) => item.cost > 0 && item.age <= 18), Array.filter((item) => item.cost > 0 && item.age <= 18),
@@ -319,10 +271,12 @@ export const layer = Layer.effect(
) )
} }
return pipe( return Option.getOrUndefined(
pipe(
candidates, candidates,
Array.filter((item) => item.small), Array.filter((item) => item.small),
(items) => (items.length > 0 ? pick(items) : pick(candidates)), (items) => (items.length > 0 ? pick(items) : pick(candidates)),
),
) )
}), }),
}, },
@@ -336,6 +290,5 @@ const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
export const locationLayer = layer.pipe( export const locationLayer = layer.pipe(
Layer.provideMerge(Integration.locationLayer), Layer.provideMerge(Integration.locationLayer),
Layer.provideMerge(PluginV2.locationLayer),
Layer.provideMerge(Policy.locationLayer), Layer.provideMerge(Policy.locationLayer),
) )
+9 -12
View File
@@ -1,7 +1,6 @@
export * as CommandV2 from "./command" export * as CommandV2 from "./command"
import { Context, Effect, Layer, Schema } from "effect" import { Context, Effect, Layer, Schema, Types } from "effect"
import { castDraft, type Draft } from "immer"
import { ModelV2 } from "./model" import { ModelV2 } from "./model"
import { State } from "./state" import { State } from "./state"
@@ -15,19 +14,17 @@ export class Info extends Schema.Class<Info>("CommandV2.Info")({
}) {} }) {}
export type Data = { export type Data = {
commands: Map<string, Info> commands: Map<string, Types.DeepMutable<Info>>
} }
export type Editor = { export type Draft = {
list: () => readonly Info[] list: () => readonly Info[]
get: (name: string) => Info | undefined get: (name: string) => Info | undefined
update: (name: string, update: (command: Draft<Info>) => void) => void update: (name: string, update: (command: Types.DeepMutable<Info>) => void) => void
remove: (name: string) => void remove: (name: string) => void
} }
export interface Interface { export interface Interface extends State.Transformable<Draft> {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly update: State.Interface<Data, Editor>["update"]
readonly get: (name: string) => Effect.Effect<Info | undefined> readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly list: () => Effect.Effect<Info[]> readonly list: () => Effect.Effect<Info[]>
} }
@@ -37,13 +34,13 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.sync(() => { Effect.sync(() => {
const state = State.create<Data, Editor>({ const state = State.create<Data, Draft>({
initial: () => ({ commands: new Map() }), initial: () => ({ commands: new Map() }),
editor: (draft) => ({ draft: (draft) => ({
list: () => Array.from(draft.commands.values()) as Info[], list: () => Array.from(draft.commands.values()) as Info[],
get: (name) => draft.commands.get(name), get: (name) => draft.commands.get(name),
update: (name, update) => { update: (name, update) => {
const current = draft.commands.get(name) ?? castDraft(new Info({ name, template: "" })) const current = draft.commands.get(name) ?? (new Info({ name, template: "" }) as Types.DeepMutable<Info>)
if (!draft.commands.has(name)) draft.commands.set(name, current) if (!draft.commands.has(name)) draft.commands.set(name, current)
update(current) update(current)
current.name = name current.name = name
@@ -55,7 +52,7 @@ export const layer = Layer.effect(
}) })
return Service.of({ return Service.of({
update: state.update, reload: state.reload,
transform: state.transform, transform: state.transform,
get: Effect.fn("CommandV2.get")(function* (name) { get: Effect.fn("CommandV2.get")(function* (name) {
return state.get().commands.get(name) return state.get().commands.get(name)
+14 -14
View File
@@ -1,5 +1,6 @@
export * as ConfigAgentPlugin from "./agent" export * as ConfigAgentPlugin from "./agent"
import { define } from "../../plugin/internal"
import path from "path" import path from "path"
import { Effect, Option, Schema } from "effect" import { Effect, Option, Schema } from "effect"
import { AgentV2 } from "../../agent" import { AgentV2 } from "../../agent"
@@ -8,7 +9,6 @@ import { ConfigAgent } from "../agent"
import { ConfigMarkdown } from "../markdown" import { ConfigMarkdown } from "../markdown"
import { FSUtil } from "../../fs-util" import { FSUtil } from "../../fs-util"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
import { PluginV2 } from "../../plugin"
import { ConfigAgentV1 } from "../../v1/config/agent" import { ConfigAgentV1 } from "../../v1/config/agent"
import { ConfigMigrateV1 } from "../../v1/config/migrate" import { ConfigMigrateV1 } from "../../v1/config/migrate"
@@ -33,12 +33,13 @@ const agentKeys = new Set([
"permissions", "permissions",
]) ])
export const Plugin = PluginV2.define({ export const Plugin = define({
id: PluginV2.ID.make("config-agent"), id: "config-agent",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
const agent = yield* AgentV2.Service
const config = yield* Config.Service const config = yield* Config.Service
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
yield* ctx.agent.transform(
Effect.fn(function* (draft) {
const documents = 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* () {
@@ -55,25 +56,23 @@ export const Plugin = PluginV2.define({
) )
}) })
}).pipe(Effect.map((documents) => documents.flat())) }).pipe(Effect.map((documents) => documents.flat()))
yield* agent.update((editor) => {
const global = documents.flatMap((document) => document.info.permissions ?? []) const global = documents.flatMap((document) => document.info.permissions ?? [])
const configuredDefault = Config.latest(documents, "default_agent") const configuredDefault = Config.latest(documents, "default_agent")
if (configuredDefault !== undefined) editor.default(AgentV2.ID.make(configuredDefault)) if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault))
for (const current of editor.list()) { for (const current of draft.list()) {
editor.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 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) {
editor.remove(agentID) draft.remove(agentID)
continue continue
} }
const exists = editor.get(agentID) !== undefined const exists = draft.get(agentID) !== undefined
editor.update(agentID, (agent) => { draft.update(agentID, (agent) => {
if (!exists) agent.permissions.push(...global) if (!exists) agent.permissions.push(...global)
if (item.model !== undefined) { if (item.model !== undefined) {
const model = ModelV2.parse(item.model) const model = ModelV2.parse(item.model)
@@ -96,7 +95,8 @@ export const Plugin = PluginV2.define({
}) })
} }
} }
}) }),
)
}), }),
}) })
+9 -10
View File
@@ -1,24 +1,24 @@
export * as ConfigCommandPlugin from "./command" export * as ConfigCommandPlugin from "./command"
import { define } from "../../plugin/internal"
import path from "path" import path from "path"
import { Effect, Option, Schema } from "effect" import { Effect, Option, Schema } 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"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
import { PluginV2 } from "../../plugin"
import { ConfigCommand } from "../command" import { ConfigCommand } from "../command"
import { ConfigMarkdown } from "../markdown" import { ConfigMarkdown } from "../markdown"
const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info) const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info)
export const Plugin = PluginV2.define({ export const Plugin = define({
id: PluginV2.ID.make("config-command"), id: "config-command",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
const command = yield* CommandV2.Service
const config = yield* Config.Service const config = yield* Config.Service
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const transform = yield* command.transform() yield* ctx.command.transform(
Effect.fn(function* (draft) {
const documents = 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(
@@ -27,11 +27,9 @@ export const Plugin = PluginV2.define({
]), ]),
) )
}).pipe(Effect.map((documents) => documents.flat())) }).pipe(Effect.map((documents) => documents.flat()))
yield* transform((editor) => {
for (const document of documents) { for (const document of documents) {
for (const [name, command] of Object.entries(document.commands ?? {})) { for (const [name, command] of Object.entries(document.commands ?? {})) {
editor.update(name, (item) => { draft.update(name, (item) => {
item.template = command.template item.template = command.template
if (command.description !== undefined) item.description = command.description if (command.description !== undefined) item.description = command.description
if (command.agent !== undefined) item.agent = command.agent if (command.agent !== undefined) item.agent = command.agent
@@ -46,7 +44,8 @@ export const Plugin = PluginV2.define({
}) })
} }
} }
}) }),
)
}), }),
}) })
@@ -0,0 +1,91 @@
export * as ConfigExternalPlugin from "./external"
import type { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
import type { Plugin as PromisePlugin } from "@opencode-ai/plugin/v2/promise"
import { Effect, Schema } from "effect"
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import { Config } from "../../config"
import { FSUtil } from "../../fs-util"
import { Location } from "../../location"
import { Npm } from "../../npm"
import { define } from "../../plugin/internal"
import { PluginPromise } from "../../plugin/promise"
const PluginModule = Schema.Struct({
default: Schema.Union([
Schema.Struct({
id: Schema.String,
effect: Schema.declare<EffectPlugin["effect"]>(
(input): input is EffectPlugin["effect"] => typeof input === "function",
),
}),
Schema.Struct({
id: Schema.String,
setup: Schema.declare<PromisePlugin["setup"]>(
(input): input is PromisePlugin["setup"] => typeof input === "function",
),
}),
]),
})
export const Plugin = define({
id: "config-plugin",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const npm = yield* Npm.Service
yield* Effect.gen(function* () {
const configured: { package: string; options?: Record<string, any> }[] = []
for (const entry of yield* config.entries()) {
if (entry.type === "document") {
const directory = entry.path ? path.dirname(entry.path) : location.directory
for (const item of entry.info.plugins ?? []) {
const ref = typeof item === "string" ? { package: item } : item
const packageName = (() => {
if (ref.package.startsWith("file://")) return fileURLToPath(ref.package)
if (ref.package.startsWith("./") || ref.package.startsWith("../")) {
return path.resolve(directory, ref.package)
}
return ref.package
})()
configured.push({ package: packageName, options: ref.options })
}
}
if (entry.type === "directory") {
const files = yield* fs
.glob("{plugin,plugins}/*.{ts,js}", {
cwd: entry.path,
absolute: true,
include: "file",
dot: true,
symlink: true,
})
.pipe(Effect.orElseSucceed(() => []))
files.sort()
for (const file of files) configured.push({ package: file })
}
}
for (const ref of configured) {
yield* Effect.gen(function* () {
const entrypoint = path.isAbsolute(ref.package)
? pathToFileURL(ref.package).href
: (yield* npm.add(ref.package)).entrypoint
if (!entrypoint) return
const mod = yield* Effect.promise(() => import(entrypoint))
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
yield* ctx.plugin.add({
id: plugin.id,
effect: (host) => plugin.effect({ ...host, options: ref.options ?? {} }),
})
}).pipe(Effect.ignoreCause)
}
}).pipe(Effect.forkScoped({ startImmediately: true }))
}),
})
+21 -20
View File
@@ -1,33 +1,29 @@
export * as ConfigProviderPlugin from "./provider" export * as ConfigProviderPlugin from "./provider"
import { define } from "../../plugin/internal"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "../../catalog"
import { Config } from "../../config" import { Config } from "../../config"
import { Integration } from "../../integration"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
import { ModelRequest } from "../../model-request" import { ModelRequest } from "../../model-request"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
export const Plugin = PluginV2.define({ export const Plugin = define({
id: PluginV2.ID.make("config-provider"), id: "config-provider",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
const catalog = yield* Catalog.Service
const config = yield* Config.Service const config = yield* Config.Service
const integrations = yield* Integration.Service yield* ctx.integration.transform(
const transform = yield* catalog.transform() Effect.fn(function* (integrations) {
const integrationTransform = yield* integrations.transform() const files = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")
const entries = yield* config.entries()
const files = 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]) => (provider.env === undefined ? [] : [id])), Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) =>
provider.env === undefined ? [] : [id],
),
), ),
) )
yield* integrationTransform((integrations) => {
for (const file of files) { for (const file of files) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) { for (const [id, item] of Object.entries(file.info.providers ?? {})) {
const integrationID = Integration.ID.make(id) const integrationID = id
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
integrations.update(integrationID, (integration) => { integrations.update(integrationID, (integration) => {
integration.name = item.name ?? integration.name integration.name = item.name ?? integration.name
@@ -40,9 +36,13 @@ export const Plugin = PluginV2.define({
} }
} }
} }
}) }),
)
yield* transform((catalog) => { yield* ctx.catalog.transform(
Effect.fn(function* (catalog) {
const entries = yield* config.entries()
const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
const configuredDefault = Config.latest(entries, "model") const configuredDefault = Config.latest(entries, "model")
if (configuredDefault !== undefined) { if (configuredDefault !== undefined) {
const model = ModelV2.parse(configuredDefault) const model = ModelV2.parse(configuredDefault)
@@ -50,7 +50,7 @@ export const Plugin = PluginV2.define({
} }
for (const file of files) { for (const file of files) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) { for (const [id, item] of Object.entries(file.info.providers ?? {})) {
const providerID = ProviderV2.ID.make(id) const providerID = id
catalog.provider.update(providerID, (provider) => { catalog.provider.update(providerID, (provider) => {
if (item.name !== undefined) provider.name = item.name if (item.name !== undefined) provider.name = item.name
if (item.api !== undefined) provider.api = { ...item.api } if (item.api !== undefined) provider.api = { ...item.api }
@@ -63,7 +63,7 @@ export const Plugin = PluginV2.define({
const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined
for (const [id, config] of Object.entries(item.models ?? {})) { for (const [id, config] of Object.entries(item.models ?? {})) {
catalog.model.update(providerID, ModelV2.ID.make(id), (model) => { catalog.model.update(providerID, id, (model) => {
if (config.family !== undefined) model.family = config.family if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name if (config.name !== undefined) model.name = config.name
if (config.api !== undefined) model.api = { ...model.api, ...config.api } if (config.api !== undefined) model.api = { ...model.api, ...config.api }
@@ -118,6 +118,7 @@ export const Plugin = PluginV2.define({
} }
} }
} }
}) }),
)
}), }),
}) })
+13 -14
View File
@@ -1,23 +1,23 @@
export * as ConfigReferencePlugin from "./reference" export * as ConfigReferencePlugin from "./reference"
import { define } from "../../plugin/internal"
import path from "path" import path from "path"
import { Effect } from "effect" import { Effect } from "effect"
import { Config } from "../../config" import { Config } from "../../config"
import { ConfigReference } from "../reference" import { ConfigReference } from "../reference"
import { Global } from "../../global"
import { Location } from "../../location"
import { PluginV2 } from "../../plugin"
import { Reference } from "../../reference" import { Reference } from "../../reference"
import { AbsolutePath } from "../../schema" import { AbsolutePath } from "../../schema"
import { Global } from "../../global"
import { Location } from "../../location"
export const Plugin = { export const Plugin = define({
id: PluginV2.ID.make("core/config-reference"), id: "core/config-reference",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service const config = yield* Config.Service
const global = yield* Global.Service
const location = yield* Location.Service const location = yield* Location.Service
const references = yield* Reference.Service const global = yield* Global.Service
const update = yield* references.transform() yield* ctx.reference.transform(
Effect.fn(function* (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 (yield* config.entries()).filter(
(entry): entry is Config.Document => entry.type === "document", (entry): entry is Config.Document => entry.type === "document",
@@ -46,12 +46,11 @@ export const Plugin = {
) )
} }
} }
for (const [name, source] of entries) draft.add(name, source)
yield* update((editor) => {
for (const [name, source] of entries) editor.add(name, source)
})
}), }),
} )
}),
})
function validAlias(name: string) { function validAlias(name: string) {
return name.length > 0 && !/[\/\s`,]/.test(name) return name.length > 0 && !/[\/\s`,]/.test(name)
+14 -15
View File
@@ -1,48 +1,47 @@
export * as ConfigSkillPlugin from "./skill" export * as ConfigSkillPlugin from "./skill"
import { define } from "../../plugin/internal"
import path from "path" import path from "path"
import { Effect } from "effect" import { Effect } from "effect"
import { Config } from "../../config" import { Config } from "../../config"
import { Global } from "../../global"
import { Location } from "../../location"
import { PluginV2 } from "../../plugin"
import { AbsolutePath } from "../../schema" import { AbsolutePath } from "../../schema"
import { SkillV2 } from "../../skill" import { SkillV2 } from "../../skill"
import { Global } from "../../global"
import { Location } from "../../location"
export const Plugin = PluginV2.define({ export const Plugin = define({
id: PluginV2.ID.make("config-skill"), id: "config-skill",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
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
const skill = yield* SkillV2.Service yield* ctx.skill.transform(
const transform = yield* skill.transform() Effect.fn(function* (draft) {
const entries = yield* config.entries() const entries = yield* config.entries()
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : [])) const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
yield* transform((editor) => {
for (const directory of directories) { for (const directory of directories) {
editor.source( draft.source(
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }), new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
) )
editor.source( draft.source(
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }), new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
) )
} }
for (const item of items) { for (const item of items) {
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) { if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
editor.source(new SkillV2.UrlSource({ type: "url", url: item })) draft.source(new SkillV2.UrlSource({ type: "url", url: item }))
continue continue
} }
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
editor.source( draft.source(
new SkillV2.DirectorySource({ new SkillV2.DirectorySource({
type: "directory", type: "directory",
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)), path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
}), }),
) )
} }
}) }),
)
}), }),
}) })
+14 -14
View File
@@ -29,33 +29,33 @@ export class Key extends Schema.Class<Key>("Credential.Key")({
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {} }) {}
export const Info = Schema.Union([OAuth, Key]) export const Value = Schema.Union([OAuth, Key])
.pipe(Schema.toTaggedUnion("type")) .pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Credential.Info" }) .annotate({ identifier: "Credential.Value" })
export type Info = Schema.Schema.Type<typeof Info> export type Value = Schema.Schema.Type<typeof Value>
export class Stored extends Schema.Class<Stored>("Credential.Stored")({ export class Info extends Schema.Class<Info>("Credential.Info")({
id: ID, id: ID,
integrationID: IntegrationSchema.ID, integrationID: IntegrationSchema.ID,
label: Schema.String, label: Schema.String,
value: Info, value: Value,
}) {} }) {}
export interface Interface { export interface Interface {
/** Returns every stored credential. */ /** Returns every stored credential. */
readonly all: () => Effect.Effect<Stored[]> readonly all: () => Effect.Effect<Info[]>
/** Returns stored credentials belonging to one integration. */ /** Returns stored credentials belonging to one integration. */
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Stored[]> readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Info[]>
/** Returns one stored credential by ID. */ /** Returns one stored credential by ID. */
readonly get: (id: ID) => Effect.Effect<Stored | undefined> readonly get: (id: ID) => Effect.Effect<Info | undefined>
/** Replaces any credential for an integration and returns the new record. */ /** Replaces any credential for an integration and returns the new record. */
readonly create: (input: { readonly create: (input: {
readonly integrationID: IntegrationSchema.ID readonly integrationID: IntegrationSchema.ID
readonly value: Info readonly value: Value
readonly label?: string readonly label?: string
}) => Effect.Effect<Stored> }) => Effect.Effect<Info>
/** Updates the label or secret value of a stored credential. */ /** Updates the label or secret value of a stored credential. */
readonly update: (id: ID, updates: Partial<Pick<Stored, "label" | "value">>) => Effect.Effect<void> readonly update: (id: ID, updates: Partial<Pick<Info, "label" | "value">>) => Effect.Effect<void>
/** Removes a stored credential. */ /** Removes a stored credential. */
readonly remove: (id: ID) => Effect.Effect<void> readonly remove: (id: ID) => Effect.Effect<void>
} }
@@ -66,10 +66,10 @@ export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const { db } = yield* Database.Service const { db } = yield* Database.Service
const decode = Schema.decodeUnknownSync(Info) const decode = Schema.decodeUnknownSync(Value)
const stored = (row: typeof CredentialTable.$inferSelect) => { const stored = (row: typeof CredentialTable.$inferSelect) => {
if (!row.integration_id) return if (!row.integration_id) return
return new Stored({ return new Info({
id: row.id, id: row.id,
integrationID: row.integration_id, integrationID: row.integration_id,
label: row.label, label: row.label,
@@ -106,7 +106,7 @@ export const layer = Layer.effect(
return row ? stored(row) : undefined return row ? stored(row) : undefined
}), }),
create: Effect.fn("Credential.create")(function* (input) { create: Effect.fn("Credential.create")(function* (input) {
const credential = new Stored({ const credential = new Info({
id: ID.create(), id: ID.create(),
integrationID: input.integrationID, integrationID: input.integrationID,
label: input.label ?? "default", label: input.label ?? "default",
+1 -1
View File
@@ -7,7 +7,7 @@ export const CredentialTable = sqliteTable("credential", {
id: text().$type<Credential.ID>().primaryKey(), id: text().$type<Credential.ID>().primaryKey(),
integration_id: text().$type<IntegrationSchema.ID>(), integration_id: text().$type<IntegrationSchema.ID>(),
label: text().notNull(), label: text().notNull(),
value: text({ mode: "json" }).$type<Credential.Info>().notNull(), value: text({ mode: "json" }).$type<Credential.Value>().notNull(),
connector_id: text(), connector_id: text(),
method_id: text(), method_id: text(),
active: integer({ mode: "boolean" }), active: integer({ mode: "boolean" }),
+3
View File
@@ -37,5 +37,8 @@ export const migrations = (
import("./migration/20260611035744_credential"), import("./migration/20260611035744_credential"),
import("./migration/20260611192811_lush_chimera"), import("./migration/20260611192811_lush_chimera"),
import("./migration/20260612174303_project_dir_strategy"), import("./migration/20260612174303_project_dir_strategy"),
import("./migration/20260622142730_simplify_session_context_epoch"),
import("./migration/20260622170816_reset_v2_session_state"),
import("./migration/20260622202450_simplify_session_input"),
]) ])
).map((module) => module.default) satisfies DatabaseMigration.Migration[] ).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,13 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260622142730_simplify_session_context_epoch",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`agent\`;`)
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`replacement_seq\`;`)
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`revision\`;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,17 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260622170816_reset_v2_session_state",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DELETE FROM \`session_context_epoch\`;`)
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\`;`)
yield* tx.run(`UPDATE \`session\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`)
yield* tx.run(`DELETE FROM \`workspace\`;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,17 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260622202450_simplify_session_input",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DELETE FROM \`session_context_epoch\`;`)
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\`;`)
yield* tx.run(`UPDATE \`session\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`)
yield* tx.run(`DELETE FROM \`workspace\`;`)
})
},
} satisfies DatabaseMigration.Migration
-3
View File
@@ -149,11 +149,8 @@ export default {
CREATE TABLE \`session_context_epoch\` ( CREATE TABLE \`session_context_epoch\` (
\`session_id\` text PRIMARY KEY, \`session_id\` text PRIMARY KEY,
\`baseline\` text NOT NULL, \`baseline\` text NOT NULL,
\`agent\` text DEFAULT 'build' NOT NULL,
\`snapshot\` text NOT NULL, \`snapshot\` text NOT NULL,
\`baseline_seq\` integer NOT NULL, \`baseline_seq\` integer NOT NULL,
\`replacement_seq\` integer,
\`revision\` integer DEFAULT 0 NOT NULL,
CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
); );
`) `)
+141 -175
View File
@@ -5,7 +5,7 @@ import { and, asc, eq, gt } from "drizzle-orm"
import { Database } from "./database/database" import { Database } from "./database/database"
import { EventSequenceTable, EventTable } from "./event/sql" import { EventSequenceTable, EventTable } from "./event/sql"
import { Location } from "./location" import { Location } from "./location"
import { externalID, type ExternalID, NonNegativeInt, withStatics } from "./schema" import { externalID, type ExternalID, withStatics } from "./schema"
import { Identifier } from "./util/identifier" import { Identifier } from "./util/identifier"
import { LayerNode } from "./effect/layer-node" import { LayerNode } from "./effect/layer-node"
import { isDeepStrictEqual } from "node:util" import { isDeepStrictEqual } from "node:util"
@@ -19,16 +19,9 @@ export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
) )
export type ID = typeof ID.Type export type ID = typeof ID.Type
/**
* Durable aggregate continuation position for embedded replay streams.
* TODO: Decide whether a future HTTP / SDK surface should expose an opaque cursor instead.
*/
export const Cursor = NonNegativeInt.pipe(Schema.brand("EventV2.Cursor"))
export type Cursor = typeof Cursor.Type
export type Definition<Type extends string = string, DataSchema extends Schema.Top = Schema.Top> = { export type Definition<Type extends string = string, DataSchema extends Schema.Top = Schema.Top> = {
readonly type: Type readonly type: Type
readonly sync?: { readonly durable?: {
readonly version: number readonly version: number
readonly aggregate: string readonly aggregate: string
} }
@@ -41,22 +34,31 @@ export type Payload<D extends Definition = Definition> = {
readonly id: ID readonly id: ID
readonly type: D["type"] readonly type: D["type"]
readonly data: Data<D> readonly data: Data<D>
/** Durable aggregate order, populated while synchronized events are projected. */ readonly durable?: {
readonly seq?: number readonly aggregateID: string
readonly version?: number readonly seq: number
readonly version: number
}
readonly location?: Location.Ref readonly location?: Location.Ref
readonly metadata?: Record<string, unknown> readonly metadata?: Record<string, unknown>
/** Internal replay marker for projectors that own non-replicated operational state. */
readonly replay?: boolean
} }
export type Projector<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void> export type Subscriber<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
type AnyProjector = (event: Payload) => Effect.Effect<void>
export type CommitGuard = (event: Payload) => Effect.Effect<void>
export type Listener = (event: Payload) => Effect.Effect<void>
export type Sync = (event: Payload) => Effect.Effect<void>
export type Unsubscribe = Effect.Effect<void> export type Unsubscribe = Effect.Effect<void>
export const latestSequence = Effect.fn("EventV2.latestSequence")(function* (
db: Database.Interface["db"],
aggregateID: string,
) {
const row = yield* db
.select({ seq: EventSequenceTable.seq })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.get()
.pipe(Effect.orDie)
return row?.seq ?? -1
})
export type SerializedEvent = { export type SerializedEvent = {
readonly id: ID readonly id: ID
readonly type: string readonly type: string
@@ -65,13 +67,8 @@ export type SerializedEvent = {
readonly data: Record<string, unknown> readonly data: Record<string, unknown>
} }
export type CursorEvent<E extends Payload = Payload> = { export class InvalidDurableEventError extends Schema.TaggedErrorClass<InvalidDurableEventError>()(
readonly cursor: Cursor "EventV2.InvalidDurableEvent",
readonly event: E
}
export class InvalidSyncEventError extends Schema.TaggedErrorClass<InvalidSyncEventError>()(
"EventV2.InvalidSyncEvent",
{ {
type: Schema.String, type: Schema.String,
message: Schema.String, message: Schema.String,
@@ -83,19 +80,11 @@ export function versionedType(type: string, version: number) {
} }
export const registry = new Map<string, Definition>() export const registry = new Map<string, Definition>()
type SyncDefinition = Definition & { const durableRegistry = new Map<string, Definition>()
readonly sync: NonNullable<Definition["sync"]>
readonly encode: (data: unknown) => unknown
readonly decode: (data: unknown) => unknown
}
const syncRegistry = new Map<string, SyncDefinition>()
// Synchronized events cross a JSON boundary, so their data schemas must encode and decode without services.
const syncCodec = (definition: Definition) => definition.data as Schema.Codec<unknown, unknown, never, never>
export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: { export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: {
readonly type: Type readonly type: Type
readonly sync?: { readonly durable?: {
readonly version: number readonly version: number
readonly aggregate: string readonly aggregate: string
} }
@@ -106,28 +95,25 @@ export function define<const Type extends string, Fields extends Schema.Struct.F
id: ID, id: ID,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
type: Schema.Literal(input.type), type: Schema.Literal(input.type),
version: Schema.optional(Schema.Number), durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number })),
location: Schema.optional(Location.Ref), location: Schema.optional(Location.Ref),
data: Data, data: Data,
}).annotate({ identifier: input.type }) }).annotate({ identifier: input.type })
const definition = Object.assign(Payload, { const definition = Object.assign(Payload, {
type: input.type, type: input.type,
...(input.sync === undefined ? {} : { sync: input.sync }), ...(input.durable === undefined ? {} : { durable: input.durable }),
data: Data, data: Data,
}) })
const existing = registry.get(input.type) const existing = registry.get(input.type)
if (input.sync === undefined || existing?.sync === undefined || input.sync.version >= existing.sync.version) { if (
input.durable === undefined ||
existing?.durable === undefined ||
input.durable.version >= existing.durable.version
) {
registry.set(input.type, definition) registry.set(input.type, definition)
} }
if (input.sync) if (input.durable) durableRegistry.set(versionedType(input.type, input.durable.version), definition)
syncRegistry.set(
versionedType(input.type, input.sync.version),
Object.assign(definition, {
encode: Schema.encodeUnknownSync(syncCodec(definition)),
decode: Schema.decodeUnknownSync(syncCodec(definition)),
}) as SyncDefinition,
)
return definition as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> & return definition as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> &
Definition<Type, Schema.Struct<Fields>> Definition<Type, Schema.Struct<Fields>>
} }
@@ -140,7 +126,7 @@ export interface PublishOptions {
readonly id?: ID readonly id?: ID
readonly metadata?: Record<string, unknown> readonly metadata?: Record<string, unknown>
readonly location?: Location.Ref readonly location?: Location.Ref
/** Local operational projection committed atomically with a new synchronized event. Not replayed or serialized. */ /** Local operational projection committed atomically with a new durable event. Not replayed or serialized. */
readonly commit?: (seq: number) => Effect.Effect<void> readonly commit?: (seq: number) => Effect.Effect<void>
} }
@@ -152,14 +138,10 @@ export interface Interface {
) => Effect.Effect<Payload<D>> ) => Effect.Effect<Payload<D>>
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>> readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
readonly all: () => Stream.Stream<Payload> readonly all: () => Stream.Stream<Payload>
readonly aggregateEvents: (input: { readonly durable: (input: { readonly aggregateID: string; readonly after?: number }) => Stream.Stream<Payload>
readonly aggregateID: string /** @deprecated Use `all()` and consume the returned stream. */
readonly after?: Cursor readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
}) => Stream.Stream<CursorEvent> readonly project: <D extends Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
readonly sync: (handler: Sync) => Effect.Effect<Unsubscribe>
readonly listen: (listener: Listener) => Effect.Effect<Unsubscribe>
readonly beforeCommit: (guard: CommitGuard) => Effect.Effect<void>
readonly project: <D extends Definition>(definition: D, projector: Projector<D>) => Effect.Effect<void>
readonly replay: ( readonly replay: (
event: SerializedEvent, event: SerializedEvent,
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean }, options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
@@ -182,37 +164,37 @@ export const layerWith = (options?: LayerOptions) =>
Layer.effect( Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const all = yield* PubSub.unbounded<Payload>() const pubsub = {
const synchronized = new Map<string, Set<PubSub.PubSub<void>>>() all: yield* PubSub.unbounded<Payload>(),
const typed = new Map<string, PubSub.PubSub<Payload>>() durable: new Map<string, Set<PubSub.PubSub<void>>>(),
const projectors = new Map<string, AnyProjector[]>() typed: new Map<string, PubSub.PubSub<Payload>>(),
const commitGuards = new Array<CommitGuard>() }
const listeners = new Array<Listener>() const projectors = new Map<string, Subscriber[]>()
const syncHandlers = new Array<Sync>() const listeners = new Array<Subscriber>()
const { db } = yield* Database.Service const { db } = yield* Database.Service
const getOrCreate = (definition: Definition) => const getOrCreate = (definition: Definition) =>
Effect.gen(function* () { Effect.gen(function* () {
const existing = typed.get(definition.type) const existing = pubsub.typed.get(definition.type)
if (existing) return existing if (existing) return existing
const pubsub = yield* PubSub.unbounded<Payload>() const created = yield* PubSub.unbounded<Payload>()
typed.set(definition.type, pubsub) pubsub.typed.set(definition.type, created)
return pubsub return created
}) })
yield* Effect.addFinalizer(() => yield* Effect.addFinalizer(() =>
Effect.gen(function* () { Effect.gen(function* () {
yield* PubSub.shutdown(all) yield* PubSub.shutdown(pubsub.all)
yield* Effect.forEach( yield* Effect.forEach(
synchronized.values(), pubsub.durable.values(),
(pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }), (pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }),
{ discard: true }, { discard: true },
) )
yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true }) yield* Effect.forEach(pubsub.typed.values(), PubSub.shutdown, { discard: true })
}), }),
) )
function commitSyncEvent( function commitDurableEvent(
event: Payload, event: Payload,
input?: { input?: {
readonly seq: number readonly seq: number
@@ -224,28 +206,20 @@ export const layerWith = (options?: LayerOptions) =>
) { ) {
return Effect.gen(function* () { return Effect.gen(function* () {
const definition = registry.get(event.type) const definition = registry.get(event.type)
const sync = definition?.sync const durable = definition?.durable
if (sync) { if (durable) {
if (event.version !== sync.version) { const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Expected event version ${sync.version}, got ${event.version}`,
}),
)
}
const aggregateID = (event.data as Record<string, unknown>)[sync.aggregate]
if (typeof aggregateID !== "string") { if (typeof aggregateID !== "string") {
yield* Effect.die( yield* Effect.die(
new InvalidSyncEventError({ new InvalidDurableEventError({
type: event.type, type: event.type,
message: `Expected string aggregate field ${sync.aggregate}`, message: `Expected string aggregate field ${durable.aggregate}`,
}), }),
) )
} else { } else {
if (input && input.aggregateID !== aggregateID) { if (input && input.aggregateID !== aggregateID) {
yield* Effect.die( yield* Effect.die(
new InvalidSyncEventError({ new InvalidDurableEventError({
type: event.type, type: event.type,
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`, message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
}), }),
@@ -265,12 +239,12 @@ export const layerWith = (options?: LayerOptions) =>
.get() .get()
.pipe(Effect.orDie) .pipe(Effect.orDie)
const latest = row?.seq ?? -1 const latest = row?.seq ?? -1
const encoded = syncRegistry const encoded = Schema.encodeUnknownSync(
.get(versionedType(definition.type, sync.version))! definition.data as Schema.Codec<unknown, unknown, never, never>,
.encode(event.data) as Record<string, unknown> )(event.data) as Record<string, unknown>
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) { if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
yield* Effect.die( yield* Effect.die(
new InvalidSyncEventError({ new InvalidDurableEventError({
type: event.type, type: event.type,
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`, message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
}), }),
@@ -285,7 +259,7 @@ export const layerWith = (options?: LayerOptions) =>
.pipe(Effect.orDie) .pipe(Effect.orDie)
if ( if (
stored?.id === event.id && stored?.id === event.id &&
stored.type === versionedType(definition.type, sync.version) && stored.type === versionedType(definition.type, durable.version) &&
isDeepStrictEqual(stored.data, encoded) isDeepStrictEqual(stored.data, encoded)
) { ) {
if (input.ownerID && row?.ownerID == null) { if (input.ownerID && row?.ownerID == null) {
@@ -299,7 +273,7 @@ export const layerWith = (options?: LayerOptions) =>
return return
} }
yield* Effect.die( yield* Effect.die(
new InvalidSyncEventError({ new InvalidDurableEventError({
type: event.type, type: event.type,
message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`, message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
}), }),
@@ -311,7 +285,7 @@ export const layerWith = (options?: LayerOptions) =>
const seq = input?.seq ?? latest + 1 const seq = input?.seq ?? latest + 1
if (input && seq !== latest + 1) { if (input && seq !== latest + 1) {
yield* Effect.die( yield* Effect.die(
new InvalidSyncEventError({ new InvalidDurableEventError({
type: event.type, type: event.type,
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`, message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
}), }),
@@ -325,16 +299,17 @@ export const layerWith = (options?: LayerOptions) =>
.pipe(Effect.orDie) .pipe(Effect.orDie)
if (stored) if (stored)
yield* Effect.die( yield* Effect.die(
new InvalidSyncEventError({ new InvalidDurableEventError({
type: event.type, type: event.type,
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`, message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
}), }),
) )
for (const guard of commitGuards) { const committed = {
yield* guard(event) ...event,
} durable: { aggregateID, seq, version: durable.version },
} as Payload
for (const projector of list) { for (const projector of list) {
yield* projector({ ...event, seq } as Payload) yield* projector(committed)
} }
if (commit) yield* commit(seq) if (commit) yield* commit(seq)
yield* db yield* db
@@ -356,7 +331,7 @@ export const layerWith = (options?: LayerOptions) =>
id: event.id, id: event.id,
aggregate_id: aggregateID, aggregate_id: aggregateID,
seq, seq,
type: versionedType(definition.type, sync.version), type: versionedType(definition.type, durable.version),
data: encoded, data: encoded,
}, },
]) ])
@@ -369,8 +344,8 @@ export const layerWith = (options?: LayerOptions) =>
.pipe(Effect.orDie) .pipe(Effect.orDie)
if (committed) { if (committed) {
yield* Effect.forEach( yield* Effect.forEach(
synchronized.get(committed.aggregateID) ?? [], pubsub.durable.get(committed.aggregateID) ?? [],
(pubsub) => PubSub.publish(pubsub, undefined), (wake) => PubSub.publish(wake, undefined),
{ discard: true }, { discard: true },
) )
} }
@@ -384,19 +359,25 @@ export const layerWith = (options?: LayerOptions) =>
function publishEvent<D extends Definition>(event: Payload<D>, commit?: PublishOptions["commit"]) { function publishEvent<D extends Definition>(event: Payload<D>, commit?: PublishOptions["commit"]) {
return Effect.gen(function* () { return Effect.gen(function* () {
const durable = registry.get(event.type)?.sync !== undefined const definition = registry.get(event.type)
if (!durable && commit) if (!definition?.durable && commit)
return yield* Effect.die( return yield* Effect.die(
new InvalidSyncEventError({ new InvalidDurableEventError({
type: event.type, type: event.type,
message: "Local commit hooks require a synchronized event", message: "Local commit hooks require a durable event",
}), }),
) )
if (durable) { if (definition?.durable) {
const committed = yield* commitSyncEvent(event as Payload, undefined, commit) const committed = yield* commitDurableEvent(event as Payload, undefined, commit)
if (committed) { if (committed) {
event = { ...event, seq: committed.seq } event = {
yield* Effect.forEach(syncHandlers, (sync) => observe(event as Payload, "sync", sync), { discard: true }) ...event,
durable: {
aggregateID: committed.aggregateID,
seq: committed.seq,
version: definition.durable.version,
},
}
yield* notify(event as Payload, true) yield* notify(event as Payload, true)
return event return event
} }
@@ -406,12 +387,11 @@ export const layerWith = (options?: LayerOptions) =>
}) })
} }
const observe = (event: Payload, kind: "sync" | "listener", observer: (event: Payload) => Effect.Effect<void>) => const observe = (event: Payload, observer: (event: Payload) => Effect.Effect<void>) =>
Effect.suspend(() => observer(event)).pipe( Effect.suspend(() => observer(event)).pipe(
Effect.catchCauseIf( Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause), (cause) => !Cause.hasInterrupts(cause),
(cause) => (cause) => Effect.logError("Event listener failed", { eventID: event.id, eventType: event.type, cause }),
Effect.logError("Event observer failed", { eventID: event.id, eventType: event.type, kind, cause }),
), ),
) )
@@ -419,12 +399,12 @@ export const layerWith = (options?: LayerOptions) =>
return Effect.gen(function* () { return Effect.gen(function* () {
yield* Effect.forEach( yield* Effect.forEach(
listeners, listeners,
(listener) => (isolateListeners ? observe(event, "listener", listener) : listener(event)), (listener) => (isolateListeners ? observe(event, listener) : listener(event)),
{ discard: true }, { discard: true },
) )
const pubsub = typed.get(event.type) const typed = pubsub.typed.get(event.type)
if (pubsub) yield* PubSub.publish(pubsub, event) if (typed) yield* PubSub.publish(typed, event)
yield* PubSub.publish(all, event) yield* PubSub.publish(pubsub.all, event)
}) })
} }
@@ -441,7 +421,6 @@ export const layerWith = (options?: LayerOptions) =>
id: options?.id ?? ID.create(), id: options?.id ?? ID.create(),
...(options?.metadata ? { metadata: options.metadata } : {}), ...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type, type: definition.type,
...(definition.sync === undefined ? {} : { version: definition.sync.version }),
...(location ? { location } : {}), ...(location ? { location } : {}),
data, data,
} as Payload<D>, } as Payload<D>,
@@ -455,27 +434,37 @@ export const layerWith = (options?: LayerOptions) =>
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean }, options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
) { ) {
return Effect.gen(function* () { return Effect.gen(function* () {
const definition = syncRegistry.get(event.type) const definition = durableRegistry.get(event.type)
if (!definition) { if (!definition?.durable) {
yield* Effect.die( yield* Effect.die(
new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` }), new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }),
) )
} else { } else {
const payload = { const payload = {
id: event.id, id: event.id,
type: definition.type, type: definition.type,
version: definition.sync.version, data: Schema.decodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)(
data: definition.decode(event.data), event.data,
replay: true, ),
} as Payload } as Payload
const committed = yield* commitSyncEvent(payload, { const committed = yield* commitDurableEvent(payload, {
seq: event.seq, seq: event.seq,
aggregateID: event.aggregateID, aggregateID: event.aggregateID,
ownerID: options?.ownerID, ownerID: options?.ownerID,
strictOwner: options?.strictOwner, strictOwner: options?.strictOwner,
}) })
if (committed && options?.publish) { if (committed && options?.publish) {
yield* notify({ ...payload, seq: committed.seq }, true) yield* notify(
{
...payload,
durable: {
aggregateID: committed.aggregateID,
seq: committed.seq,
version: definition.durable.version,
},
},
true,
)
} }
} }
}) })
@@ -490,7 +479,7 @@ export const layerWith = (options?: LayerOptions) =>
if (!source) return undefined if (!source) return undefined
if (events.some((event) => event.aggregateID !== source)) { if (events.some((event) => event.aggregateID !== source)) {
yield* Effect.die( yield* Effect.die(
new InvalidSyncEventError({ new InvalidDurableEventError({
type: events[0]?.type ?? "unknown", type: events[0]?.type ?? "unknown",
message: "Replay events must belong to the same aggregate", message: "Replay events must belong to the same aggregate",
}), }),
@@ -501,7 +490,7 @@ export const layerWith = (options?: LayerOptions) =>
const seq = start + index const seq = start + index
if (event.seq !== seq) { if (event.seq !== seq) {
yield* Effect.die( yield* Effect.die(
new InvalidSyncEventError({ new InvalidDurableEventError({
type: event.type, type: event.type,
message: `Replay sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`, message: `Replay sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`,
}), }),
@@ -540,22 +529,18 @@ export const layerWith = (options?: LayerOptions) =>
Stream.map((event) => event as Payload<D>), Stream.map((event) => event as Payload<D>),
) )
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(all) const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.all)
const decodeSerializedEvent = (event: SerializedEvent): CursorEvent => { const decodeSerializedEvent = (event: SerializedEvent): Payload => {
const definition = syncRegistry.get(event.type) const definition = durableRegistry.get(event.type)
if (!definition) { if (!definition?.durable) {
throw new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` }) throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
} }
return { return {
cursor: Cursor.make(event.seq),
event: {
id: event.id, id: event.id,
type: definition.type, type: definition.type,
version: definition.sync.version, durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version },
seq: event.seq, data: Schema.decodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)(event.data),
data: definition.decode(event.data),
},
} }
} }
@@ -583,43 +568,40 @@ export const layerWith = (options?: LayerOptions) =>
), ),
) )
const subscribeSynchronized = (aggregateID: string) => const subscribeDurable = (aggregateID: string) =>
Effect.gen(function* () { Effect.gen(function* () {
const pubsub = yield* PubSub.sliding<void>(1) const wake = yield* PubSub.sliding<void>(1)
const subscription = yield* PubSub.subscribe(pubsub) const subscription = yield* PubSub.subscribe(wake)
yield* Effect.acquireRelease( yield* Effect.acquireRelease(
Effect.sync(() => { Effect.sync(() => {
const pubsubs = synchronized.get(aggregateID) ?? new Set() const wakes = pubsub.durable.get(aggregateID) ?? new Set()
pubsubs.add(pubsub) wakes.add(wake)
synchronized.set(aggregateID, pubsubs) pubsub.durable.set(aggregateID, wakes)
}), }),
() => () =>
Effect.sync(() => { Effect.sync(() => {
const pubsubs = synchronized.get(aggregateID) const wakes = pubsub.durable.get(aggregateID)
pubsubs?.delete(pubsub) wakes?.delete(wake)
if (pubsubs?.size === 0) synchronized.delete(aggregateID) if (wakes?.size === 0) pubsub.durable.delete(aggregateID)
}).pipe(Effect.andThen(PubSub.shutdown(pubsub))), }).pipe(Effect.andThen(PubSub.shutdown(wake))),
) )
return subscription return subscription
}) })
const streamEvents = (input: { const durable = (input: { readonly aggregateID: string; readonly after?: number }): Stream.Stream<Payload> =>
readonly aggregateID: string
readonly after?: Cursor
}): Stream.Stream<CursorEvent> =>
Stream.unwrap( Stream.unwrap(
Effect.gen(function* () { Effect.gen(function* () {
const synchronized = yield* subscribeSynchronized(input.aggregateID) const wakes = yield* subscribeDurable(input.aggregateID)
let cursor = input.after ?? -1 let sequence = input.after ?? -1
const read = Effect.suspend(() => readAfter(input.aggregateID, cursor)).pipe( const read = Effect.suspend(() => readAfter(input.aggregateID, sequence)).pipe(
Effect.tap((events) => Effect.tap((events) =>
Effect.sync(() => { Effect.sync(() => {
cursor = events.at(-1)?.cursor ?? cursor sequence = events.at(-1)?.durable?.seq ?? sequence
}), }),
), ),
) )
const historical = yield* read const historical = yield* read
const live = Stream.fromSubscription(synchronized).pipe( const live = Stream.fromSubscription(wakes).pipe(
Stream.mapEffect(() => read), Stream.mapEffect(() => read),
Stream.flattenIterable, Stream.flattenIterable,
) )
@@ -627,7 +609,7 @@ export const layerWith = (options?: LayerOptions) =>
}), }),
) )
const listen = (listener: Listener): Effect.Effect<Unsubscribe> => const listen = (listener: Subscriber): Effect.Effect<Unsubscribe> =>
Effect.sync(() => { Effect.sync(() => {
listeners.push(listener) listeners.push(listener)
return Effect.sync(() => { return Effect.sync(() => {
@@ -636,21 +618,7 @@ export const layerWith = (options?: LayerOptions) =>
}) })
}) })
const sync = (handler: Sync): Effect.Effect<Unsubscribe> => const project = <D extends Definition>(definition: D, projector: Subscriber<D>): Effect.Effect<void> =>
Effect.sync(() => {
syncHandlers.push(handler)
return Effect.sync(() => {
const index = syncHandlers.indexOf(handler)
if (index >= 0) syncHandlers.splice(index, 1)
})
})
const beforeCommit = (guard: CommitGuard): Effect.Effect<void> =>
Effect.sync(() => {
commitGuards.push(guard)
})
const project = <D extends Definition>(definition: D, projector: Projector<D>): Effect.Effect<void> =>
Effect.sync(() => { Effect.sync(() => {
const list = projectors.get(definition.type) ?? [] const list = projectors.get(definition.type) ?? []
list.push((event) => projector(event as Payload<D>)) list.push((event) => projector(event as Payload<D>))
@@ -661,10 +629,8 @@ export const layerWith = (options?: LayerOptions) =>
publish, publish,
subscribe, subscribe,
all: streamAll, all: streamAll,
aggregateEvents: streamEvents, durable,
sync,
listen, listen,
beforeCommit,
project, project,
replay, replay,
replayAll, replayAll,
+1 -1
View File
@@ -123,6 +123,6 @@ const baseLayer = Layer.effect(
}), }),
) )
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.defaultLayer), Layer.provide(FSUtil.defaultLayer)) export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.locationLayer), Layer.provide(FSUtil.defaultLayer))
export const locationLayer = layer export const locationLayer = layer
+1 -1
View File
@@ -232,6 +232,6 @@ export const fffLayer = Layer.effect(
}), }),
) )
export const defaultLayer = Layer.unwrap( export const locationLayer = Layer.unwrap(
Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)), Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)),
) )
+64 -76
View File
@@ -1,7 +1,19 @@
export * as Integration from "./integration" export * as Integration from "./integration"
import { Cause, Clock, Context, Duration, Effect, Exit, Layer, Schedule, Schema, Scope, SynchronizedRef } from "effect" import {
import { castDraft, enableMapSet, type Draft } from "immer" Cause,
Clock,
Context,
Duration,
Effect,
Exit,
Layer,
Schedule,
Schema,
Scope,
SynchronizedRef,
Types,
} from "effect"
import { Credential } from "./credential" import { Credential } from "./credential"
import { IntegrationSchema } from "./integration/schema" import { IntegrationSchema } from "./integration/schema"
import { withStatics } from "./schema" import { withStatics } from "./schema"
@@ -42,13 +54,15 @@ export const SelectPrompt = Schema.Struct({
type: Schema.Literal("select"), type: Schema.Literal("select"),
key: Schema.String, key: Schema.String,
message: Schema.String, message: Schema.String,
options: Schema.Array( options: Schema.mutable(
Schema.Array(
Schema.Struct({ Schema.Struct({
label: Schema.String, label: Schema.String,
value: Schema.String, value: Schema.String,
hint: Schema.optional(Schema.String), hint: Schema.optional(Schema.String),
}), }),
), ),
),
when: Schema.optional(When), when: Schema.optional(When),
}).annotate({ identifier: "Integration.SelectPrompt" }) }).annotate({ identifier: "Integration.SelectPrompt" })
export type SelectPrompt = typeof SelectPrompt.Type export type SelectPrompt = typeof SelectPrompt.Type
@@ -60,7 +74,7 @@ export const OAuthMethod = Schema.Struct({
id: MethodID, id: MethodID,
type: Schema.Literal("oauth"), type: Schema.Literal("oauth"),
label: Schema.String, label: Schema.String,
prompts: Schema.optional(Schema.Array(Prompt)), prompts: Schema.optional(Schema.mutable(Schema.Array(Prompt))),
}).annotate({ identifier: "Integration.OAuthMethod" }) }).annotate({ identifier: "Integration.OAuthMethod" })
export type OAuthMethod = typeof OAuthMethod.Type export type OAuthMethod = typeof OAuthMethod.Type
@@ -72,7 +86,7 @@ export type KeyMethod = typeof KeyMethod.Type
export const EnvMethod = Schema.Struct({ export const EnvMethod = Schema.Struct({
type: Schema.Literal("env"), type: Schema.Literal("env"),
names: Schema.Array(Schema.String), names: Schema.mutable(Schema.Array(Schema.String)),
}).annotate({ identifier: "Integration.EnvMethod" }) }).annotate({ identifier: "Integration.EnvMethod" })
export type EnvMethod = typeof EnvMethod.Type export type EnvMethod = typeof EnvMethod.Type
@@ -82,8 +96,8 @@ export type Method = typeof Method.Type
export class Info extends Schema.Class<Info>("Integration.Info")({ export class Info extends Schema.Class<Info>("Integration.Info")({
id: ID, id: ID,
name: Schema.String, name: Schema.String,
methods: Schema.Array(Method), methods: Schema.mutable(Schema.Array(Method)),
connections: Schema.Array(IntegrationConnection.Info), connections: Schema.mutable(Schema.Array(IntegrationConnection.Info)),
}) {} }) {}
export type Inputs = Readonly<{ [key: string]: string }> export type Inputs = Readonly<{ [key: string]: string }>
@@ -94,11 +108,11 @@ export type OAuthAuthorization = {
} & ( } & (
| { | {
readonly mode: "auto" readonly mode: "auto"
readonly callback: Effect.Effect<Credential.Info, unknown> readonly callback: Effect.Effect<Credential.Value, unknown>
} }
| { | {
readonly mode: "code" readonly mode: "code"
readonly callback: (code: string) => Effect.Effect<Credential.Info, unknown> readonly callback: (code: string) => Effect.Effect<Credential.Value, unknown>
} }
) )
@@ -172,19 +186,19 @@ export type Ref = {
} }
type Entry = { type Entry = {
ref: Ref ref: Types.DeepMutable<Ref>
methods: Method[] methods: Types.DeepMutable<Method>[]
implementations: Map<MethodID, OAuthImplementation> implementations: Map<MethodID, Types.DeepMutable<OAuthImplementation>>
} }
type Data = { type Data = {
integrations: Map<ID, Entry> integrations: Map<ID, Entry>
} }
export type Editor = { export type Draft = {
list: () => readonly Ref[] list: () => readonly Ref[]
get: (id: ID) => Ref | undefined get: (id: ID) => Ref | undefined
update: (id: ID, update: (integration: Draft<Ref>) => void) => void update: (id: ID, update: (integration: Types.DeepMutable<Ref>) => void) => void
remove: (id: ID) => void remove: (id: ID) => void
method: { method: {
list: (integrationID: ID) => readonly Method[] list: (integrationID: ID) => readonly Method[]
@@ -193,18 +207,13 @@ export type Editor = {
} }
} }
export interface Interface { export interface Interface extends State.Transformable<Draft> {
/** Registers a scoped transform over the integration registry. */ /** Registers a scoped transform over the integration registry. */
readonly transform: State.Interface<Data, Editor>["transform"]
/** Registers and immediately applies a scoped integration registry update. */
readonly update: State.Interface<Data, Editor>["update"]
/** Returns one integration with its methods and current connections. */ /** Returns one integration with its methods and current connections. */
readonly get: (id: ID) => Effect.Effect<Info | undefined> readonly get: (id: ID) => Effect.Effect<Info | undefined>
/** Returns all integrations with their methods and current connections. */ /** Returns all integrations with their methods and current connections. */
readonly list: () => Effect.Effect<Info[]> readonly list: () => Effect.Effect<Info[]>
readonly connection: { readonly connection: {
/** Returns active connections for every registered or credential-backed integration. */
readonly list: () => Effect.Effect<Map<ID, IntegrationConnection.Info>>
/** Returns the active connection for one integration. */ /** Returns the active connection for one integration. */
readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined> readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
/** Runs a key method and stores the resulting credential. */ /** Runs a key method and stores the resulting credential. */
@@ -230,7 +239,7 @@ export interface Interface {
/** Updates a stored credential exposed as a connection. */ /** Updates a stored credential exposed as a connection. */
readonly update: ( readonly update: (
credentialID: Credential.ID, credentialID: Credential.ID,
updates: Partial<Pick<Credential.Stored, "label">>, updates: Partial<Pick<Credential.Info, "label">>,
) => Effect.Effect<void> ) => Effect.Effect<void>
/** Removes a stored credential connection. */ /** Removes a stored credential connection. */
readonly remove: (credentialID: Credential.ID) => Effect.Effect<void> readonly remove: (credentialID: Credential.ID) => Effect.Effect<void>
@@ -252,8 +261,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Integration") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Integration") {}
enableMapSet()
const attemptLifetime = Duration.toMillis(Duration.minutes(10)) const attemptLifetime = Duration.toMillis(Duration.minutes(10))
const terminalRetention = Duration.toMillis(Duration.minutes(1)) const terminalRetention = Duration.toMillis(Duration.minutes(1))
const scrubInterval = Duration.seconds(30) const scrubInterval = Duration.seconds(30)
@@ -284,15 +291,17 @@ export const locationLayer = Layer.effect(
const events = yield* EventV2.Service const events = yield* EventV2.Service
const scope = yield* Scope.Scope const scope = yield* Scope.Scope
const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>()) const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
const state = State.create<Data, Editor>({ const state = State.create<Data, Draft>({
initial: () => ({ integrations: new Map<ID, Entry>() }), initial: () => ({ integrations: new Map<ID, Entry>() }),
editor: (draft) => ({ draft: (draft) => ({
list: () => Array.from(draft.integrations.values(), (entry) => entry.ref) as Ref[], list: () => Array.from(draft.integrations.values(), (entry) => entry.ref) as Ref[],
get: (id) => draft.integrations.get(id)?.ref as Ref | undefined, get: (id) => draft.integrations.get(id)?.ref as Ref | undefined,
update: (id, update) => { update: (id, update) => {
const current = const current = draft.integrations.get(id) ?? {
draft.integrations.get(id) ?? ref: { id, name: id },
castDraft({ ref: { id, name: id } as Ref, methods: [], implementations: new Map() }) methods: [],
implementations: new Map(),
}
if (!draft.integrations.has(id)) draft.integrations.set(id, current) if (!draft.integrations.has(id)) draft.integrations.set(id, current)
update(current.ref) update(current.ref)
current.ref.id = id current.ref.id = id
@@ -301,16 +310,14 @@ export const locationLayer = Layer.effect(
method: { method: {
list: (integrationID) => (draft.integrations.get(integrationID)?.methods as Method[] | undefined) ?? [], list: (integrationID) => (draft.integrations.get(integrationID)?.methods as Method[] | undefined) ?? [],
update: (implementation) => { update: (implementation) => {
const current = const current = draft.integrations.get(implementation.integrationID) ?? {
draft.integrations.get(implementation.integrationID) ??
castDraft({
ref: { ref: {
id: implementation.integrationID, id: implementation.integrationID,
name: implementation.integrationID, name: implementation.integrationID,
} as Ref, },
methods: [], methods: [],
implementations: new Map<MethodID, OAuthImplementation>(), implementations: new Map<MethodID, Types.DeepMutable<OAuthImplementation>>(),
}) }
if (!draft.integrations.has(implementation.integrationID)) { if (!draft.integrations.has(implementation.integrationID)) {
draft.integrations.set(implementation.integrationID, current) draft.integrations.set(implementation.integrationID, current)
} }
@@ -319,10 +326,13 @@ export const locationLayer = Layer.effect(
if (method.type !== "oauth" || implementation.method.type !== "oauth") return true if (method.type !== "oauth" || implementation.method.type !== "oauth") return true
return method.id === implementation.method.id return method.id === implementation.method.id
}) })
if (index === -1) current.methods.push(castDraft(implementation.method)) if (index === -1) current.methods.push(implementation.method as Types.DeepMutable<Method>)
else current.methods[index] = castDraft(implementation.method) else current.methods[index] = implementation.method as Types.DeepMutable<Method>
if (isOAuthImplementation(implementation)) { if (implementation.method.type === "oauth") {
current.implementations.set(implementation.method.id, castDraft(implementation)) current.implementations.set(
implementation.method.id,
implementation as Types.DeepMutable<OAuthImplementation>,
)
} }
}, },
remove: (integrationID, method) => { remove: (integrationID, method) => {
@@ -341,39 +351,27 @@ export const locationLayer = Layer.effect(
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid), finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
}) })
const connections = (entry: Entry, saved: readonly Credential.Stored[]): IntegrationConnection.Info[] => { const resolveConnections = (entry: Entry | undefined, saved: readonly Credential.Info[]) => {
const connected = saved.map((credential) => ({ const credentials = saved
.map((credential) => ({
type: "credential" as const, type: "credential" as const,
id: credential.id, id: credential.id,
label: credential.label, label: credential.label,
})) }))
const detected = entry.methods .toReversed()
const env = (entry?.methods ?? [])
.filter((method) => method.type === "env") .filter((method) => method.type === "env")
.flatMap((method) => method.names.filter((name) => process.env[name])) .flatMap((method) => method.names.filter((name) => process.env[name]))
.map((name) => ({ type: "env" as const, name })) .map((name) => ({ type: "env" as const, name }))
return [...connected, ...detected] return [...credentials, ...env]
} }
const activeConnection = ( const project = (entry: Entry, connections: IntegrationConnection.Info[]) =>
entry: Entry | undefined,
saved: readonly Credential.Stored[],
): IntegrationConnection.Info | undefined => {
const credential = saved.at(-1)
if (credential) return { type: "credential", id: credential.id, label: credential.label }
if (!entry) return
const name = entry.methods
.filter((method) => method.type === "env")
.flatMap((method) => method.names)
.find((name) => process.env[name])
if (name) return { type: "env", name }
}
const project = (entry: Entry, saved: readonly Credential.Stored[]) =>
new Info({ new Info({
id: entry.ref.id, id: entry.ref.id,
name: entry.ref.name, name: entry.ref.name,
methods: entry.methods, methods: entry.methods,
connections: connections(entry, saved), connections,
}) })
const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) => const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
@@ -387,7 +385,7 @@ export const locationLayer = Layer.effect(
return error instanceof Error ? error.message : String(error) return error instanceof Error ? error.message : String(error)
} }
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.Info, unknown>) { const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.Value, unknown>) {
const now = yield* Clock.currentTimeMillis const now = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modify(attempts, (current) => { const result = yield* SynchronizedRef.modify(attempts, (current) => {
const attempt = current.get(attemptID) const attempt = current.get(attemptID)
@@ -434,32 +432,22 @@ export const locationLayer = Layer.effect(
return Service.of({ return Service.of({
transform: state.transform, transform: state.transform,
update: state.update, reload: state.reload,
get: Effect.fn("Integration.get")(function* (id) { get: Effect.fn("Integration.get")(function* (id) {
const entry = state.get().integrations.get(id) const entry = state.get().integrations.get(id)
if (!entry) return undefined if (!entry) return undefined
return project(entry, yield* credentials.list(id)) return project(entry, resolveConnections(entry, yield* credentials.list(id)))
}), }),
list: Effect.fn("Integration.list")(function* () { list: Effect.fn("Integration.list")(function* () {
return (yield* Effect.forEach(state.get().integrations.values(), (entry) => const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
Effect.gen(function* () { return Array.from(state.get().integrations.values(), (entry) =>
return project(entry, yield* credentials.list(entry.ref.id)) project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? [])),
}), ).toSorted((a, b) => a.name.localeCompare(b.name))
)).toSorted((a, b) => a.name.localeCompare(b.name))
}), }),
connection: { connection: {
list: Effect.fn("Integration.connection.list")(function* () {
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
return new Map(
new Set([...state.get().integrations.keys(), ...saved.keys()]).values().flatMap((id) => {
const connection = activeConnection(state.get().integrations.get(id), saved.get(id) ?? [])
return connection ? [[id, connection] as const] : []
}),
)
}),
forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) { forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) {
const entry = state.get().integrations.get(id) const entry = state.get().integrations.get(id)
return activeConnection(entry, yield* credentials.list(id)) return resolveConnections(entry, yield* credentials.list(id))[0]
}), }),
key: Effect.fn("Integration.connection.key")(function* (input) { key: Effect.fn("Integration.connection.key")(function* (input) {
const method = state const method = state
+2 -2
View File
@@ -7,7 +7,7 @@ import { Catalog } from "./catalog"
import { Integration } from "./integration" import { Integration } from "./integration"
import { CommandV2 } from "./command" import { CommandV2 } from "./command"
import { AgentV2 } from "./agent" import { AgentV2 } from "./agent"
import { PluginBoot } from "./plugin/boot" import { PluginInternal } from "./plugin/internal"
import { Project } from "./project" import { Project } from "./project"
import { ProjectCopy } from "./project/copy" import { ProjectCopy } from "./project/copy"
import { ProjectDirectories } from "./project/directories" import { ProjectDirectories } from "./project/directories"
@@ -65,7 +65,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Integration.locationLayer, Integration.locationLayer,
CommandV2.locationLayer, CommandV2.locationLayer,
AgentV2.locationLayer, AgentV2.locationLayer,
PluginBoot.locationLayer, PluginInternal.locationLayer,
ProjectCopy.locationLayer, ProjectCopy.locationLayer,
FileSystem.locationLayer, FileSystem.locationLayer,
Watcher.locationLayer, Watcher.locationLayer,
+1 -1
View File
@@ -33,7 +33,7 @@ export type Request = typeof Request.Type
interface MutableRequest { interface MutableRequest {
headers: Record<string, string> headers: Record<string, string>
body: Record<string, unknown> body: Record<string, unknown>
generation?: Generation generation?: Record<string, unknown>
options?: Record<string, unknown> options?: Record<string, unknown>
} }
+11 -8
View File
@@ -1,5 +1,4 @@
import { DateTime, Schema } from "effect" import { Schema, Types } from "effect"
import { DateTimeUtcFromMillis } from "effect/Schema"
import { ProviderV2 } from "./provider" import { ProviderV2 } from "./provider"
import { ModelRequest } from "./model-request" import { ModelRequest } from "./model-request"
@@ -16,8 +15,8 @@ export type Family = typeof Family.Type
export const Capabilities = Schema.Struct({ export const Capabilities = Schema.Struct({
tools: Schema.Boolean, tools: Schema.Boolean,
// mime patterns, image, audio, video/*, text/* // mime patterns, image, audio, video/*, text/*
input: Schema.String.pipe(Schema.Array), input: Schema.String.pipe(Schema.Array, Schema.mutable),
output: Schema.String.pipe(Schema.Array), output: Schema.String.pipe(Schema.Array, Schema.mutable),
}) })
export type Capabilities = typeof Capabilities.Type export type Capabilities = typeof Capabilities.Type
@@ -67,11 +66,11 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
variants: Schema.Struct({ variants: Schema.Struct({
id: VariantID, id: VariantID,
...ModelRequest.Request.fields, ...ModelRequest.Request.fields,
}).pipe(Schema.Array), }).pipe(Schema.Array, Schema.mutable),
time: Schema.Struct({ time: Schema.Struct({
released: DateTimeUtcFromMillis, released: Schema.Finite,
}), }),
cost: Cost.pipe(Schema.Array), cost: Cost.pipe(Schema.Array, Schema.mutable),
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
enabled: Schema.Boolean, enabled: Schema.Boolean,
limit: Schema.Struct({ limit: Schema.Struct({
@@ -103,7 +102,7 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
}, },
variants: [], variants: [],
time: { time: {
released: DateTime.makeUnsafe(0), released: 0,
}, },
cost: [], cost: [],
status: "active", status: "active",
@@ -116,6 +115,10 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
} }
} }
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & {
api: ProviderV2.MutableApi<Api>
}
export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } { export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } {
const [providerID, ...modelID] = input.split("/") const [providerID, ...modelID] = input.split("/")
return { return {
+11 -15
View File
@@ -20,7 +20,7 @@ export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedErr
export interface EntryPoint { export interface EntryPoint {
readonly directory: string readonly directory: string
readonly entrypoint: Option.Option<string> readonly entrypoint?: string
} }
export interface Interface { export interface Interface {
@@ -34,7 +34,7 @@ export interface Interface {
}[] }[]
}, },
) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError> ) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
readonly which: (pkg: string, bin?: string) => Effect.Effect<Option.Option<string>> readonly which: (pkg: string, bin?: string) => Effect.Effect<string | undefined>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
@@ -47,12 +47,11 @@ export function sanitize(pkg: string) {
} }
const resolveEntryPoint = (name: string, dir: string): EntryPoint => { const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
let entrypoint: Option.Option<string> let entrypoint: string | undefined
try { try {
const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir) entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
entrypoint = Option.some(resolved)
} catch { } catch {
entrypoint = Option.none() entrypoint = undefined
} }
return { return {
directory: dir, directory: dir,
@@ -130,7 +129,7 @@ export const layer = Layer.effect(
const first = tree.edgesOut.values().next().value?.to const first = tree.edgesOut.values().next().value?.to
if (!first) { if (!first) {
const result = resolveEntryPoint(name, path.join(dir, "node_modules", name)) const result = resolveEntryPoint(name, path.join(dir, "node_modules", name))
if (Option.isSome(result.entrypoint)) return result if (result.entrypoint) return result
return yield* new InstallFailedError({ add: [pkg], dir }) return yield* new InstallFailedError({ add: [pkg], dir })
} }
return resolveEntryPoint(first.name, first.path) return resolveEntryPoint(first.name, first.path)
@@ -219,7 +218,8 @@ export const layer = Layer.effect(
return Option.some(files[0]) return Option.some(files[0])
}) })
return yield* Effect.gen(function* () { return Option.getOrUndefined(
yield* Effect.gen(function* () {
const bin = yield* pick() const bin = yield* pick()
if (Option.isSome(bin)) { if (Option.isSome(bin)) {
return Option.some(path.join(binDir, bin.value)) return Option.some(path.join(binDir, bin.value))
@@ -235,6 +235,7 @@ export const layer = Layer.effect(
}).pipe( }).pipe(
Effect.scoped, Effect.scoped,
Effect.orElseSucceed(() => Option.none<string>()), Effect.orElseSucceed(() => Option.none<string>()),
),
) )
}) })
@@ -261,14 +262,9 @@ export async function install(...args: Parameters<Interface["install"]>) {
} }
export async function add(...args: Parameters<Interface["add"]>) { export async function add(...args: Parameters<Interface["add"]>) {
const entry = await runPromise((svc) => svc.add(...args)) return runPromise((svc) => svc.add(...args))
return {
directory: entry.directory,
entrypoint: Option.getOrUndefined(entry.entrypoint),
}
} }
export async function which(...args: Parameters<Interface["which"]>) { export async function which(...args: Parameters<Interface["which"]>) {
const resolved = await runPromise((svc) => svc.which(...args)) return runPromise((svc) => svc.which(...args))
return Option.getOrUndefined(resolved)
} }
+1 -1
View File
@@ -12,5 +12,5 @@ export const Rule = Schema.Struct({
}).annotate({ identifier: "PermissionV2.Rule" }) }).annotate({ identifier: "PermissionV2.Rule" })
export type Rule = typeof Rule.Type export type Rule = typeof Rule.Type
export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionV2.Ruleset" }) export const Ruleset = Schema.mutable(Schema.Array(Rule)).annotate({ identifier: "PermissionV2.Ruleset" })
export type Ruleset = typeof Ruleset.Type export type Ruleset = typeof Ruleset.Type
+121 -148
View File
@@ -1,12 +1,18 @@
export * as PluginV2 from "./plugin" export * as PluginV2 from "./plugin"
import { createDraft, finishDraft, type Draft } from "immer" import { Context, Deferred, Effect, Exit, Layer, Schema, Scope } from "effect"
import type { LanguageModelV3 } from "@ai-sdk/provider" import type { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Context, Effect, Exit, Layer, Schema, Scope } from "effect" import { AgentV2 } from "./agent"
import type { ModelV2 } from "./model" import { AISDK } from "./aisdk"
import type { Catalog } from "./catalog" import { Catalog } from "./catalog"
import { CommandV2 } from "./command"
import { EventV2 } from "./event" import { EventV2 } from "./event"
import { Integration } from "./integration"
import { KeyedMutex } from "./effect/keyed-mutex" import { KeyedMutex } from "./effect/keyed-mutex"
import { PluginHost } from "./plugin/host"
import { Reference } from "./reference"
import { SkillV2 } from "./skill"
import { State } from "./state"
export const ID = Schema.String.pipe(Schema.brand("Plugin.ID")) export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
export type ID = typeof ID.Type export type ID = typeof ID.Type
@@ -20,71 +26,10 @@ export const Event = {
}), }),
} }
type HookSpec = {
"catalog.transform": {
input: Catalog.Editor
output: {}
}
"aisdk.language": {
input: {
model: ModelV2.Info
sdk: any
options: Record<string, any>
}
output: {
language?: LanguageModelV3
}
}
"aisdk.sdk": {
input: {
model: ModelV2.Info
package: string
options: Record<string, any>
}
output: {
sdk?: any
}
}
}
export type Hooks = {
[Name in keyof HookSpec]: Readonly<HookSpec[Name]["input"]> & {
-readonly [Field in keyof HookSpec[Name]["output"]]: HookSpec[Name]["output"][Field] extends object
? Draft<HookSpec[Name]["output"][Field]>
: HookSpec[Name]["output"][Field]
}
}
export type HookFunctions = {
[key in keyof Hooks]?: (input: Hooks[key]) => Effect.Effect<void>
}
export type HookInput<Name extends keyof Hooks> = HookSpec[Name]["input"]
export type HookOutput<Name extends keyof Hooks> = HookSpec[Name]["output"]
export type Effect<R = never> = Effect.Effect<HookFunctions | void, never, R | Scope.Scope>
export function define<R>(input: { id: ID; effect: Effect.Effect<HookFunctions | void, never, R> }) {
return input
}
export interface Interface { export interface Interface {
readonly add: (input: { readonly add: (id: ID, effect: Plugin["effect"]) => Effect.Effect<void>
id: ID
effect: Effect.Effect<void | HookFunctions, never, Scope.Scope>
}) => Effect.Effect<void, never, never>
readonly remove: (id: ID) => Effect.Effect<void> readonly remove: (id: ID) => Effect.Effect<void>
readonly triggerFor: <Name extends keyof Hooks>( readonly wait: (id: ID) => Effect.Effect<void>
id: ID,
name: Name,
input: HookInput<Name>,
output: HookOutput<Name>,
) => Effect.Effect<HookInput<Name> & HookOutput<Name>>
readonly trigger: <Name extends keyof Hooks>(
name: Name,
input: HookInput<Name>,
output: HookOutput<Name>,
) => Effect.Effect<HookInput<Name> & HookOutput<Name>>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Plugin") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Plugin") {}
@@ -92,95 +37,123 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
let hooks: {
id: ID
hooks: HookFunctions
scope: Scope.Closeable
}[] = []
const events = yield* EventV2.Service const events = yield* EventV2.Service
const scope = yield* Scope.Scope
const locks = KeyedMutex.makeUnsafe<ID>() const locks = KeyedMutex.makeUnsafe<ID>()
const scope = yield* Scope.make()
const active = new Map<ID, Scope.Closeable>()
const loading = new Set<ID>()
const waiters = new Map<ID, Set<Deferred.Deferred<void>>>()
const failures = new Map<ID, Exit.Exit<void, never>>()
let host: Parameters<Plugin["effect"]>[0]
const svc = Service.of({ const add = Effect.fn("Plugin.add")(function* (id: ID, effect: Plugin["effect"]) {
add: Effect.fn("Plugin.add")(function* (input) { if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${id}`)
yield* locks.withLock(input.id)(
Effect.gen(function* () {
const existing = hooks.find((item) => item.id === input.id)
if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
const childScope = yield* Scope.fork(scope)
const result = yield* input.effect.pipe(
Scope.provide(childScope),
Effect.withSpan("Plugin.load", {
attributes: {
"plugin.id": input.id,
},
}),
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(childScope, exit) : Effect.void)),
)
hooks = [
...hooks.filter((item) => item.id !== input.id),
{
id: input.id,
hooks: result ?? {},
scope: childScope,
},
]
yield* events.publish(Event.Added, { id: input.id })
}),
)
}),
trigger: Effect.fn("Plugin.trigger")(function* (name, input, output) {
return yield* svc.triggerFor(ID.make("*"), name, input, output)
}),
triggerFor: Effect.fn("Plugin.triggerFor")(function* (id, name, input, output) {
const draftEntries = new Map<string, ReturnType<typeof createDraft>>()
const event = {
...input,
...output,
} as Record<string, unknown>
for (const [field, value] of Object.entries(output)) {
if (value && typeof value === "object") {
draftEntries.set(field, createDraft(value))
event[field] = draftEntries.get(field)
}
}
for (const item of hooks) {
if (id !== ID.make("*") && item.id !== id) continue
const match = item.hooks[name]
if (!match) continue
yield* match(event as any).pipe(
Effect.withSpan(`Plugin.hook.${name}`, {
attributes: {
plugin: item.id,
hook: name,
},
}),
)
}
for (const [field, draft] of draftEntries) {
event[field] = finishDraft(draft)
}
return event as any
}),
remove: Effect.fn("Plugin.remove")(function* (id) {
yield* locks.withLock(id)( yield* locks.withLock(id)(
Effect.sync(() => {
loading.add(id)
failures.delete(id)
}).pipe(
Effect.andThen(
State.batch(
Effect.gen(function* () { Effect.gen(function* () {
const existing = hooks.find((item) => item.id === id) const existing = active.get(id)
hooks = hooks.filter((item) => item.id !== id) active.delete(id)
if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore) if (existing) yield* Scope.close(existing, Exit.void).pipe(Effect.ignore)
}),
const child = yield* Scope.fork(scope)
yield* effect(host).pipe(
Scope.provide(child),
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }),
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
) )
}), yield* events.publish(Event.Added, { id })
active.set(id, child)
yield* Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.succeed(waiter, undefined), {
discard: true,
}) })
return svc waiters.delete(id)
}),
),
),
Effect.onExit((exit) => {
if (Exit.isSuccess(exit)) return Effect.void
failures.set(id, exit)
return Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.done(waiter, exit), {
discard: true,
}).pipe(Effect.ensuring(Effect.sync(() => waiters.delete(id))))
}),
Effect.ensuring(Effect.sync(() => loading.delete(id))),
),
)
})
const remove = Effect.fn("Plugin.remove")(function* (id: ID) {
if (loading.has(id)) return yield* Effect.die(`Cannot remove plugin ${id} while it is loading`)
yield* locks.withLock(id)(
State.batch(
Effect.gen(function* () {
const current = active.get(id)
active.delete(id)
failures.delete(id)
if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore)
}),
),
)
})
const wait = Effect.fn("Plugin.wait")(function* (id: ID) {
const waiter = yield* Deferred.make<void>()
const pending = yield* locks.withLock(id)(
Effect.sync(() => {
if (active.has(id)) return false
const failure = failures.get(id)
if (failure) return failure
const current = waiters.get(id) ?? new Set()
current.add(waiter)
waiters.set(id, current)
return true
}),
)
if (!pending) return
if (typeof pending !== "boolean") return yield* pending
yield* Deferred.await(waiter).pipe(
Effect.ensuring(
locks.withLock(id)(
Effect.sync(() => {
const current = waiters.get(id)
current?.delete(waiter)
if (current?.size === 0) waiters.delete(id)
}),
),
),
)
})
yield* Effect.addFinalizer((exit) =>
Effect.gen(function* () {
active.clear()
yield* State.batch(Scope.close(scope, exit))
}), }),
) )
export const locationLayer = layer const service = Service.of({
add,
remove,
wait,
})
host = yield* PluginHost.make(service)
return service
}),
)
// opencode export const locationLayer = layer.pipe(
// sdcok Layer.provideMerge(AgentV2.locationLayer),
Layer.provideMerge(AISDK.locationLayer),
Layer.provideMerge(Catalog.locationLayer),
Layer.provideMerge(CommandV2.locationLayer),
Layer.provideMerge(Integration.locationLayer),
Layer.provideMerge(Reference.locationLayer),
Layer.provideMerge(SkillV2.locationLayer),
)
+12 -13
View File
@@ -1,12 +1,12 @@
export * as AgentPlugin from "./agent" export * as AgentPlugin from "./agent"
import path from "path" import path from "path"
import { define } from "./internal"
import { Effect } from "effect" import { Effect } from "effect"
import { AgentV2 } from "../agent" import { AgentV2 } from "../agent"
import { Global } from "../global" import { Global } from "../global"
import { Location } from "../location" import { Location } from "../location"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { PluginV2 } from "../plugin"
const TRUNCATION_GLOB = path.join(Global.Path.data, "tool-output", "*") const TRUNCATION_GLOB = path.join(Global.Path.data, "tool-output", "*")
const BUILD_SYSTEM = const BUILD_SYSTEM =
@@ -97,10 +97,9 @@ Rules:
- If the conversation ends with an unanswered question to the user, preserve that exact question - If the conversation ends with an unanswered question to the user, preserve that exact question
- If the conversation ends with an imperative statement or request to the user (e.g. "Now please run the command and paste the console output"), always include that exact request in the summary` - If the conversation ends with an imperative statement or request to the user (e.g. "Now please run the command and paste the console output"), always include that exact request in the summary`
export const Plugin = PluginV2.define({ export const Plugin = define({
id: PluginV2.ID.make("agent"), id: "agent",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
const agent = yield* AgentV2.Service
const location = yield* Location.Service const location = yield* Location.Service
const worktree = location.directory const worktree = location.directory
const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")] const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")]
@@ -122,8 +121,8 @@ export const Plugin = PluginV2.define({
{ action: "read", resource: "*.env.example", effect: "allow" }, { action: "read", resource: "*.env.example", effect: "allow" },
] ]
yield* agent.update((editor) => { yield* ctx.agent.transform((draft) => {
editor.update(AgentV2.defaultID, (item) => { draft.update(AgentV2.defaultID, (item) => {
item.description = "The default agent. Executes tools based on configured permissions." item.description = "The default agent. Executes tools based on configured permissions."
item.system ??= BUILD_SYSTEM item.system ??= BUILD_SYSTEM
item.mode = "primary" item.mode = "primary"
@@ -135,7 +134,7 @@ export const Plugin = PluginV2.define({
) )
}) })
editor.update(AgentV2.ID.make("plan"), (item) => { draft.update(AgentV2.ID.make("plan"), (item) => {
item.description = "Plan mode. Disallows all edit tools." item.description = "Plan mode. Disallows all edit tools."
item.mode = "primary" item.mode = "primary"
item.permissions.push( item.permissions.push(
@@ -154,14 +153,14 @@ export const Plugin = PluginV2.define({
) )
}) })
editor.update(AgentV2.ID.make("general"), (item) => { draft.update(AgentV2.ID.make("general"), (item) => {
item.description = item.description =
"General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel." "General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel."
item.mode = "subagent" item.mode = "subagent"
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "todowrite", resource: "*", effect: "deny" }])) item.permissions.push(...PermissionV2.merge(defaults, [{ action: "todowrite", resource: "*", effect: "deny" }]))
}) })
editor.update(AgentV2.ID.make("explore"), (item) => { draft.update(AgentV2.ID.make("explore"), (item) => {
item.description = item.description =
'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.' 'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.'
item.system = PROMPT_EXPLORE item.system = PROMPT_EXPLORE
@@ -182,21 +181,21 @@ export const Plugin = PluginV2.define({
) )
}) })
editor.update(AgentV2.ID.make("compaction"), (item) => { draft.update(AgentV2.ID.make("compaction"), (item) => {
item.mode = "primary" item.mode = "primary"
item.hidden = true item.hidden = true
item.system = PROMPT_COMPACTION item.system = PROMPT_COMPACTION
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }])) item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
}) })
editor.update(AgentV2.ID.make("title"), (item) => { draft.update(AgentV2.ID.make("title"), (item) => {
item.mode = "primary" item.mode = "primary"
item.hidden = true item.hidden = true
item.system = PROMPT_TITLE item.system = PROMPT_TITLE
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }])) item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
}) })
editor.update(AgentV2.ID.make("summary"), (item) => { draft.update(AgentV2.ID.make("summary"), (item) => {
item.mode = "primary" item.mode = "primary"
item.hidden = true item.hidden = true
item.system = PROMPT_SUMMARY item.system = PROMPT_SUMMARY
-135
View File
@@ -1,135 +0,0 @@
export * as PluginBoot from "./boot"
import { Context, Deferred, Effect, Layer } from "effect"
import { Credential } from "../credential"
import { Integration } from "../integration"
import { AgentV2 } from "../agent"
import { Catalog } from "../catalog"
import { CommandV2 } from "../command"
import { Config } from "../config"
import { ConfigAgentPlugin } from "../config/plugin/agent"
import { ConfigCommandPlugin } from "../config/plugin/command"
import { ConfigSkillPlugin } from "../config/plugin/skill"
import { ConfigReferencePlugin } from "../config/plugin/reference"
import { EventV2 } from "../event"
import { FSUtil } from "../fs-util"
import { Global } from "../global"
import { Location } from "../location"
import { ModelsDev } from "../models-dev"
import { Npm } from "../npm"
import { PluginV2 } from "../plugin"
import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
import { SkillPlugin } from "./skill"
import { ConfigProviderPlugin } from "../config/plugin/provider"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
import { SkillV2 } from "../skill"
import { Reference } from "../reference"
type Plugin = {
id: PluginV2.ID
effect: PluginV2.Effect<
| Catalog.Service
| CommandV2.Service
| Credential.Service
| Integration.Service
| AgentV2.Service
| Npm.Service
| EventV2.Service
| FSUtil.Service
| Global.Service
| Location.Service
| PluginV2.Service
| Config.Service
| ModelsDev.Service
| SkillV2.Service
| Reference.Service
>
}
export interface Interface {
readonly wait: () => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/PluginBoot") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const commands = yield* CommandV2.Service
const plugin = yield* PluginV2.Service
const credentials = yield* Credential.Service
const integrations = yield* Integration.Service
const agents = yield* AgentV2.Service
const config = yield* Config.Service
const location = yield* Location.Service
const modelsDev = yield* ModelsDev.Service
const npm = yield* Npm.Service
const events = yield* EventV2.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const skill = yield* SkillV2.Service
const references = yield* Reference.Service
const done = yield* Deferred.make<void>()
const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) {
yield* plugin.add({
id: input.id,
effect: input.effect.pipe(
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(CommandV2.Service, commands),
Effect.provideService(Credential.Service, credentials),
Effect.provideService(Integration.Service, integrations),
Effect.provideService(AgentV2.Service, agents),
Effect.provideService(Config.Service, config),
Effect.provideService(Location.Service, location),
Effect.provideService(ModelsDev.Service, modelsDev),
Effect.provideService(Npm.Service, npm),
Effect.provideService(EventV2.Service, events),
Effect.provideService(FSUtil.Service, fs),
Effect.provideService(Global.Service, global),
Effect.provideService(SkillV2.Service, skill),
Effect.provideService(Reference.Service, references),
Effect.provideService(PluginV2.Service, plugin),
),
})
})
const boot = Effect.gen(function* () {
yield* add(AgentPlugin.Plugin)
yield* add(CommandPlugin.Plugin)
yield* add(SkillPlugin.Plugin)
for (const item of ProviderPlugins) {
yield* add(item)
}
yield* add(ModelsDevPlugin)
yield* add(ConfigProviderPlugin.Plugin)
yield* add(ConfigAgentPlugin.Plugin)
yield* add(ConfigCommandPlugin.Plugin)
yield* add(ConfigSkillPlugin.Plugin)
yield* add(ConfigReferencePlugin.Plugin)
}).pipe(Effect.withSpan("PluginBoot.boot"))
yield* boot.pipe(
Effect.exit,
Effect.flatMap((exit) => Deferred.done(done, exit)),
Effect.forkScoped,
)
return Service.of({
wait: () => Deferred.await(done),
})
}),
)
export const locationLayer = layer.pipe(
Layer.provideMerge(Integration.locationLayer),
Layer.provideMerge(Catalog.locationLayer),
Layer.provideMerge(CommandV2.locationLayer),
Layer.provideMerge(Config.locationLayer),
Layer.provideMerge(AgentV2.locationLayer),
Layer.provideMerge(SkillV2.locationLayer),
Layer.provideMerge(Reference.locationLayer),
)
+7 -11
View File
@@ -1,25 +1,21 @@
export * as CommandPlugin from "./command" export * as CommandPlugin from "./command"
import { define } from "./internal"
import { Effect } from "effect" import { Effect } from "effect"
import { CommandV2 } from "../command"
import { Location } from "../location" import { Location } from "../location"
import { PluginV2 } from "../plugin"
import PROMPT_INITIALIZE from "./command/initialize.txt" import PROMPT_INITIALIZE from "./command/initialize.txt"
import PROMPT_REVIEW from "./command/review.txt" import PROMPT_REVIEW from "./command/review.txt"
export const Plugin = PluginV2.define({ export const Plugin = define({
id: PluginV2.ID.make("command"), id: "command",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
const command = yield* CommandV2.Service
const location = yield* Location.Service const location = yield* Location.Service
const transform = yield* command.transform() yield* ctx.command.transform((draft) => {
draft.update("init", (command) => {
yield* transform((editor) => {
editor.update("init", (command) => {
command.template = PROMPT_INITIALIZE.replace("${path}", location.project.directory) command.template = PROMPT_INITIALIZE.replace("${path}", location.project.directory)
command.description = "guided AGENTS.md setup" command.description = "guided AGENTS.md setup"
}) })
editor.update("review", (command) => { draft.update("review", (command) => {
command.template = PROMPT_REVIEW.replace("${path}", location.project.directory) command.template = PROMPT_REVIEW.replace("${path}", location.project.directory)
command.description = "review changes [commit|branch|pr], defaults to uncommitted" command.description = "review changes [commit|branch|pr], defaults to uncommitted"
command.subtask = true command.subtask = true
+154
View File
@@ -0,0 +1,154 @@
export * as PluginHost from "./host"
import type { PluginContext as Interface } from "@opencode-ai/plugin/v2/effect"
import { Effect, Schema } from "effect"
import { AgentV2 } from "../agent"
import { AISDK } from "../aisdk"
import { Catalog } from "../catalog"
import { CommandV2 } from "../command"
import { Integration } from "../integration"
import { ModelV2 } from "../model"
import { PluginV2 } from "../plugin"
import { ProviderV2 } from "../provider"
import { Reference } from "../reference"
import { SkillV2 } from "../skill"
export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) {
const agents = yield* AgentV2.Service
const aisdk = yield* AISDK.Service
const catalog = yield* Catalog.Service
const commands = yield* CommandV2.Service
const integration = yield* Integration.Service
const reference = yield* Reference.Service
const skill = yield* SkillV2.Service
return {
options: {},
agent: {
reload: agents.reload,
transform: (callback) =>
agents.transform((draft) =>
callback({
list: draft.list,
get: (id) => draft.get(AgentV2.ID.make(id)),
default: (id) => draft.default(id === undefined ? undefined : AgentV2.ID.make(id)),
update: (id, update) => draft.update(AgentV2.ID.make(id), update),
remove: (id) => draft.remove(AgentV2.ID.make(id)),
}),
),
},
aisdk: {
sdk: (callback) =>
aisdk.hook.sdk((event) => {
const output = {
model: event.model,
package: event.package,
options: event.options,
sdk: event.sdk,
}
const result = callback(output)
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))),
)
}),
language: (callback) =>
aisdk.hook.language((event) => {
const output = {
model: event.model,
sdk: event.sdk,
options: event.options,
language: event.language,
}
const result = callback(output)
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
Effect.tap(() => Effect.sync(() => (event.language = output.language))),
)
}),
},
catalog: {
reload: catalog.reload,
transform: (callback) =>
catalog.transform((draft) =>
callback({
provider: {
list: draft.provider.list,
get: (id) => draft.provider.get(ProviderV2.ID.make(id)),
update: (id, update) => draft.provider.update(ProviderV2.ID.make(id), update),
remove: (id) => draft.provider.remove(ProviderV2.ID.make(id)),
},
model: {
get: (providerID, modelID) => draft.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
update: (providerID, modelID, update) =>
draft.model.update(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID), update),
remove: (providerID, modelID) =>
draft.model.remove(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
default: {
get: draft.model.default.get,
set: (providerID, modelID) =>
draft.model.default.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
},
},
}),
),
},
command: {
reload: commands.reload,
transform: commands.transform,
},
integration: {
reload: integration.reload,
transform: (callback) =>
integration.transform((draft) =>
callback({
list: draft.list,
get: (id) => draft.get(Integration.ID.make(id)),
update: (id, update) => draft.update(Integration.ID.make(id), update),
remove: (id) => draft.remove(Integration.ID.make(id)),
method: {
list: (id) => draft.method.list(Integration.ID.make(id)),
update: (input) => {
if (input.method.type === "env") {
draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: { type: "env", names: input.method.names },
})
return
}
draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: { type: "key", label: input.method.label },
})
},
remove: (id, method) =>
draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)),
},
}),
),
},
plugin: {
add: (input) => plugin.add(PluginV2.ID.make(input.id), input.effect),
remove: (id) => plugin.remove(PluginV2.ID.make(id)),
},
reference: {
reload: reference.reload,
transform: (callback) =>
reference.transform((draft) =>
callback({
add: (name, source) => draft.add(name, Schema.decodeUnknownSync(Reference.Source)(source)),
remove: draft.remove,
list: draft.list,
}),
),
},
skill: {
reload: skill.reload,
transform: (callback) =>
skill.transform((draft) =>
callback({
source: (source) => draft.source(Schema.decodeUnknownSync(SkillV2.Source)(source)),
list: draft.list,
}),
),
},
} satisfies Interface
})
+118
View File
@@ -0,0 +1,118 @@
export * as PluginInternal from "./internal"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { Effect, Layer, Scope } from "effect"
import { AgentV2 } from "../agent"
import { Catalog } from "../catalog"
import { CommandV2 } from "../command"
import { Config } from "../config"
import { ConfigAgentPlugin } from "../config/plugin/agent"
import { ConfigCommandPlugin } from "../config/plugin/command"
import { ConfigExternalPlugin } from "../config/plugin/external"
import { ConfigProviderPlugin } from "../config/plugin/provider"
import { ConfigReferencePlugin } from "../config/plugin/reference"
import { ConfigSkillPlugin } from "../config/plugin/skill"
import { EventV2 } from "../event"
import { FileSystem } from "../filesystem"
import { FSUtil } from "../fs-util"
import { Global } from "../global"
import { Integration } from "../integration"
import { Location } from "../location"
import { ModelsDev } from "../models-dev"
import { Npm } from "../npm"
import { PluginV2 } from "../plugin"
import { Reference } from "../reference"
import { SkillV2 } from "../skill"
import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
import { SkillPlugin } from "./skill"
export type Requirements =
| AgentV2.Service
| Catalog.Service
| CommandV2.Service
| Config.Service
| EventV2.Service
| FileSystem.Service
| FSUtil.Service
| Global.Service
| Integration.Service
| Location.Service
| ModelsDev.Service
| Npm.Service
| Reference.Service
| SkillV2.Service
export interface Plugin<R = never> {
readonly id: string
readonly effect: (context: PluginContext) => Effect.Effect<void, never, R | Scope.Scope>
}
export function define<R>(plugin: Plugin<R>) {
return plugin
}
export const locationLayer = Layer.effectDiscard(
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const commands = yield* CommandV2.Service
const plugin = yield* PluginV2.Service
const integration = yield* Integration.Service
const agents = yield* AgentV2.Service
const config = yield* Config.Service
const location = yield* Location.Service
const modelsDev = yield* ModelsDev.Service
const npm = yield* Npm.Service
const events = yield* EventV2.Service
const fs = yield* FSUtil.Service
const filesystem = yield* FileSystem.Service
const global = yield* Global.Service
const skill = yield* SkillV2.Service
const reference = yield* Reference.Service
const add = <R>(input: Plugin<R>) => {
const loaded = {
id: input.id,
effect: (context: PluginContext) =>
input
.effect(context)
.pipe(
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(CommandV2.Service, commands),
Effect.provideService(Integration.Service, integration),
Effect.provideService(AgentV2.Service, agents),
Effect.provideService(Config.Service, config),
Effect.provideService(Location.Service, location),
Effect.provideService(ModelsDev.Service, modelsDev),
Effect.provideService(Npm.Service, npm),
Effect.provideService(EventV2.Service, events),
Effect.provideService(FSUtil.Service, fs),
Effect.provideService(FileSystem.Service, filesystem),
Effect.provideService(Global.Service, global),
Effect.provideService(SkillV2.Service, skill),
Effect.provideService(Reference.Service, reference),
),
}
return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect)
}
yield* Effect.gen(function* () {
yield* add(ConfigReferencePlugin.Plugin)
yield* add(AgentPlugin.Plugin)
yield* add(CommandPlugin.Plugin)
yield* add(SkillPlugin.Plugin)
yield* add(ModelsDevPlugin)
yield* add(ConfigProviderPlugin.Plugin)
yield* add(ConfigAgentPlugin.Plugin)
yield* add(ConfigCommandPlugin.Plugin)
yield* add(ConfigSkillPlugin.Plugin)
for (const item of ProviderPlugins) yield* add(item)
yield* add(ConfigExternalPlugin.Plugin)
}).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
}),
).pipe(
Layer.provideMerge(PluginV2.locationLayer),
Layer.provideMerge(Config.locationLayer),
Layer.provideMerge(FileSystem.locationLayer),
)
+17 -22
View File
@@ -1,16 +1,14 @@
import { DateTime, Effect, Scope, Stream } from "effect" import { define } from "./internal"
import { Catalog } from "../catalog" import { Effect, Stream } from "effect"
import { Integration } from "../integration"
import { EventV2 } from "../event" import { EventV2 } from "../event"
import { ModelV2 } from "../model" import { ModelV2 } from "../model"
import { ModelRequest } from "../model-request" import { ModelRequest } from "../model-request"
import { ModelsDev } from "../models-dev" import { ModelsDev } from "../models-dev"
import { PluginV2 } from "../plugin"
import { ProviderV2 } from "../provider" import { ProviderV2 } from "../provider"
function released(date: string) { function released(date: string) {
const time = Date.parse(date) const time = Date.parse(date)
return DateTime.makeUnsafe(Number.isFinite(time) ? time : 0) return Number.isFinite(time) ? time : 0
} }
function cost(input: ModelsDev.Model["cost"]) { function cost(input: ModelsDev.Model["cost"]) {
@@ -51,22 +49,17 @@ function variants(model: ModelsDev.Model, packageName?: string) {
}) })
} }
export const ModelsDevPlugin = PluginV2.define({ export const ModelsDevPlugin = define({
id: PluginV2.ID.make("models-dev"), id: "models-dev",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const modelsDev = yield* ModelsDev.Service const modelsDev = yield* ModelsDev.Service
const events = yield* EventV2.Service const events = yield* EventV2.Service
const scope = yield* Scope.Scope yield* ctx.integration.transform(
const transform = yield* catalog.transform() Effect.fn(function* (integrations) {
const integrationTransform = yield* integrations.transform()
const refresh = Effect.fn("ModelsDevPlugin.refresh")(function* () {
const data = yield* modelsDev.get() const data = yield* modelsDev.get()
yield* integrationTransform((integrations) => {
for (const item of Object.values(data)) { for (const item of Object.values(data)) {
if (item.env.length === 0) continue if (item.env.length === 0) continue
const integrationID = Integration.ID.make(item.id) const integrationID = item.id
integrations.update(integrationID, (integration) => (integration.name = item.name)) integrations.update(integrationID, (integration) => (integration.name = item.name))
integrations.method.update({ integrations.method.update({
integrationID, integrationID,
@@ -77,8 +70,11 @@ export const ModelsDevPlugin = PluginV2.define({
method: { type: "env", names: [...item.env] }, method: { type: "env", names: [...item.env] },
}) })
} }
}) }),
yield* transform((catalog) => { )
yield* ctx.catalog.transform(
Effect.fn(function* (catalog) {
const data = yield* modelsDev.get()
for (const item of Object.values(data)) { 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) => {
@@ -132,11 +128,10 @@ export const ModelsDevPlugin = PluginV2.define({
}) })
} }
} }
}) }),
}) )
yield* refresh()
yield* events.subscribe(ModelsDev.Event.Refreshed).pipe( yield* events.subscribe(ModelsDev.Event.Refreshed).pipe(
Stream.runForEach(() => refresh()), Stream.runForEach(() => ctx.integration.reload().pipe(Effect.andThen(ctx.catalog.reload()))),
Effect.forkScoped({ startImmediately: true }), Effect.forkScoped({ startImmediately: true }),
) )
}), }),
+89
View File
@@ -0,0 +1,89 @@
export * as PluginPromise from "./promise"
import { define } from "@opencode-ai/plugin/v2/effect"
import type { Plugin, PluginContext, Registration } from "@opencode-ai/plugin/v2/promise"
import { Effect, Scope } from "effect"
// 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> }
/**
* Adapts a Promise plugin into an Effect plugin so the existing Effect-only
* loader (`PluginV2` / `PluginInternal`) can run it unchanged.
*
* Hook registrations created during the async `setup` attach to the plugin's
* scope, so unloading the plugin disposes them. The captured fiber context
* preserves boot-time batching, so Promise-plugin transforms still coalesce
* into one reload per domain.
*/
export function fromPromise(plugin: Plugin) {
return define({
id: plugin.id,
effect: (host) =>
Effect.gen(function* () {
const scope = yield* Scope.Scope
const context = yield* Effect.context<Scope.Scope>()
// Run a hook registration on the plugin scope and resolve once it is registered.
const register = (effect: Effect.Effect<HostRegistration, never, Scope.Scope>): Promise<Registration> =>
Effect.runPromiseWith(context)(Scope.provide(scope)(effect)).then((registration) => ({
dispose: () => Effect.runPromiseWith(context)(registration.dispose),
}))
const run = (effect: Effect.Effect<void>) => Effect.runPromiseWith(context)(effect)
const transform =
<Draft>(domain: {
transform: (
callback: (draft: Draft) => Effect.Effect<void> | void,
) => Effect.Effect<HostRegistration, never, Scope.Scope>
}) =>
(callback: (draft: Draft) => Promise<void> | void) =>
register(domain.transform((draft) => Effect.promise(() => Promise.resolve(callback(draft)))))
const context2: PluginContext = {
options: host.options,
agent: {
transform: transform(host.agent),
reload: () => run(host.agent.reload()),
},
aisdk: {
sdk: (callback) =>
register(host.aisdk.sdk((event) => Effect.promise(() => Promise.resolve(callback(event))))),
language: (callback) =>
register(host.aisdk.language((event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
catalog: {
transform: transform(host.catalog),
reload: () => run(host.catalog.reload()),
},
command: {
transform: transform(host.command),
reload: () => run(host.command.reload()),
},
integration: {
transform: transform(host.integration),
reload: () => run(host.integration.reload()),
},
plugin: {
add: (input) => {
const child = fromPromise(input)
return run(host.plugin.add(child))
},
remove: (id) => run(host.plugin.remove(id)),
},
reference: {
transform: transform(host.reference),
reload: () => run(host.reference.reload()),
},
skill: {
transform: transform(host.skill),
reload: () => run(host.skill.reload()),
},
}
yield* Effect.promise(() => Promise.resolve(plugin.setup(context2)))
}),
})
}
+3 -1
View File
@@ -30,8 +30,10 @@ import { VercelPlugin } from "./provider/vercel"
import { VenicePlugin } from "./provider/venice" import { VenicePlugin } from "./provider/venice"
import { XAIPlugin } from "./provider/xai" import { XAIPlugin } from "./provider/xai"
import { ZenmuxPlugin } from "./provider/zenmux" import { ZenmuxPlugin } from "./provider/zenmux"
import type { PluginInternal } from "./internal"
import type { Scope } from "effect"
export const ProviderPlugins = [ export const ProviderPlugins: PluginInternal.Plugin<PluginInternal.Requirements | Scope.Scope>[] = [
AlibabaPlugin, AlibabaPlugin,
AmazonBedrockPlugin, AmazonBedrockPlugin,
AnthropicPlugin, AnthropicPlugin,
+7 -7
View File
@@ -1,15 +1,15 @@
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../../plugin" import { define } from "../internal"
export const AlibabaPlugin = PluginV2.define({ export const AlibabaPlugin = define({
id: PluginV2.ID.make("alibaba"), id: "alibaba",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
return { yield* ctx.aisdk.sdk(
"aisdk.sdk": Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/alibaba") return if (evt.package !== "@ai-sdk/alibaba") return
const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba")) const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba"))
evt.sdk = mod.createAlibaba(evt.options) evt.sdk = mod.createAlibaba(evt.options)
}), }),
} )
}), }),
}) })
@@ -1,6 +1,6 @@
import { Effect } from "effect" import { Effect } from "effect"
import type { LanguageModelV3 } from "@ai-sdk/provider" import type { LanguageModelV3 } from "@ai-sdk/provider"
import { PluginV2 } from "../../plugin" import { define } from "../internal"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
type MantleSDK = { type MantleSDK = {
@@ -59,11 +59,11 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) {
return sdk.responses(modelID) return sdk.responses(modelID)
} }
export const AmazonBedrockPlugin = PluginV2.define({ export const AmazonBedrockPlugin = define({
id: PluginV2.ID.make("amazon-bedrock"), id: "amazon-bedrock",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
return { yield* ctx.catalog.transform(
"catalog.transform": Effect.fn(function* (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
@@ -77,7 +77,9 @@ export const AmazonBedrockPlugin = PluginV2.define({
}) })
} }
}), }),
"aisdk.sdk": Effect.fn(function* (evt) { )
yield* ctx.aisdk.sdk(
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
const options = { ...evt.options } const options = { ...evt.options }
const profile = typeof options.profile === "string" ? options.profile : process.env.AWS_PROFILE const profile = typeof options.profile === "string" ? options.profile : process.env.AWS_PROFILE
@@ -108,7 +110,9 @@ export const AmazonBedrockPlugin = PluginV2.define({
const mod = yield* Effect.promise(() => import("@ai-sdk/amazon-bedrock")) const mod = yield* Effect.promise(() => import("@ai-sdk/amazon-bedrock"))
evt.sdk = mod.createAmazonBedrock(options) evt.sdk = mod.createAmazonBedrock(options)
}), }),
"aisdk.language": Effect.fn(function* (evt) { )
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.amazonBedrock) return if (evt.model.providerID !== ProviderV2.ID.amazonBedrock) return
if (evt.model.api.type === "aisdk" && evt.model.api.package === "@ai-sdk/amazon-bedrock/mantle") { if (evt.model.api.type === "aisdk" && evt.model.api.package === "@ai-sdk/amazon-bedrock/mantle") {
evt.language = selectMantleModel(evt.sdk, evt.model.api.id) evt.language = selectMantleModel(evt.sdk, evt.model.api.id)
@@ -117,6 +121,6 @@ export const AmazonBedrockPlugin = PluginV2.define({
const region = typeof evt.options.region === "string" ? evt.options.region : process.env.AWS_REGION const region = typeof evt.options.region === "string" ? evt.options.region : process.env.AWS_REGION
evt.language = evt.sdk.languageModel(resolveModelID(evt.model.api.id, region)) evt.language = evt.sdk.languageModel(resolveModelID(evt.model.api.id, region))
}), }),
} )
}), }),
}) })
+10 -8
View File
@@ -1,11 +1,11 @@
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../../plugin" import { define } from "../internal"
export const AnthropicPlugin = PluginV2.define({ export const AnthropicPlugin = define({
id: PluginV2.ID.make("anthropic"), id: "anthropic",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
return { yield* ctx.catalog.transform(
"catalog.transform": Effect.fn(function* (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
@@ -15,11 +15,13 @@ export const AnthropicPlugin = PluginV2.define({
}) })
} }
}), }),
"aisdk.sdk": Effect.fn(function* (evt) { )
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/anthropic") return if (evt.package !== "@ai-sdk/anthropic") return
const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic")) const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic"))
evt.sdk = mod.createAnthropic(evt.options) evt.sdk = mod.createAnthropic(evt.options)
}), }),
} )
}), }),
}) })
+22 -16
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../../plugin" import { define } from "../internal"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
function selectLanguage(sdk: any, modelID: string, useChat: boolean) { function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
@@ -10,11 +10,11 @@ function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
return sdk.languageModel(modelID) return sdk.languageModel(modelID)
} }
export const AzurePlugin = PluginV2.define({ export const AzurePlugin = define({
id: PluginV2.ID.make("azure"), id: "azure",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
return { yield* ctx.catalog.transform(
"catalog.transform": Effect.fn(function* (evt) { Effect.fn(function* (evt) {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/azure") continue if (item.provider.api.package !== "@ai-sdk/azure") continue
@@ -27,7 +27,9 @@ export const AzurePlugin = PluginV2.define({
}) })
} }
}), }),
"aisdk.sdk": Effect.fn(function* (evt) { )
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/azure") return if (evt.package !== "@ai-sdk/azure") return
if (evt.model.providerID === ProviderV2.ID.azure) { if (evt.model.providerID === ProviderV2.ID.azure) {
if ( if (
@@ -43,19 +45,21 @@ export const AzurePlugin = PluginV2.define({
const mod = yield* Effect.promise(() => import("@ai-sdk/azure")) const mod = yield* Effect.promise(() => import("@ai-sdk/azure"))
evt.sdk = mod.createAzure(evt.options) evt.sdk = mod.createAzure(evt.options)
}), }),
"aisdk.language": Effect.fn(function* (evt) { )
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.azure) return if (evt.model.providerID !== ProviderV2.ID.azure) return
evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls)) evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls))
}), }),
} )
}), }),
}) })
export const AzureCognitiveServicesPlugin = PluginV2.define({ export const AzureCognitiveServicesPlugin = define({
id: PluginV2.ID.make("azure-cognitive-services"), id: "azure-cognitive-services",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
return { yield* ctx.catalog.transform(
"catalog.transform": Effect.fn(function* (evt) { Effect.fn(function* (evt) {
const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
if (!resourceName) return if (!resourceName) return
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
@@ -67,10 +71,12 @@ export const AzureCognitiveServicesPlugin = PluginV2.define({
}) })
} }
}), }),
"aisdk.language": Effect.fn(function* (evt) { )
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return
evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls)) evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls))
}), }),
} )
}), }),
}) })
+12 -10
View File
@@ -1,24 +1,26 @@
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../../plugin" import { define } from "../internal"
export const CerebrasPlugin = PluginV2.define({ export const CerebrasPlugin = define({
id: PluginV2.ID.make("cerebras"), id: "cerebras",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
return { yield* ctx.catalog.transform(
"catalog.transform": Effect.fn(function* (ctx) { Effect.fn(function* (evt) {
for (const item of ctx.provider.list()) { for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/cerebras") continue if (item.provider.api.package !== "@ai-sdk/cerebras") continue
ctx.provider.update(item.provider.id, (provider) => { evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["X-Cerebras-3rd-Party-Integration"] = "opencode" provider.request.headers["X-Cerebras-3rd-Party-Integration"] = "opencode"
}) })
} }
}), }),
"aisdk.sdk": Effect.fn(function* (evt) { )
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/cerebras") return if (evt.package !== "@ai-sdk/cerebras") return
const mod = yield* Effect.promise(() => import("@ai-sdk/cerebras")) const mod = yield* Effect.promise(() => import("@ai-sdk/cerebras"))
evt.sdk = mod.createCerebras(evt.options) evt.sdk = mod.createCerebras(evt.options)
}), }),
} )
}), }),
}) })
@@ -1,13 +1,13 @@
import os from "os" import os from "os"
import { InstallationVersion } from "../../installation/version" import { InstallationVersion } from "../../installation/version"
import { Effect, Option, Schema } from "effect" import { Effect, Option, Schema } from "effect"
import { PluginV2 } from "../../plugin" import { define } from "../internal"
export const CloudflareAIGatewayPlugin = PluginV2.define({ export const CloudflareAIGatewayPlugin = define({
id: PluginV2.ID.make("cloudflare-ai-gateway"), id: "cloudflare-ai-gateway",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
return { yield* ctx.aisdk.sdk(
"aisdk.sdk": Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "ai-gateway-provider") return if (evt.package !== "ai-gateway-provider") return
if (evt.options.baseURL) return if (evt.options.baseURL) return
@@ -31,7 +31,7 @@ export const CloudflareAIGatewayPlugin = PluginV2.define({
}, },
} }
}), }),
} )
}), }),
}) })
@@ -1,16 +1,16 @@
import os from "os" import os from "os"
import { InstallationVersion } from "../../installation/version" import { InstallationVersion } from "../../installation/version"
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../../plugin" import { define } from "../internal"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
const providerID = ProviderV2.ID.make("cloudflare-workers-ai") const providerID = ProviderV2.ID.make("cloudflare-workers-ai")
export const CloudflareWorkersAIPlugin = PluginV2.define({ export const CloudflareWorkersAIPlugin = define({
id: PluginV2.ID.make("cloudflare-workers-ai"), id: "cloudflare-workers-ai",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
return { yield* ctx.catalog.transform(
"catalog.transform": Effect.fn(function* (evt) { Effect.fn(function* (evt) {
const item = evt.provider.get(providerID) const item = evt.provider.get(providerID)
if (!item) return if (!item) return
evt.provider.update(item.provider.id, (provider) => { evt.provider.update(item.provider.id, (provider) => {
@@ -20,7 +20,9 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({
if (accountId) provider.api.url = workersEndpoint(accountId) if (accountId) provider.api.url = workersEndpoint(accountId)
}) })
}), }),
"aisdk.sdk": Effect.fn(function* (evt) { )
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.model.providerID !== providerID) return if (evt.model.providerID !== providerID) return
if (evt.package !== "@ai-sdk/openai-compatible") return if (evt.package !== "@ai-sdk/openai-compatible") return
@@ -34,11 +36,13 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({
}) as any, }) as any,
) )
}), }),
"aisdk.language": Effect.fn(function* (evt) { )
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== providerID) return if (evt.model.providerID !== providerID) return
evt.language = evt.sdk.languageModel(evt.model.api.id) evt.language = evt.sdk.languageModel(evt.model.api.id)
}), }),
} )
}), }),
}) })
+7 -7
View File
@@ -1,15 +1,15 @@
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../../plugin" import { define } from "../internal"
export const CoherePlugin = PluginV2.define({ export const CoherePlugin = define({
id: PluginV2.ID.make("cohere"), id: "cohere",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
return { yield* ctx.aisdk.sdk(
"aisdk.sdk": Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/cohere") return if (evt.package !== "@ai-sdk/cohere") return
const mod = yield* Effect.promise(() => import("@ai-sdk/cohere")) const mod = yield* Effect.promise(() => import("@ai-sdk/cohere"))
evt.sdk = mod.createCohere(evt.options) evt.sdk = mod.createCohere(evt.options)
}), }),
} )
}), }),
}) })
@@ -1,15 +1,15 @@
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../../plugin" import { define } from "../internal"
export const DeepInfraPlugin = PluginV2.define({ export const DeepInfraPlugin = define({
id: PluginV2.ID.make("deepinfra"), id: "deepinfra",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
return { yield* ctx.aisdk.sdk(
"aisdk.sdk": Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/deepinfra") return if (evt.package !== "@ai-sdk/deepinfra") return
const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra")) const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra"))
evt.sdk = mod.createDeepInfra(evt.options) evt.sdk = mod.createDeepInfra(evt.options)
}), }),
} )
}), }),
}) })
+10 -10
View File
@@ -1,19 +1,19 @@
import { Npm } from "../../npm" import { Effect } from "effect"
import { Effect, Option } from "effect"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { PluginV2 } from "../../plugin" import { define } from "../internal"
import { Npm } from "../../npm"
export const DynamicProviderPlugin = PluginV2.define({ export const DynamicProviderPlugin = define({
id: PluginV2.ID.make("dynamic-provider"), id: "dynamic-provider",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
const npm = yield* Npm.Service const npm = yield* Npm.Service
return { yield* ctx.aisdk.sdk(
"aisdk.sdk": Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.sdk) return if (evt.sdk) return
const installedPath = evt.package.startsWith("file://") const installedPath = evt.package.startsWith("file://")
? evt.package ? evt.package
: Option.getOrUndefined((yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint) : (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint
if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`) if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`)
const mod = yield* Effect.promise(async () => { const mod = yield* Effect.promise(async () => {
@@ -26,6 +26,6 @@ export const DynamicProviderPlugin = PluginV2.define({
evt.sdk = mod[match](evt.options) evt.sdk = mod[match](evt.options)
}), }),
} )
}), }),
}) })
+7 -7
View File
@@ -1,15 +1,15 @@
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../../plugin" import { define } from "../internal"
export const GatewayPlugin = PluginV2.define({ export const GatewayPlugin = define({
id: PluginV2.ID.make("gateway"), id: "gateway",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
return { yield* ctx.aisdk.sdk(
"aisdk.sdk": Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/gateway") return if (evt.package !== "@ai-sdk/gateway") return
const mod = yield* Effect.promise(() => import("@ai-sdk/gateway")) const mod = yield* Effect.promise(() => import("@ai-sdk/gateway"))
evt.sdk = mod.createGateway(evt.options) evt.sdk = mod.createGateway(evt.options)
}), }),
} )
}), }),
}) })
@@ -1,6 +1,6 @@
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
import { PluginV2 } from "../../plugin" import { define } from "../internal"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
function shouldUseResponses(modelID: string) { function shouldUseResponses(modelID: string) {
@@ -11,16 +11,29 @@ function shouldUseResponses(modelID: string) {
return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini") return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")
} }
export const GithubCopilotPlugin = PluginV2.define({ export const GithubCopilotPlugin = define({
id: PluginV2.ID.make("github-copilot"), id: "github-copilot",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
return { yield* ctx.catalog.transform(
"aisdk.sdk": Effect.fn(function* (evt) { Effect.fn(function* (evt) {
const item = evt.provider.get(ProviderV2.ID.githubCopilot)
if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
// This chat-only alias conflicts with the Copilot GPT-5 Responses route,
// so hide it only for Copilot rather than for every provider catalog.
model.enabled = false
})
}),
)
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/github-copilot") return if (evt.package !== "@ai-sdk/github-copilot") return
const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider")) const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider"))
evt.sdk = mod.createOpenaiCompatible(evt.options) evt.sdk = mod.createOpenaiCompatible(evt.options)
}), }),
"aisdk.language": Effect.fn(function* (evt) { )
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return
if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) { if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) {
evt.language = evt.sdk.languageModel(evt.model.api.id) evt.language = evt.sdk.languageModel(evt.model.api.id)
@@ -30,15 +43,6 @@ export const GithubCopilotPlugin = PluginV2.define({
? evt.sdk.responses(evt.model.api.id) ? evt.sdk.responses(evt.model.api.id)
: evt.sdk.chat(evt.model.api.id) : evt.sdk.chat(evt.model.api.id)
}), }),
"catalog.transform": Effect.fn(function* (evt) { )
const item = evt.provider.get(ProviderV2.ID.githubCopilot)
if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
// This chat-only alias conflicts with the Copilot GPT-5 Responses route,
// so hide it only for Copilot rather than for every provider catalog.
model.enabled = false
})
}),
}
}), }),
}) })
+10 -8
View File
@@ -1,14 +1,14 @@
import os from "os" import os from "os"
import { InstallationVersion } from "../../installation/version" import { InstallationVersion } from "../../installation/version"
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../../plugin" import { define } from "../internal"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
export const GitLabPlugin = PluginV2.define({ export const GitLabPlugin = define({
id: PluginV2.ID.make("gitlab"), id: "gitlab",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
return { yield* ctx.aisdk.sdk(
"aisdk.sdk": Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "gitlab-ai-provider") return if (evt.package !== "gitlab-ai-provider") return
const mod = yield* Effect.promise(() => import("gitlab-ai-provider")) const mod = yield* Effect.promise(() => import("gitlab-ai-provider"))
evt.sdk = mod.createGitLab({ evt.sdk = mod.createGitLab({
@@ -30,7 +30,9 @@ export const GitLabPlugin = PluginV2.define({
}, },
}) })
}), }),
"aisdk.language": Effect.fn(function* (evt) { )
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.gitlab) return if (evt.model.providerID !== ProviderV2.ID.gitlab) return
const featureFlags = const featureFlags =
typeof evt.options.featureFlags === "object" && evt.options.featureFlags ? evt.options.featureFlags : {} typeof evt.options.featureFlags === "object" && evt.options.featureFlags ? evt.options.featureFlags : {}
@@ -58,6 +60,6 @@ export const GitLabPlugin = PluginV2.define({
featureFlags, featureFlags,
}) })
}), }),
} )
}), }),
}) })
@@ -1,5 +1,5 @@
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../../plugin" import { define } from "../internal"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
function resolveProject(options: Record<string, any>) { function resolveProject(options: Record<string, any>) {
@@ -54,11 +54,11 @@ function authFetch(fetchWithRuntimeOptions?: unknown) {
} }
} }
export const GoogleVertexPlugin = PluginV2.define({ export const GoogleVertexPlugin = define({
id: PluginV2.ID.make("google-vertex"), id: "google-vertex",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
return { yield* ctx.catalog.transform(
"catalog.transform": Effect.fn(function* (evt) { Effect.fn(function* (evt) {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.type !== "aisdk") continue
if ( if (
@@ -83,7 +83,9 @@ export const GoogleVertexPlugin = PluginV2.define({
}) })
} }
}), }),
"aisdk.sdk": Effect.fn(function* (evt) { )
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) { if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) {
evt.options.fetch = authFetch(evt.options.fetch) evt.options.fetch = authFetch(evt.options.fetch)
return return
@@ -100,19 +102,21 @@ export const GoogleVertexPlugin = PluginV2.define({
location, location,
}) })
}), }),
"aisdk.language": Effect.fn(function* (evt) { )
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.googleVertex) return if (evt.model.providerID !== ProviderV2.ID.googleVertex) return
evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim()) evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim())
}), }),
} )
}), }),
}) })
export const GoogleVertexAnthropicPlugin = PluginV2.define({ export const GoogleVertexAnthropicPlugin = define({
id: PluginV2.ID.make("google-vertex-anthropic"), id: "google-vertex-anthropic",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
return { yield* ctx.catalog.transform(
"catalog.transform": Effect.fn(function* (evt) { Effect.fn(function* (evt) {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/google-vertex/anthropic") continue if (item.provider.api.package !== "@ai-sdk/google-vertex/anthropic") continue
@@ -132,7 +136,9 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({
}) })
} }
}), }),
"aisdk.sdk": Effect.fn(function* (evt) { )
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/google-vertex/anthropic") return if (evt.package !== "@ai-sdk/google-vertex/anthropic") return
const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic")) const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic"))
const project = const project =
@@ -156,10 +162,12 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({
: {}), : {}),
}) })
}), }),
"aisdk.language": Effect.fn(function* (evt) { )
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("google-vertex-anthropic")) return if (evt.model.providerID !== ProviderV2.ID.make("google-vertex-anthropic")) return
evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim()) evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim())
}), }),
} )
}), }),
}) })
+7 -7
View File
@@ -1,15 +1,15 @@
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../../plugin" import { define } from "../internal"
export const GooglePlugin = PluginV2.define({ export const GooglePlugin = define({
id: PluginV2.ID.make("google"), id: "google",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
return { yield* ctx.aisdk.sdk(
"aisdk.sdk": Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/google") return if (evt.package !== "@ai-sdk/google") return
const mod = yield* Effect.promise(() => import("@ai-sdk/google")) const mod = yield* Effect.promise(() => import("@ai-sdk/google"))
evt.sdk = mod.createGoogleGenerativeAI(evt.options) evt.sdk = mod.createGoogleGenerativeAI(evt.options)
}), }),
} )
}), }),
}) })
+7 -7
View File
@@ -1,15 +1,15 @@
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../../plugin" import { define } from "../internal"
export const GroqPlugin = PluginV2.define({ export const GroqPlugin = define({
id: PluginV2.ID.make("groq"), id: "groq",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
return { yield* ctx.aisdk.sdk(
"aisdk.sdk": Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/groq") return if (evt.package !== "@ai-sdk/groq") return
const mod = yield* Effect.promise(() => import("@ai-sdk/groq")) const mod = yield* Effect.promise(() => import("@ai-sdk/groq"))
evt.sdk = mod.createGroq(evt.options) evt.sdk = mod.createGroq(evt.options)
}), }),
} )
}), }),
}) })
+7 -7
View File
@@ -1,11 +1,11 @@
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../../plugin" import { define } from "../internal"
export const KiloPlugin = PluginV2.define({ export const KiloPlugin = define({
id: PluginV2.ID.make("kilo"), id: "kilo",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
return { yield* ctx.catalog.transform(
"catalog.transform": Effect.fn(function* (evt) { Effect.fn(function* (evt) {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
@@ -16,6 +16,6 @@ export const KiloPlugin = PluginV2.define({
}) })
} }
}), }),
} )
}), }),
}) })
@@ -1,19 +1,19 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "../internal"
import { Integration } from "../../integration" import { Integration } from "../../integration"
import { PluginV2 } from "../../plugin"
export const LLMGatewayPlugin = PluginV2.define({ export const LLMGatewayPlugin = define({
id: PluginV2.ID.make("llmgateway"), id: "llmgateway",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
return { yield* ctx.catalog.transform(
"catalog.transform": Effect.fn(function* (evt) { Effect.fn(function* (evt) {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
if (item.provider.disabled) continue if (item.provider.disabled) continue
if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue
if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue
evt.provider.update(item.provider.id, (provider) => { evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/" provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.request.headers["X-Title"] = "opencode" provider.request.headers["X-Title"] = "opencode"
@@ -21,6 +21,6 @@ export const LLMGatewayPlugin = PluginV2.define({
}) })
} }
}), }),
} )
}), }),
}) })
+7 -7
View File
@@ -1,15 +1,15 @@
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../../plugin" import { define } from "../internal"
export const MistralPlugin = PluginV2.define({ export const MistralPlugin = define({
id: PluginV2.ID.make("mistral"), id: "mistral",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
return { yield* ctx.aisdk.sdk(
"aisdk.sdk": Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/mistral") return if (evt.package !== "@ai-sdk/mistral") return
const mod = yield* Effect.promise(() => import("@ai-sdk/mistral")) const mod = yield* Effect.promise(() => import("@ai-sdk/mistral"))
evt.sdk = mod.createMistral(evt.options) evt.sdk = mod.createMistral(evt.options)
}), }),
} )
}), }),
}) })
+7 -7
View File
@@ -1,11 +1,11 @@
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../../plugin" import { define } from "../internal"
export const NvidiaPlugin = PluginV2.define({ export const NvidiaPlugin = define({
id: PluginV2.ID.make("nvidia"), id: "nvidia",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
return { yield* ctx.catalog.transform(
"catalog.transform": Effect.fn(function* (evt) { Effect.fn(function* (evt) {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
@@ -17,6 +17,6 @@ export const NvidiaPlugin = PluginV2.define({
}) })
} }
}), }),
} )
}), }),
}) })
@@ -1,17 +1,17 @@
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../../plugin" import { define } from "../internal"
export const OpenAICompatiblePlugin = PluginV2.define({ export const OpenAICompatiblePlugin = define({
id: PluginV2.ID.make("openai-compatible"), id: "openai-compatible",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
return { yield* ctx.aisdk.sdk(
"aisdk.sdk": Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.sdk) return if (evt.sdk) return
if (!evt.package.includes("@ai-sdk/openai-compatible")) return if (!evt.package.includes("@ai-sdk/openai-compatible")) return
if (evt.options.includeUsage !== false) evt.options.includeUsage = true if (evt.options.includeUsage !== false) evt.options.includeUsage = true
const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible")) const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible"))
evt.sdk = mod.createOpenAICompatible(evt.options as any) evt.sdk = mod.createOpenAICompatible(evt.options as any)
}), }),
} )
}), }),
}) })
+23 -19
View File
@@ -1,29 +1,20 @@
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
import { PluginV2 } from "../../plugin" import { define } from "../internal"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
import { Integration } from "../../integration" import { Integration } from "../../integration"
import { browser, headless } from "./openai-auth" import { browser, headless } from "./openai-auth"
export const OpenAIPlugin = PluginV2.define({ export const OpenAIPlugin = define({
id: PluginV2.ID.make("openai"), id: "openai",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
yield* integrations.update((editor) => { yield* integrations.transform((draft) => {
editor.method.update(browser) draft.method.update(browser)
editor.method.update(headless) draft.method.update(headless)
}) })
return { yield* ctx.catalog.transform(
"aisdk.sdk": Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/openai") return
const mod = yield* Effect.promise(() => import("@ai-sdk/openai"))
evt.sdk = mod.createOpenAI(evt.options)
}),
"aisdk.language": Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.openai) return
evt.language = evt.sdk.responses(evt.model.api.id)
}),
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai") continue if (item.provider.api.package !== "@ai-sdk/openai") continue
@@ -35,6 +26,19 @@ export const OpenAIPlugin = PluginV2.define({
}) })
} }
}), }),
} )
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/openai") return
const mod = yield* Effect.promise(() => import("@ai-sdk/openai"))
evt.sdk = mod.createOpenAI(evt.options)
}),
)
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.openai) return
evt.language = evt.sdk.responses(evt.model.api.id)
}),
)
}), }),
}) })
@@ -1,15 +1,15 @@
import { Effect } from "effect" import { Effect } from "effect"
import { Integration } from "../../integration" import { define } from "../internal"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
import { Integration } from "../../integration"
export const OpencodePlugin = PluginV2.define({ export const OpencodePlugin = define({
id: PluginV2.ID.make("opencode"), id: "opencode",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
let hasKey = false let hasKey = false
return { yield* ctx.catalog.transform(
"catalog.transform": Effect.fn(function* (evt) { Effect.fn(function* (evt) {
const item = evt.provider.get(ProviderV2.ID.opencode) const item = evt.provider.get(ProviderV2.ID.opencode)
if (!item) return if (!item) return
const integration = yield* integrations.get(Integration.ID.make(item.provider.id)) const integration = yield* integrations.get(Integration.ID.make(item.provider.id))
@@ -27,6 +27,6 @@ export const OpencodePlugin = PluginV2.define({
}) })
} }
}), }),
} )
}), }),
}) })
@@ -1,12 +1,12 @@
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
import { PluginV2 } from "../../plugin" import { define } from "../internal"
export const OpenRouterPlugin = PluginV2.define({ export const OpenRouterPlugin = define({
id: PluginV2.ID.make("openrouter"), id: "openrouter",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
return { yield* ctx.catalog.transform(
"catalog.transform": Effect.fn(function* (evt) { Effect.fn(function* (evt) {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@openrouter/ai-sdk-provider") continue if (item.provider.api.package !== "@openrouter/ai-sdk-provider") continue
@@ -24,11 +24,13 @@ export const OpenRouterPlugin = PluginV2.define({
} }
} }
}), }),
"aisdk.sdk": Effect.fn(function* (evt) { )
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@openrouter/ai-sdk-provider") return if (evt.package !== "@openrouter/ai-sdk-provider") return
const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider")) const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider"))
evt.sdk = mod.createOpenRouter(evt.options) evt.sdk = mod.createOpenRouter(evt.options)
}), }),
} )
}), }),
}) })
@@ -1,15 +1,15 @@
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../../plugin" import { define } from "../internal"
export const PerplexityPlugin = PluginV2.define({ export const PerplexityPlugin = define({
id: PluginV2.ID.make("perplexity"), id: "perplexity",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
return { yield* ctx.aisdk.sdk(
"aisdk.sdk": Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/perplexity") return if (evt.package !== "@ai-sdk/perplexity") return
const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity")) const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity"))
evt.sdk = mod.createPerplexity(evt.options) evt.sdk = mod.createPerplexity(evt.options)
}), }),
} )
}), }),
}) })
@@ -1,15 +1,15 @@
import { Npm } from "../../npm" import { Effect } from "effect"
import { Effect, Option } from "effect"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { PluginV2 } from "../../plugin" import { define } from "../internal"
import { Npm } from "../../npm"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
export const SapAICorePlugin = PluginV2.define({ export const SapAICorePlugin = define({
id: PluginV2.ID.make("sap-ai-core"), id: "sap-ai-core",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
const npm = yield* Npm.Service const npm = yield* Npm.Service
return { yield* ctx.aisdk.sdk(
"aisdk.sdk": Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return
const serviceKey = const serviceKey =
process.env.AICORE_SERVICE_KEY ?? process.env.AICORE_SERVICE_KEY ??
@@ -18,7 +18,7 @@ export const SapAICorePlugin = PluginV2.define({
const installedPath = evt.package.startsWith("file://") const installedPath = evt.package.startsWith("file://")
? evt.package ? evt.package
: Option.getOrUndefined((yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint) : (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint
if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`) if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`)
const mod = yield* Effect.promise(async () => { const mod = yield* Effect.promise(async () => {
@@ -35,10 +35,12 @@ export const SapAICorePlugin = PluginV2.define({
: {}, : {},
) )
}), }),
"aisdk.language": Effect.fn(function* (evt) { )
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return
evt.language = evt.sdk(evt.model.api.id) evt.language = evt.sdk(evt.model.api.id)
}), }),
} )
}), }),
}) })
@@ -1,5 +1,5 @@
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../../plugin" import { define } from "../internal"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response> type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>
@@ -64,11 +64,11 @@ export function cortexFetch(upstream: FetchLike = fetch) {
} }
} }
export const SnowflakeCortexPlugin = PluginV2.define({ export const SnowflakeCortexPlugin = define({
id: PluginV2.ID.make("snowflake-cortex"), id: "snowflake-cortex",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
return { yield* ctx.aisdk.sdk(
"aisdk.sdk": Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return
const token = const token =
process.env.SNOWFLAKE_CORTEX_TOKEN ?? process.env.SNOWFLAKE_CORTEX_TOKEN ??
@@ -84,6 +84,6 @@ export const SnowflakeCortexPlugin = PluginV2.define({
fetch: cortexFetch(upstream) as typeof fetch, fetch: cortexFetch(upstream) as typeof fetch,
} as any) } as any)
}), }),
} )
}), }),
}) })

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