Compare commits

...

66 Commits

Author SHA1 Message Date
Kit Langton 84a1b0280e effect(cli): unwrap run/runtime.boot and variant.shared facades 2026-05-12 16:26:22 -04:00
Frank b9e7cbf13c sync 2026-05-12 16:06:19 -04:00
Kit Langton 3f74abc6cd test: simplify Effect migration follow-ups (#27136) 2026-05-12 20:00:54 +00:00
Kit Langton e0d0fe1ff7 test(bus): migrate integration tests to Effect runner (#27132) 2026-05-12 19:58:54 +00:00
opencode-agent[bot] f7dbb4dac4 chore: generate 2026-05-12 19:48:35 +00:00
Kit Langton c5849e56cc test(project): migrate project tests to Effect runner (#27134) 2026-05-12 19:47:17 +00:00
opencode-agent[bot] e46ab34d27 chore: generate 2026-05-12 19:44:26 +00:00
Kit Langton 1d4613006a test(project): migrate instance tests to Effect runner (#27130) 2026-05-12 19:41:46 +00:00
Kit Langton 71040c54aa test(plugin): migrate loader shared tests to Effect runner (#27129) 2026-05-12 19:41:44 +00:00
Kit Langton fec78154b5 test(bus): migrate bus tests to Effect runner (#27131) 2026-05-12 19:41:24 +00:00
Kit Langton 3e2ec192cf test(question): remove WithInstance bridge (#27128) 2026-05-12 19:40:01 +00:00
Kit Langton ec960da42a test(skill): migrate discovery tests to Effect runner (#27127) 2026-05-12 19:39:03 +00:00
Shoubhit Dash 45de4975de refactor(core): resolve default agent info (#27125) 2026-05-13 01:08:30 +05:30
Kit Langton e540daabc4 test(agent): migrate plan bypass tests to Effect runner (#27119) 2026-05-12 14:56:01 -04:00
opencode-agent[bot] f3c91c5f96 chore: generate 2026-05-12 18:52:25 +00:00
Kit Langton 549b146ea6 Stabilize session event tests (#27117) 2026-05-12 18:51:18 +00:00
Kit Langton 3c7569d852 test(tool): migrate external directory tests to Effect runner (#27122) 2026-05-12 18:50:56 +00:00
Kit Langton 8f1ded9e08 test(file): migrate ripgrep tests to Effect runner (#27120) 2026-05-12 18:50:26 +00:00
Kit Langton b668af29dd test(git): migrate git tests to Effect runner (#27121) 2026-05-12 18:50:07 +00:00
Kit Langton ec30ff9120 test(agent): migrate agent tests to Effect runner (#27118) 2026-05-12 18:49:30 +00:00
opencode-agent[bot] a3714d4399 chore: update nix node_modules hashes 2026-05-12 18:27:42 +00:00
Kit Langton 822eec0d62 Fix runner cancel completion (#27115) 2026-05-12 14:22:56 -04:00
Kit Langton 3974520742 Migrate UI cancel error to tagged error (#27112) 2026-05-12 18:09:00 +00:00
Kit Langton fda37b3609 Remove Zod from app global SDK (#27111) 2026-05-12 14:02:12 -04:00
Kit Langton 6b950b666a Remove Zod from core dependencies (#27107) 2026-05-12 13:51:08 -04:00
Kit Langton bc4fdb8370 Remove unused app ID schema (#27105) 2026-05-12 13:09:23 -04:00
Kit Langton 2b9af91568 Remove Zod from core log (#27102) 2026-05-12 13:08:57 -04:00
Kit Langton 53a3f95088 Make core fn Zod import type-only (#27103) 2026-05-12 12:58:50 -04:00
Dax Raad 5a4596c879 core: Wait 3 days before installing new package versions to reduce supply chain risk 2026-05-12 12:50:32 -04:00
opencode-agent[bot] 0ce614a280 chore: generate 2026-05-12 16:46:35 +00:00
Kit Langton e8125e9b42 test(server): migrate session list tests to Effect runner (#27101) 2026-05-12 16:45:05 +00:00
Kit Langton a7b5041674 test(file): migrate fsmonitor tests to Effect runner (#27099) 2026-05-12 16:44:04 +00:00
Kit Langton a16789dfdd test(tool): migrate apply patch tests to Effect runner (#27100) 2026-05-12 16:43:33 +00:00
Kit Langton 8115004c73 test(file): migrate path traversal tests to Effect runner (#27098) 2026-05-12 16:42:49 +00:00
Kit Langton ec4fdaf8e9 test(tool): migrate tool define tests to Effect runner (#27097) 2026-05-12 16:42:19 +00:00
Shoubhit Dash 3dc2c1d81c fix(session): preserve usage update timestamps (#27094) 2026-05-12 22:10:28 +05:30
Kit Langton d658e1e350 Remove local MCP Zod schema (#27095) 2026-05-12 12:38:27 -04:00
opencode-agent[bot] 30e3fa1de9 chore: generate 2026-05-12 16:33:06 +00:00
Aiden Cline 23f8b3eb3e fix: annotate Effect log metadata (#27093) 2026-05-12 11:31:18 -05:00
Kit Langton c7d8b0d565 Delete named schema error wrapper (#27066) 2026-05-12 12:04:28 -04:00
Kit Langton 257fcafc83 test(tool): migrate edit concurrency test (#26983) 2026-05-12 11:52:31 -04:00
Kit Langton 04aafe2bfc test(provider): migrate more config-backed cases (#27067) 2026-05-12 14:19:49 +00:00
opencode-agent[bot] 0fd0facc44 chore: generate 2026-05-12 13:31:03 +00:00
Kit Langton 0de3b67cc0 test(tool): migrate shell tests to Effect runner (#26968) 2026-05-12 13:28:46 +00:00
Kit Langton 28f38fc871 Remove Zod from named errors (#26982) 2026-05-12 09:20:15 -04:00
Shoubhit Dash 8feb4a31c7 feat(core): add background job service (#27033) 2026-05-12 15:22:38 +05:30
Simon Klee 8f05bbfaa6 prompt: fix cursor math for wide characters (#27017)
String.length counts code points, not display columns, so CJK
characters and emoji that occupy two terminal cells caused
misaligned cursors, broken mention triggers, and incorrect
history restoration offsets.

Use Bun.stringWidth for now, we need an alternative for this.

Fix #26716
Close #26922
2026-05-12 11:45:28 +02:00
Brendan Allan d276d96cdf fix(app): remember selected model variant when switching sessions/projects (#27029) 2026-05-12 17:44:50 +08:00
Luke Parker caf1151cb5 refactor(app): centralize sync query options (#25941)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-05-12 16:40:21 +08:00
Brendan Allan ff38bbeeeb refactor(desktop): remove configureEnv callback from spawnLocalServer (#27022) 2026-05-12 16:39:56 +08:00
Shoubhit Dash 2481dde36d chore: remove codesearch tool (#27019) 2026-05-12 13:44:02 +05:30
Matt H 61174b7878 fix(tui): make websearch provider label reactive (#26943) 2026-05-12 08:01:16 +00:00
opencode-agent[bot] 907281a80a chore: generate 2026-05-12 06:44:09 +00:00
Brendan Allan 3992e2a76b feat(app): add ctrl/cmd+number keybinds to switch projects (#26280) 2026-05-12 14:43:07 +08:00
opencode-agent[bot] ea6eabe1d9 chore: generate 2026-05-12 05:20:22 +00:00
Dax 36d40fee4d Track session usage totals (#26644) 2026-05-12 01:18:57 -04:00
James Long e36bc20f84 fix(tui): fix flicker by avoiding redundant workspace session sync (#26997) 2026-05-12 00:30:03 -04:00
Aiden Cline 487575773d feat: create global opencode.jsonc if no configs exist (#26992) 2026-05-11 23:13:22 -05:00
opencode-agent[bot] 5cc84800dc chore: generate 2026-05-12 03:43:40 +00:00
James Long 2b432d9e03 fix(tui): scope events by project (#26936) 2026-05-11 23:42:04 -04:00
opencode-agent[bot] 591eb667d5 chore: generate 2026-05-12 03:26:47 +00:00
Aiden Cline ddce776225 ignore: add codebase skill to repo (#26990) 2026-05-12 03:25:41 +00:00
Aiden Cline 1a28924ed8 fix: grep external directory permission evaluation (#26958) 2026-05-11 21:47:35 -05:00
Brendan Allan 871374804f fix(app): use keyed Show for project in layout (#26985) 2026-05-12 10:39:36 +08:00
Brendan Allan 78a2639e5e fix(app): open next project when closing current one (#26987) 2026-05-12 10:38:21 +08:00
Kit Langton cc1835e0db test(provider): migrate config-backed cases to Effect runner (#26969) 2026-05-12 02:23:52 +00:00
139 changed files with 7866 additions and 5372 deletions
+1 -5
View File
@@ -1,11 +1,7 @@
{
"$schema": "https://opencode.ai/config.json",
"provider": {},
"permission": {
"edit": {
"packages/opencode/migration/*": "ask",
},
},
"permission": {},
"mcp": {},
"tools": {
"github-triage": false,
@@ -0,0 +1,37 @@
# Deepening
How to deepen a cluster of shallow modules safely, given its dependencies. Assumes the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**.
## Dependency categories
When assessing a candidate for deepening, classify its dependencies. The category determines how the deepened module is tested across its seam.
### 1. In-process
Pure computation, in-memory state, no I/O. Always deepenable — merge the modules and test through the new interface directly. No adapter needed.
### 2. Local-substitutable
Dependencies that have local test stand-ins (PGLite for Postgres, in-memory filesystem). Deepenable if the stand-in exists. The deepened module is tested with the stand-in running in the test suite. The seam is internal; no port at the module's external interface.
### 3. Remote but owned (Ports & Adapters)
Your own services across a network boundary (microservices, internal APIs). Define a **port** (interface) at the seam. The deep module owns the logic; the transport is injected as an **adapter**. Tests use an in-memory adapter. Production uses an HTTP/gRPC/queue adapter.
Recommendation shape: _"Define a port at the seam, implement an HTTP adapter for production and an in-memory adapter for testing, so the logic sits in one deep module even though it's deployed across a network."_
### 4. True external (Mock)
Third-party services (Stripe, Twilio, etc.) you don't control. The deepened module takes the external dependency as an injected port; tests provide a mock adapter.
## Seam discipline
- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a port unless at least two adapters are justified (typically production + test). A single-adapter seam is just indirection.
- **Internal seams vs external seams.** A deep module can have internal seams (private to its implementation, used by its own tests) as well as the external seam at its interface. Don't expose internal seams through the interface just because tests use them.
## Testing strategy: replace, don't layer
- Old unit tests on shallow modules become waste once tests at the deepened module's interface exist — delete them.
- Write new tests at the deepened module's interface. The **interface is the test surface**.
- Tests assert on observable outcomes through the interface, not internal state.
- Tests should survive internal refactors — they describe behaviour, not implementation. If a test has to change when the implementation changes, it's testing past the interface.
@@ -0,0 +1,44 @@
# Interface Design
When the user wants to explore alternative interfaces for a chosen deepening candidate, use this parallel sub-agent pattern. Based on "Design It Twice" (Ousterhout) — your first idea is unlikely to be the best.
Uses the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**, **leverage**.
## Process
### 1. Frame the problem space
Before spawning sub-agents, write a user-facing explanation of the problem space for the chosen candidate:
- The constraints any new interface would need to satisfy
- The dependencies it would rely on, and which category they fall into (see [DEEPENING.md](DEEPENING.md))
- A rough illustrative code sketch to ground the constraints — not a proposal, just a way to make the constraints concrete
Show this to the user, then immediately proceed to Step 2. The user reads and thinks while the sub-agents work in parallel.
### 2. Spawn sub-agents
Spawn 3+ sub-agents in parallel using the Agent tool. Each must produce a **radically different** interface for the deepened module.
Prompt each sub-agent with a separate technical brief (file paths, coupling details, dependency category from [DEEPENING.md](DEEPENING.md), what sits behind the seam). The brief is independent of the user-facing problem-space explanation in Step 1. Give each agent a different design constraint:
- Agent 1: "Minimize the interface — aim for 13 entry points max. Maximise leverage per entry point."
- Agent 2: "Maximise flexibility — support many use cases and extension."
- Agent 3: "Optimise for the most common caller — make the default case trivial."
- Agent 4 (if applicable): "Design around ports & adapters for cross-seam dependencies."
Include both [LANGUAGE.md](LANGUAGE.md) vocabulary and CONTEXT.md vocabulary in the brief so each sub-agent names things consistently with the architecture language and the project's domain language.
Each sub-agent outputs:
1. Interface (types, methods, params — plus invariants, ordering, error modes)
2. Usage example showing how callers use it
3. What the implementation hides behind the seam
4. Dependency strategy and adapters (see [DEEPENING.md](DEEPENING.md))
5. Trade-offs — where leverage is high, where it's thin
### 3. Present and compare
Present designs sequentially so the user can absorb each one, then compare them in prose. Contrast by **depth** (leverage at the interface), **locality** (where change concentrates), and **seam placement**.
After comparing, give your own recommendation: which design you think is strongest and why. If elements from different designs would combine well, propose a hybrid. Be opinionated — the user wants a strong read, not a menu.
@@ -0,0 +1,53 @@
# Language
Shared vocabulary for every suggestion this skill makes. Use these terms exactly — don't substitute "component," "service," "API," or "boundary." Consistent language is the whole point.
## Terms
**Module**
Anything with an interface and an implementation. Deliberately scale-agnostic — applies equally to a function, class, package, or tier-spanning slice.
_Avoid_: unit, component, service.
**Interface**
Everything a caller must know to use the module correctly. Includes the type signature, but also invariants, ordering constraints, error modes, required configuration, and performance characteristics.
_Avoid_: API, signature (too narrow — those refer only to the type-level surface).
**Implementation**
What's inside a module — its body of code. Distinct from **Adapter**: a thing can be a small adapter with a large implementation (a Postgres repo) or a large adapter with a small implementation (an in-memory fake). Reach for "adapter" when the seam is the topic; "implementation" otherwise.
**Depth**
Leverage at the interface — the amount of behaviour a caller (or test) can exercise per unit of interface they have to learn. A module is **deep** when a large amount of behaviour sits behind a small interface. A module is **shallow** when the interface is nearly as complex as the implementation.
**Seam** _(from Michael Feathers)_
A place where you can alter behaviour without editing in that place. The _location_ at which a module's interface lives. Choosing where to put the seam is its own design decision, distinct from what goes behind it.
_Avoid_: boundary (overloaded with DDD's bounded context).
**Adapter**
A concrete thing that satisfies an interface at a seam. Describes _role_ (what slot it fills), not substance (what's inside).
**Leverage**
What callers get from depth. More capability per unit of interface they have to learn. One implementation pays back across N call sites and M tests.
**Locality**
What maintainers get from depth. Change, bugs, knowledge, and verification concentrate at one place rather than spreading across callers. Fix once, fixed everywhere.
## Principles
- **Depth is a property of the interface, not the implementation.** A deep module can be internally composed of small, mockable, swappable parts — they just aren't part of the interface. A module can have **internal seams** (private to its implementation, used by its own tests) as well as the **external seam** at its interface.
- **The deletion test.** Imagine deleting the module. If complexity vanishes, the module wasn't hiding anything (it was a pass-through). If complexity reappears across N callers, the module was earning its keep.
- **The interface is the test surface.** Callers and tests cross the same seam. If you want to test _past_ the interface, the module is probably the wrong shape.
- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a seam unless something actually varies across it.
## Relationships
- A **Module** has exactly one **Interface** (the surface it presents to callers and tests).
- **Depth** is a property of a **Module**, measured against its **Interface**.
- A **Seam** is where a **Module**'s **Interface** lives.
- An **Adapter** sits at a **Seam** and satisfies the **Interface**.
- **Depth** produces **Leverage** for callers and **Locality** for maintainers.
## Rejected framings
- **Depth as ratio of implementation-lines to interface-lines** (Ousterhout): rewards padding the implementation. We use depth-as-leverage instead.
- **"Interface" as the TypeScript `interface` keyword or a class's public methods**: too narrow — interface here includes every fact a caller must know.
- **"Boundary"**: overloaded with DDD's bounded context. Say **seam** or **interface**.
@@ -0,0 +1,71 @@
---
name: improve-codebase-architecture
description: Find deepening opportunities in a codebase, informed by the domain language in CONTEXT.md and the decisions in docs/adr/. Use when the user wants to improve architecture, find refactoring opportunities, consolidate tightly-coupled modules, or make a codebase more testable and AI-navigable.
---
# Improve Codebase Architecture
Surface architectural friction and propose **deepening opportunities** — refactors that turn shallow modules into deep ones. The aim is testability and AI-navigability.
## Glossary
Use these terms exactly in every suggestion. Consistent language is the point — don't drift into "component," "service," "API," or "boundary." Full definitions in [LANGUAGE.md](LANGUAGE.md).
- **Module** — anything with an interface and an implementation (function, class, package, slice).
- **Interface** — everything a caller must know to use the module: types, invariants, error modes, ordering, config. Not just the type signature.
- **Implementation** — the code inside.
- **Depth** — leverage at the interface: a lot of behaviour behind a small interface. **Deep** = high leverage. **Shallow** = interface nearly as complex as the implementation.
- **Seam** — where an interface lives; a place behaviour can be altered without editing in place. (Use this, not "boundary.")
- **Adapter** — a concrete thing satisfying an interface at a seam.
- **Leverage** — what callers get from depth.
- **Locality** — what maintainers get from depth: change, bugs, knowledge concentrated in one place.
Key principles (see [LANGUAGE.md](LANGUAGE.md) for the full list):
- **Deletion test**: imagine deleting the module. If complexity vanishes, it was a pass-through. If complexity reappears across N callers, it was earning its keep.
- **The interface is the test surface.**
- **One adapter = hypothetical seam. Two adapters = real seam.**
This skill is _informed_ by the project's domain model. The domain language gives names to good seams; ADRs record decisions the skill should not re-litigate.
## Process
### 1. Explore
Read the project's domain glossary and any ADRs in the area you're touching first.
Then use the Agent tool with `subagent_type=Explore` to walk the codebase. Don't follow rigid heuristics — explore organically and note where you experience friction:
- Where does understanding one concept require bouncing between many small modules?
- Where are modules **shallow** — interface nearly as complex as the implementation?
- Where have pure functions been extracted just for testability, but the real bugs hide in how they're called (no **locality**)?
- Where do tightly-coupled modules leak across their seams?
- Which parts of the codebase are untested, or hard to test through their current interface?
Apply the **deletion test** to anything you suspect is shallow: would deleting it concentrate complexity, or just move it? A "yes, concentrates" is the signal you want.
### 2. Present candidates
Present a numbered list of deepening opportunities. For each candidate:
- **Files** — which files/modules are involved
- **Problem** — why the current architecture is causing friction
- **Solution** — plain English description of what would change
- **Benefits** — explained in terms of locality and leverage, and also in how tests would improve
**Use CONTEXT.md vocabulary for the domain, and [LANGUAGE.md](LANGUAGE.md) vocabulary for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service."
**ADR conflicts**: if a candidate contradicts an existing ADR, only surface it when the friction is real enough to warrant revisiting the ADR. Mark it clearly (e.g. _"contradicts ADR-0007 — but worth reopening because…"_). Don't list every theoretical refactor an ADR forbids.
Do NOT propose interfaces yet. Ask the user: "Which of these would you like to explore?"
### 3. Grilling loop
Once the user picks a candidate, drop into a grilling conversation. Walk the design tree with them — constraints, dependencies, the shape of the deepened module, what sits behind the seam, what tests survive.
Side effects happen inline as decisions crystallize:
- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md` — same discipline as `/grill-with-docs` (see [CONTEXT-FORMAT.md](../grill-with-docs/CONTEXT-FORMAT.md)). Create the file lazily if it doesn't exist.
- **Sharpening a fuzzy term during the conversation?** Update `CONTEXT.md` right there.
- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones. See [ADR-FORMAT.md](../grill-with-docs/ADR-FORMAT.md).
- **Want to explore alternative interfaces for the deepened module?** See [INTERFACE-DESIGN.md](INTERFACE-DESIGN.md).
-2
View File
@@ -65,7 +65,6 @@
"solid-list": "catalog:",
"tailwindcss": "catalog:",
"virtua": "catalog:",
"zod": "catalog:",
},
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
@@ -214,7 +213,6 @@
"npm-package-arg": "13.0.2",
"semver": "^7.6.3",
"xdg-basedir": "5.1.0",
"zod": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
+2 -1
View File
@@ -1,6 +1,7 @@
[install]
exact = true
# Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200
[test]
root = "./do-not-run-tests-from-root"
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-Q9r1S15YL9LQK7DRhuOpw3Fxi24BPovEM995GZJayKw=",
"aarch64-linux": "sha256-C0rRTLnxxuuEkCBc3JZbkR66TUVwpcPFif3BU9GRAuA=",
"aarch64-darwin": "sha256-1HvalOO/pOkRlYH8CZ93psapt90C+pYzui1JCadBE1Q=",
"x86_64-darwin": "sha256-RrndyLWfhWm4mZ88XytFF2NI+ly8la550Z5LBN/g5u4="
"x86_64-linux": "sha256-MUHog06sZEi6bXR1m8exdkjSNW9bHEv9bPQXACJ7SFw=",
"aarch64-linux": "sha256-3dwdZ3It++OsdGT8xMOQ10Arz8eeODp/LXOrI4DLEhY=",
"aarch64-darwin": "sha256-TmUPGDCewjsrT13npVH6B55J43NKKut67p/HgPJpQNM=",
"x86_64-darwin": "sha256-j8I7t3MZoUQUMFRWyaFO75TRbAw5TauSZAa4yKOHFMA="
}
}
+1 -2
View File
@@ -73,7 +73,6 @@
"solid-js": "catalog:",
"solid-list": "catalog:",
"tailwindcss": "catalog:",
"virtua": "catalog:",
"zod": "catalog:"
"virtua": "catalog:"
}
}
@@ -107,7 +107,8 @@ function createCommandEntries(props: {
const allowed = createMemo(() => {
if (props.filesOnly()) return []
return props.command.options.filter(
(option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open",
(option) =>
!option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
)
})
@@ -6,7 +6,8 @@ import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch"
import { useLanguage } from "@/context/language"
import { mcpQueryKey } from "@/context/global-sync"
import { useQueryOptions } from "@/context/global-sync"
import { pathKey } from "@/utils/path-key"
const statusLabels = {
connected: "mcp.status.connected",
@@ -20,6 +21,7 @@ export const DialogSelectMcp: Component = () => {
const sdk = useSDK()
const language = useLanguage()
const queryClient = useQueryClient()
const queryOptions = useQueryOptions()
const items = createMemo(() =>
Object.entries(sync.data.mcp ?? {})
@@ -32,7 +34,7 @@ export const DialogSelectMcp: Component = () => {
if (sync.data.mcp[name]?.status === "connected") await sdk.client.mcp.disconnect({ name })
else await sdk.client.mcp.connect({ name })
},
onSuccess: () => queryClient.refetchQueries({ queryKey: mcpQueryKey(sync.directory) }),
onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))),
}))
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
+6 -6
View File
@@ -16,7 +16,6 @@ import {
} from "@/context/prompt"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
import { useGlobalSDK } from "@/context/global-sdk"
import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments"
import { Button } from "@opencode-ai/ui/button"
@@ -56,7 +55,8 @@ import { PromptDragOverlay } from "./prompt-input/drag-overlay"
import { promptPlaceholder } from "./prompt-input/placeholder"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { useQueries } from "@tanstack/solid-query"
import { loadAgentsQuery, loadProvidersQuery } from "@/context/global-sync/bootstrap"
import { useQueryOptions } from "@/context/global-sync"
import { pathKey } from "@/utils/path-key"
interface PromptInputProps {
class?: string
@@ -103,7 +103,7 @@ const NON_EMPTY_TEXT = /[^\s\u200B]/
export const PromptInput: Component<PromptInputProps> = (props) => {
const sdk = useSDK()
const globalSDK = useGlobalSDK()
const queryOptions = useQueryOptions()
const sync = useSync()
const local = useLocal()
@@ -1256,9 +1256,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({
queries: [
loadAgentsQuery(sdk.directory, sdk.client),
loadProvidersQuery(null, globalSDK.client),
loadProvidersQuery(sdk.directory, sdk.client),
queryOptions.agents(pathKey(sdk.directory)),
queryOptions.providers(null),
queryOptions.providers(pathKey(sdk.directory)),
],
}))
@@ -123,11 +123,13 @@ function listFor(command: CommandContext, map: KeybindMap, palette: string) {
for (const opt of command.catalog) {
if (opt.id.startsWith("suggested.")) continue
if (opt.hidden) continue
out.set(opt.id, { title: opt.title, group: groupFor(opt.id) })
}
for (const opt of command.options) {
if (opt.id.startsWith("suggested.")) continue
if (opt.hidden) continue
out.set(opt.id, { title: opt.title, group: groupFor(opt.id) })
}
@@ -15,7 +15,8 @@ import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
import { mcpQueryKey } from "@/context/global-sync"
import { useQueryOptions } from "@/context/global-sync"
import { pathKey } from "@/utils/path-key"
const pollMs = 10_000
@@ -139,13 +140,14 @@ const useMcpToggleMutation = () => {
const sdk = useSDK()
const language = useLanguage()
const queryClient = useQueryClient()
const queryOptions = useQueryOptions()
return useMutation(() => ({
mutationFn: async (name: string) => {
const status = sync.data.mcp[name]
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
},
onSuccess: () => queryClient.refetchQueries({ queryKey: mcpQueryKey(sync.directory) }),
onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))),
onError: (err) => {
showToast({
variant: "error",
+10 -7
View File
@@ -81,6 +81,7 @@ export interface CommandOption {
slash?: string
suggested?: boolean
disabled?: boolean
hidden?: boolean
onSelect?: (source?: "palette" | "keybind" | "slash") => void
onHighlight?: () => (() => void) | void
}
@@ -93,6 +94,7 @@ export type CommandCatalogItem = {
category?: string
keybind?: KeybindConfig
slash?: string
hidden?: boolean
}
export type CommandRegistration = {
@@ -279,13 +281,14 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
setCatalog(
registered().reduce((acc, opt) => {
const id = actionId(opt.id)
acc[id] = {
title: opt.title,
description: opt.description,
category: opt.category,
keybind: opt.keybind,
slash: opt.slash,
}
if (opt.title)
acc[id] = {
title: opt.title,
description: opt.description,
category: opt.category,
keybind: opt.keybind,
slash: opt.slash,
}
return acc
}, {} as CommandCatalog),
)
+3 -5
View File
@@ -3,15 +3,13 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener"
import { batch, onCleanup, onMount } from "solid-js"
import z from "zod"
import { createSdkForServer } from "@/utils/server"
import { useLanguage } from "./language"
import { usePlatform } from "./platform"
import { useServer } from "./server"
const abortError = z.object({
name: z.literal("AbortError"),
})
const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
name: "GlobalSDK",
@@ -103,7 +101,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
let streamErrorLogged = false
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
const aborted = (error: unknown) => abortError.safeParse(error).success
const aborted = isAbortError
let attempt: AbortController | undefined
let run: Promise<void> | undefined
+43 -28
View File
@@ -18,8 +18,10 @@ import {
bootstrapDirectory,
bootstrapGlobal,
clearProviderRev,
loadAgentsQuery,
loadGlobalConfigQuery,
loadPathQuery,
loadProjectsQuery,
loadProvidersQuery,
} from "./global-sync/bootstrap"
import { createChildStoreManager } from "./global-sync/child-store"
@@ -33,6 +35,7 @@ import { formatServerError } from "@/utils/server-errors"
import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
import { createRefreshQueue } from "./global-sync/queue"
import { directoryKey } from "./global-sync/utils"
import { PathKey } from "@/utils/path-key"
type GlobalStore = {
ready: boolean
@@ -48,24 +51,33 @@ type GlobalStore = {
reload: undefined | "pending" | "complete"
}
export const loadSessionsQueryKey = (directory: string) => [directory, "loadSessions"] as const
export const mcpQueryKey = (directory: string) => [directory, "mcp"] as const
export const loadMcpQuery = (directory: string, sdk: OpencodeClient) =>
queryOptions({
queryKey: mcpQueryKey(directory),
queryKey: [directory, "mcp"] as const,
queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
})
export const lspQueryKey = (directory: string) => [directory, "lsp"] as const
export const loadLspQuery = (directory: string, sdk: OpencodeClient) =>
queryOptions({
queryKey: lspQueryKey(directory),
queryKey: [directory, "lsp"] as const,
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
})
function makeQueryOptionsApi(globalSDK: () => OpencodeClient, sdkFor: (dir: PathKey) => OpencodeClient) {
return {
globalConfig: () => loadGlobalConfigQuery(globalSDK()),
projects: () => loadProjectsQuery(globalSDK()),
providers: (directory: PathKey | null) =>
loadProvidersQuery(directory, directory === null ? globalSDK() : sdkFor(directory)),
path: (directory: PathKey | null) => loadPathQuery(directory, directory === null ? globalSDK() : sdkFor(directory)),
agents: (directory: PathKey) => loadAgentsQuery(directory, sdkFor(directory)),
mcp: (directory: PathKey) => loadMcpQuery(directory, sdkFor(directory)),
lsp: (directory: PathKey) => loadLspQuery(directory, sdkFor(directory)),
sessions: (directory: PathKey) => ({ queryKey: [directory, "loadSessions"] as const }),
}
}
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
function createGlobalSync() {
const globalSDK = useGlobalSDK()
const language = useLanguage()
@@ -77,12 +89,22 @@ function createGlobalSync() {
const sessionLoads = new Map<string, Promise<void>>()
const sessionMeta = new Map<string, { limit: number }>()
const sdkFor = (directory: string) => {
const key = directoryKey(directory)
const cached = sdkCache.get(key)
if (cached) return cached
const sdk = globalSDK.createClient({
directory,
throwOnError: true,
})
sdkCache.set(key, sdk)
return sdk
}
const queryOptionsApi = makeQueryOptionsApi(() => globalSDK.client, sdkFor)
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
queries: [
loadGlobalConfigQuery(globalSDK.client),
loadProvidersQuery(null, globalSDK.client),
loadPathQuery(null, globalSDK.client),
],
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
}))
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
@@ -181,18 +203,6 @@ function createGlobalSync() {
bootstrapInstance,
})
const sdkFor = (directory: string) => {
const key = directoryKey(directory)
const cached = sdkCache.get(key)
if (cached) return cached
const sdk = globalSDK.createClient({
directory,
throwOnError: true,
})
sdkCache.set(key, sdk)
return sdk
}
const children = createChildStoreManager({
owner,
isBooting: (directory) => booting.has(directory),
@@ -209,7 +219,7 @@ function createGlobalSync() {
clearSessionPrefetchDirectory(key)
},
translate: language.t,
getSdk: sdkFor,
queryOptions: queryOptionsApi,
global: {
provider: globalStore.provider,
},
@@ -239,7 +249,7 @@ function createGlobalSync() {
const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
const promise = queryClient
.fetchQuery({
queryKey: loadSessionsQueryKey(key),
...queryOptionsApi.sessions(key),
queryFn: () =>
loadRootSessionsWithFallback({
directory,
@@ -368,7 +378,7 @@ function createGlobalSync() {
setSessionTodo,
vcsCache: children.vcsCache.get(key),
loadLsp: () => {
void queryClient.fetchQuery(loadLspQuery(key, sdkFor(directory)))
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
},
})
})
@@ -426,6 +436,7 @@ function createGlobalSync() {
},
child: children.child,
peek: children.peek,
queryOptions: queryOptionsApi,
// bootstrap,
updateConfig: updateConfigMutation.mutateAsync,
project: projectApi,
@@ -447,3 +458,7 @@ export function useGlobalSync() {
if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
return context
}
export function useQueryOptions() {
return useGlobalSync().queryOptions
}
@@ -22,7 +22,7 @@ describe("createChildStoreManager", () => {
onBootstrap() {},
onDispose() {},
translate: (key) => key,
getSdk: () => null!,
queryOptions: {} as any,
global: { provider: null! },
})
@@ -1,7 +1,7 @@
import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import type { OpencodeClient, ProviderListResponse, VcsInfo } from "@opencode-ai/sdk/v2/client"
import type { ProviderListResponse, VcsInfo } from "@opencode-ai/sdk/v2/client"
import {
DIR_IDLE_TTL_MS,
MAX_DIR_STORES,
@@ -15,8 +15,7 @@ import {
} from "./types"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
import { useQueries } from "@tanstack/solid-query"
import { loadPathQuery, loadProvidersQuery } from "./bootstrap"
import { loadLspQuery, loadMcpQuery } from "../global-sync"
import { QueryOptionsApi } from "../global-sync"
import { directoryKey, type DirectoryKey } from "./utils"
export function createChildStoreManager(input: {
@@ -26,7 +25,7 @@ export function createChildStoreManager(input: {
onBootstrap: (directory: string) => void
onDispose: (directory: string) => void
translate: (key: string, vars?: Record<string, string | number>) => string
getSdk: (directory: string) => OpencodeClient
queryOptions: QueryOptionsApi
global: {
provider: ProviderListResponse
}
@@ -171,17 +170,15 @@ export function createChildStoreManager(input: {
const init = () =>
createRoot((dispose) => {
const sdk = input.getSdk(directory)
const initialMeta = meta[0].value
const initialIcon = icon[0].value
const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({
queries: [
loadPathQuery(key, sdk),
loadMcpQuery(key, sdk),
loadLspQuery(key, sdk),
loadProvidersQuery(key, sdk),
input.queryOptions.path(key),
input.queryOptions.mcp(key),
input.queryOptions.lsp(key),
input.queryOptions.providers(key),
],
}))
+13 -5
View File
@@ -44,7 +44,7 @@ const migrate = (value: unknown) => {
}
const clone = (value: State | undefined) => {
if (!value) return undefined
if (!value) return
return {
...value,
model: value.model ? { ...value.model } : undefined,
@@ -104,7 +104,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const pickAgent = (name: string | undefined) => {
const items = list()
if (items.length === 0) return undefined
if (items.length === 0) return
return items.find((item) => item.name === name) ?? items[0]
}
@@ -227,14 +227,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
() => agent.current()?.model,
fallback,
)
if (!item) return undefined
if (!item) return
return models.find(item)
}
const configured = () => {
const item = agent.current()
const model = current()
if (!item || !model) return undefined
if (!item || !model) return
return getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
@@ -314,11 +314,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
configured,
selected,
current() {
return resolveModelVariant({
const resolved = resolveModelVariant({
variants: this.list(),
selected: this.selected(),
configured: this.configured(),
})
if (resolved) return resolved
const model = current()
if (!model) return
const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id })
if (saved && this.list().includes(saved)) return saved
},
list() {
const item = current()
@@ -335,6 +340,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
variant: value ?? null,
})
write({ variant: value ?? null })
if (model) {
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined)
}
})
},
cycle() {
+1
View File
@@ -25,6 +25,7 @@ export const dict = {
"command.project.open": "Open project",
"command.project.previous": "Previous project",
"command.project.next": "Next project",
"command.project.index": "Switch to project {{index}}",
"command.provider.connect": "Connect provider",
"command.server.switch": "Switch server",
"command.settings.open": "Open settings",
+32 -16
View File
@@ -960,6 +960,15 @@ export default function Layout(props: ParentProps) {
void openProject(target.worktree)
}
function navigateToProjectIndex(index: number) {
const projects = layout.projects.list()
const target = projects[index]
if (!target) return
globalSync.child(target.worktree)
void openProject(target.worktree)
}
function navigateSessionByUnseen(offset: number) {
const sessions = currentSessions()
if (sessions.length === 0) return
@@ -1040,6 +1049,19 @@ export default function Layout(props: ParentProps) {
keybind: "mod+alt+arrowdown",
onSelect: () => navigateProjectByOffset(1),
},
...Array.from({ length: 9 }, (_, i) => {
const index = i
const number = index + 1
return {
id: `project.${number}`,
category: language.t("command.category.project"),
title: `Open Project {number}`,
keybind: `mod+${number}`,
disabled: layout.projects.list().length <= index,
hidden: true,
onSelect: () => navigateToProjectIndex(index),
}
}),
{
id: "provider.connect",
title: language.t("command.provider.connect"),
@@ -1409,19 +1431,20 @@ export default function Layout(props: ParentProps) {
const index = list.findIndex((x) => pathKey(x.worktree) === key)
const active = pathKey(currentProject()?.worktree ?? "") === key
if (index === -1) return
const next = list[index + 1]
if (!active) {
layout.projects.close(directory)
return
}
if (!next) {
if (list.length === 1) {
layout.projects.close(directory)
navigate("/")
return
}
const next = list[index + 1] ?? list[index - 1]
navigateWithSidebarReset(`/${base64Encode(next.worktree)}/session`)
layout.projects.close(directory)
queueMicrotask(() => {
@@ -2096,6 +2119,7 @@ export default function Layout(props: ParentProps) {
</div>
</Show>
}
keyed
>
{(project) => (
<>
@@ -2106,9 +2130,7 @@ export default function Layout(props: ParentProps) {
id={`project:${projectId()}`}
value={projectName}
onSave={(next) => {
const item = project()
if (!item) return
void renameProject(item, next)
void renameProject(project, next)
}}
class="text-14-medium text-text-strong truncate"
displayClass="text-14-medium text-text-strong truncate"
@@ -2150,9 +2172,7 @@ export default function Layout(props: ParentProps) {
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
const item = project()
if (!item) return
showEditProjectDialog(item)
showEditProjectDialog(project)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
@@ -2162,9 +2182,7 @@ export default function Layout(props: ParentProps) {
data-project={slug()}
disabled={!canToggle()}
onSelect={() => {
const item = project()
if (!item) return
toggleProjectWorkspaces(item)
toggleProjectWorkspaces(project)
}}
>
<DropdownMenu.ItemLabel>
@@ -2223,7 +2241,7 @@ export default function Layout(props: ParentProps) {
<div class="flex-1 min-h-0">
<LocalWorkspace
ctx={workspaceSidebarCtx}
project={project()}
project={project}
sortNow={sortNow}
mobile={panelProps.mobile}
/>
@@ -2238,9 +2256,7 @@ export default function Layout(props: ParentProps) {
icon="plus-small"
class="w-full"
onClick={() => {
const item = project()
if (!item) return
void createWorkspace(item)
void createWorkspace(project)
}}
>
{language.t("workspace.new")}
@@ -2267,7 +2283,7 @@ export default function Layout(props: ParentProps) {
<SortableWorkspace
ctx={workspaceSidebarCtx}
directory={directory}
project={project()}
project={project}
sortNow={sortNow}
mobile={panelProps.mobile}
/>
@@ -14,7 +14,7 @@ import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { type Session } from "@opencode-ai/sdk/v2/client"
import { type LocalProject } from "@/context/layout"
import { loadSessionsQueryKey, useGlobalSync } from "@/context/global-sync"
import { useGlobalSync, useQueryOptions } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { pathKey } from "@/utils/path-key"
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
@@ -300,6 +300,7 @@ export const SortableWorkspace = (props: {
const navigate = useNavigate()
const params = useParams()
const globalSync = useGlobalSync()
const queryOptions = useQueryOptions()
const language = useLanguage()
const sortable = createSortable(props.directory)
const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false })
@@ -320,7 +321,7 @@ export const SortableWorkspace = (props: {
const boot = createMemo(() => open() || active())
const count = createMemo(() => sessions()?.length ?? 0)
const hasMore = createMemo(() => workspaceStore.sessionTotal > count())
const fetching = useIsFetching(() => ({ queryKey: loadSessionsQueryKey(props.directory) }))
const fetching = useIsFetching(() => queryOptions.sessions(pathKey(props.directory)))
const busy = createMemo(() => props.ctx.isBusy(props.directory))
const loading = () => fetching() > 0 && count() === 0
const touch = createMediaQuery("(hover: none)")
@@ -446,6 +447,7 @@ export const LocalWorkspace = (props: {
mobile?: boolean
}): JSX.Element => {
const globalSync = useGlobalSync()
const queryOptions = useQueryOptions()
const language = useLanguage()
const workspace = createMemo(() => {
const [store, setStore] = globalSync.child(props.project.worktree)
@@ -454,7 +456,7 @@ export const LocalWorkspace = (props: {
const slug = createMemo(() => base64Encode(props.project.worktree))
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
const count = createMemo(() => sessions()?.length ?? 0)
const fetching = useIsFetching(() => ({ queryKey: loadSessionsQueryKey(props.project.worktree) }))
const fetching = useIsFetching(() => queryOptions.sessions(pathKey(props.project.worktree)))
const hasMore = createMemo(() => workspace().store.sessionTotal > count())
const loading = () => fetching() > 0 && count() === 0
const loadMore = async () => {
-6
View File
@@ -1,5 +1,3 @@
import z from "zod"
const prefixes = {
session: "ses",
message: "msg",
@@ -15,10 +13,6 @@ let counter = 0
type Prefix = keyof typeof prefixes
export namespace Identifier {
export function schema(prefix: Prefix) {
return z.string().startsWith(prefixes[prefix])
}
export function ascending(prefix: Prefix, given?: string) {
return generateID(prefix, false, given)
}
@@ -299,7 +299,6 @@ export async function handler(
let buffer = ""
let responseLength = 0
let timestampFirstByte = 0
let timestampLastByte = 0
function pump(): Promise<void> {
return (
+1 -2
View File
@@ -41,8 +41,7 @@
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"semver": "^7.6.3",
"xdg-basedir": "5.1.0",
"zod": "catalog:"
"xdg-basedir": "5.1.0"
},
"overrides": {
"drizzle-orm": "catalog:"
+31 -22
View File
@@ -1,8 +1,8 @@
import z from "zod"
import { Schema } from "effect"
export abstract class NamedError extends Error {
abstract schema(): z.core.$ZodType
abstract toObject(): { name: string; data: any }
abstract schema(): Schema.Top
abstract toObject(): { name: string; data: unknown }
static hasName(error: unknown, name: string): boolean {
return (
@@ -10,30 +10,42 @@ export abstract class NamedError extends Error {
)
}
static create<Name extends string, Data extends z.core.$ZodType>(name: Name, data: Data) {
const schema = z
.object({
name: z.literal(name),
data,
})
.meta({
ref: name,
})
static create<Name extends string, Fields extends Schema.Struct.Fields>(
name: Name,
fields: Fields,
): ReturnType<typeof NamedError.createSchemaClass<Name, Schema.Struct<Fields>>>
static create<Name extends string, DataSchema extends Schema.Top>(
name: Name,
data: DataSchema,
): ReturnType<typeof NamedError.createSchemaClass<Name, DataSchema>>
static create<Name extends string>(name: Name, data: Schema.Top | Schema.Struct.Fields) {
return NamedError.createSchemaClass(name, Schema.isSchema(data) ? data : Schema.Struct(data))
}
private static createSchemaClass<Name extends string, DataSchema extends Schema.Top>(name: Name, data: DataSchema) {
const schema = Schema.Struct({
name: Schema.Literal(name),
data,
}).annotate({ identifier: name })
type Data = Schema.Schema.Type<DataSchema>
const result = class extends NamedError {
public static readonly Schema = schema
public static readonly EffectSchema = schema
public static readonly tag = name
public override readonly name = name as Name
public override readonly name = name
constructor(
public readonly data: z.input<Data>,
public readonly data: Data,
options?: ErrorOptions,
) {
super(name, options)
this.name = name
}
static isInstance(input: any): input is InstanceType<typeof result> {
return typeof input === "object" && "name" in input && input.name === name
static isInstance(input: unknown): input is InstanceType<typeof result> {
return NamedError.hasName(input, name)
}
schema() {
@@ -51,10 +63,7 @@ export abstract class NamedError extends Error {
return result
}
public static readonly Unknown = NamedError.create(
"UnknownError",
z.object({
message: z.string(),
}),
)
public static readonly Unknown = NamedError.create("UnknownError", {
message: Schema.String,
})
}
-11
View File
@@ -1,11 +0,0 @@
import { z } from "zod"
export function fn<T extends z.ZodType, Result>(schema: T, cb: (input: z.infer<T>) => Result) {
const result = (input: z.infer<T>) => {
const parsed = schema.parse(input)
return cb(parsed)
}
result.force = (input: z.infer<T>) => cb(input)
result.schema = schema
return result
}
+6 -3
View File
@@ -4,11 +4,14 @@ import path from "path"
import fs from "fs/promises"
import { createWriteStream } from "fs"
import * as Global from "../global"
import z from "zod"
import { Schema } from "effect"
import { Glob } from "./glob"
export const Level = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).meta({ ref: "LogLevel", description: "Log level" })
export type Level = z.infer<typeof Level>
export const Level = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate({
identifier: "LogLevel",
description: "Log level",
})
export type Level = Schema.Schema.Type<typeof Level>
const levelPriority: Record<Level, number> = {
DEBUG: 0,
+11 -17
View File
@@ -291,25 +291,19 @@ const main = Effect.gen(function* () {
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
})
ensureLoopbackNoProxy()
useEnvProxy()
logger.log("spawning sidecar", { url })
const { listener, health } = yield* Effect.promise(() =>
spawnLocalServer(
hostname,
port,
password,
() => {
ensureLoopbackNoProxy()
useEnvProxy()
},
{
needsMigration,
userDataPath: app.getPath("userData"),
onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress),
onStdout: (message) => logger.log("sidecar stdout", { message }),
onStderr: (message) => logger.warn("sidecar stderr", { message }),
onExit: (code) => logger.warn("sidecar exited", { code }),
},
),
spawnLocalServer(hostname, port, password, {
needsMigration,
userDataPath: app.getPath("userData"),
onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress),
onStdout: (message) => logger.log("sidecar stdout", { message }),
onStderr: (message) => logger.warn("sidecar stderr", { message }),
onExit: (code) => logger.warn("sidecar exited", { code }),
}),
)
server = listener
yield* Deferred.succeed(serverReady, {
-2
View File
@@ -70,10 +70,8 @@ export async function spawnLocalServer(
hostname: string,
port: number,
password: string,
configureEnv: () => void,
options: SpawnLocalServerOptions,
) {
configureEnv?.()
const sidecar = join(dirname(fileURLToPath(import.meta.url)), "sidecar.js")
const child = utilityProcess.fork(sidecar, [], {
cwd: process.cwd(),
+4 -1
View File
@@ -1,9 +1,12 @@
import { Message, Model, Part, Session, SnapshotFileDiff } from "@opencode-ai/sdk/v2"
import { fn } from "@opencode-ai/core/util/fn"
import { iife } from "@opencode-ai/core/util/iife"
import z from "zod"
import { Storage } from "./storage"
function fn<T extends z.ZodType, Result>(schema: T, cb: (input: z.infer<T>) => Result) {
return (input: z.infer<T>) => cb(schema.parse(input))
}
export namespace Share {
export const Info = z.object({
id: z.string(),
@@ -15,7 +15,6 @@ import { Binary } from "@opencode-ai/core/util/binary"
import { NamedError } from "@opencode-ai/core/util/error"
import { DateTime } from "luxon"
import { createStore } from "solid-js/store"
import z from "zod"
import NotFound from "../[...404]"
import { Tabs } from "@opencode-ai/ui/tabs"
import { MessageNav } from "@opencode-ai/ui/message-nav"
@@ -33,13 +32,28 @@ const ClientOnlyWorkerPoolProvider = clientOnly(() =>
})),
)
const SessionDataMissingError = NamedError.create(
"SessionDataMissingError",
z.object({
sessionID: z.string(),
message: z.string().optional(),
}),
)
class SessionDataMissingError extends NamedError {
public override readonly name = "SessionDataMissingError"
constructor(
public readonly data: { sessionID: string; message?: string },
options?: ErrorOptions,
) {
super("SessionDataMissingError", options)
}
static isInstance(input: unknown): input is SessionDataMissingError {
return NamedError.hasName(input, "SessionDataMissingError")
}
schema(): never {
throw new Error("SessionDataMissingError does not expose a schema")
}
toObject() {
return { name: this.name, data: this.data }
}
}
const getData = query(async (shareID) => {
"use server"
@@ -0,0 +1,6 @@
ALTER TABLE `session` ADD `cost` real DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE `session` ADD `tokens_input` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE `session` ADD `tokens_output` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE `session` ADD `tokens_reasoning` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE `session` ADD `tokens_cache_read` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE `session` ADD `tokens_cache_write` integer DEFAULT 0 NOT NULL;
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -1094,8 +1094,8 @@ export class Agent implements ACPAgent {
const currentModeId = await (async () => {
if (!availableModes.length) return undefined
const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent()))
const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id
const defaultAgent = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo()))
const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgent.name)?.id ?? availableModes[0].id
this.sessionManager.setMode(sessionId, resolvedModeId)
return resolvedModeId
})()
@@ -1328,7 +1328,8 @@ export class Agent implements ACPAgent {
if (!current) {
this.sessionManager.setModel(session.id, model)
}
const agent = session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent())))
const agent =
session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo()))).name
const parts: Array<
| { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }
+12 -4
View File
@@ -57,6 +57,7 @@ const GeneratedAgent = Schema.Struct({
export interface Interface {
readonly get: (agent: string) => Effect.Effect<Info>
readonly list: () => Effect.Effect<Info[]>
readonly defaultInfo: () => Effect.Effect<Info>
readonly defaultAgent: () => Effect.Effect<string>
readonly generate: (input: {
description: string
@@ -206,7 +207,6 @@ export const layer = Layer.effect(
glob: "allow",
webfetch: "allow",
websearch: "allow",
codesearch: "allow",
read: "allow",
repo_clone: "allow",
repo_overview: "allow",
@@ -334,23 +334,28 @@ export const layer = Layer.effect(
)
})
const defaultAgent = Effect.fnUntraced(function* () {
const defaultInfo = Effect.fnUntraced(function* () {
const c = yield* config.get()
if (c.default_agent) {
const agent = agents[c.default_agent]
if (!agent) throw new Error(`default agent "${c.default_agent}" not found`)
if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`)
if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`)
return agent.name
return agent
}
const visible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true)
if (!visible) throw new Error("no primary visible agent found")
return visible.name
return visible
})
const defaultAgent = Effect.fnUntraced(function* () {
return (yield* defaultInfo()).name
})
return {
get,
list,
defaultInfo,
defaultAgent,
} satisfies State
}),
@@ -363,6 +368,9 @@ export const layer = Layer.effect(
list: Effect.fn("Agent.list")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.list())
}),
defaultInfo: Effect.fn("Agent.defaultInfo")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.defaultInfo())
}),
defaultAgent: Effect.fn("Agent.defaultAgent")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.defaultAgent())
}),
+200
View File
@@ -0,0 +1,200 @@
import { InstanceState } from "@/effect/instance-state"
import { Identifier } from "@/id/id"
import { Cause, Clock, Context, Deferred, Effect, Fiber, Layer, Scope, SynchronizedRef } from "effect"
export type Status = "running" | "completed" | "error" | "cancelled"
export type Info = {
id: string
type: string
title?: string
status: Status
started_at: number
completed_at?: number
output?: string
error?: string
metadata?: Record<string, unknown>
}
type Active = {
info: Info
done: Deferred.Deferred<Info>
fiber?: Fiber.Fiber<void, unknown>
}
type State = {
jobs: SynchronizedRef.SynchronizedRef<Map<string, Active>>
scope: Scope.Scope
}
type FinishResult = {
info?: Info
done?: Deferred.Deferred<Info>
}
export type StartInput = {
id?: string
type: string
title?: string
metadata?: Record<string, unknown>
run: Effect.Effect<string, unknown>
}
export type WaitInput = {
id: string
timeout?: number
}
export type WaitResult = {
info?: Info
timedOut: boolean
}
export interface Interface {
readonly list: () => Effect.Effect<Info[]>
readonly get: (id: string) => Effect.Effect<Info | undefined>
readonly start: (input: StartInput) => Effect.Effect<Info>
readonly wait: (input: WaitInput) => Effect.Effect<WaitResult>
readonly cancel: (id: string) => Effect.Effect<Info | undefined>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/BackgroundJob") {}
function snapshot(job: Active): Info {
return {
...job.info,
...(job.info.metadata ? { metadata: { ...job.info.metadata } } : {}),
}
}
function errorText(error: unknown) {
if (error instanceof Error) return error.message
return String(error)
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make<State>(
Effect.fn("BackgroundJob.state")(function* () {
return {
jobs: yield* SynchronizedRef.make(new Map()),
scope: yield* Scope.Scope,
}
}),
)
const finish = Effect.fn("BackgroundJob.finish")(function* (
id: string,
status: Exclude<Status, "running">,
data?: { output?: string; error?: string },
) {
const completed_at = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modify(
(yield* InstanceState.get(state)).jobs,
(jobs): readonly [FinishResult, Map<string, Active>] => {
const job = jobs.get(id)
if (!job) return [{}, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
const next = {
...job,
fiber: undefined,
info: {
...job.info,
status,
completed_at,
...(data?.output !== undefined ? { output: data.output } : {}),
...(data?.error !== undefined ? { error: data.error } : {}),
},
}
return [{ info: snapshot(next), done: job.done }, new Map(jobs).set(id, next)]
},
)
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
return result.info
})
const list: Interface["list"] = Effect.fn("BackgroundJob.list")(function* () {
return Array.from((yield* SynchronizedRef.get((yield* InstanceState.get(state)).jobs)).values())
.map(snapshot)
.toSorted((a, b) => a.started_at - b.started_at)
})
const get: Interface["get"] = Effect.fn("BackgroundJob.get")(function* (id) {
const job = (yield* SynchronizedRef.get((yield* InstanceState.get(state)).jobs)).get(id)
if (!job) return
return snapshot(job)
})
const start: Interface["start"] = Effect.fn("BackgroundJob.start")(function* (input) {
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const s = yield* InstanceState.get(state)
const id = input.id ?? Identifier.ascending("job")
const started_at = yield* Clock.currentTimeMillis
const done = yield* Deferred.make<Info>()
return yield* SynchronizedRef.modifyEffect(
s.jobs,
Effect.fnUntraced(function* (jobs) {
const existing = jobs.get(id)
if (existing?.info.status === "running") return [snapshot(existing), jobs] as const
const fiber = yield* restore(input.run).pipe(
Effect.matchCauseEffect({
onSuccess: (output) => finish(id, "completed", { output }),
onFailure: (cause) =>
finish(id, Cause.hasInterruptsOnly(cause) ? "cancelled" : "error", {
error: errorText(Cause.squash(cause)),
}),
}),
Effect.asVoid,
Effect.forkIn(s.scope, { startImmediately: true }),
)
const job = {
info: {
id,
type: input.type,
title: input.title,
status: "running" as const,
started_at,
metadata: input.metadata,
},
done,
fiber,
}
return [snapshot(job), new Map(jobs).set(id, job)] as const
}),
)
}),
)
})
const wait: Interface["wait"] = Effect.fn("BackgroundJob.wait")(function* (input) {
const job = (yield* SynchronizedRef.get((yield* InstanceState.get(state)).jobs)).get(input.id)
if (!job) return { timedOut: false }
if (job.info.status !== "running") return { info: snapshot(job), timedOut: false }
if (input.timeout === undefined) return { info: yield* Deferred.await(job.done), timedOut: false }
if (input.timeout <= 0) return { info: snapshot(job), timedOut: true }
const info = yield* Deferred.await(job.done).pipe(Effect.timeoutOption(input.timeout))
if (info._tag === "Some") return { info: info.value, timedOut: false }
return { info: snapshot(job), timedOut: true }
})
const cancel: Interface["cancel"] = Effect.fn("BackgroundJob.cancel")(function* (id) {
const job = (yield* SynchronizedRef.get((yield* InstanceState.get(state)).jobs)).get(id)
if (!job) return
if (job.info.status !== "running") return snapshot(job)
if (job.fiber) {
yield* Fiber.interrupt(job.fiber).pipe(Effect.ignore)
yield* Fiber.await(job.fiber).pipe(Effect.ignore)
}
const info = yield* finish(id, "cancelled")
return info
})
return Service.of({ list, get, start, wait, cancel })
}),
)
export const defaultLayer = layer
export * as BackgroundJob from "./job"
@@ -0,0 +1,39 @@
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
function displayOffsetIndex(value: string, offset: number) {
if (offset <= 0) return 0
let width = 0
for (const part of graphemes.segment(value)) {
const next = width + Bun.stringWidth(part.segment)
if (next > offset) return part.index
width = next
}
return value.length
}
export function displaySlice(value: string, start = 0, end = Bun.stringWidth(value)) {
return value.slice(displayOffsetIndex(value, start), displayOffsetIndex(value, end))
}
export function displayCharAt(value: string, offset: number) {
let width = 0
for (const part of graphemes.segment(value)) {
const next = width + Bun.stringWidth(part.segment)
if (offset === width || offset < next) return part.segment
width = next
}
}
export function mentionTriggerIndex(value: string, offset = Bun.stringWidth(value)) {
const text = displaySlice(value, 0, offset)
const index = text.lastIndexOf("@")
if (index === -1) return
const before = index === 0 ? undefined : text[index - 1]
const query = text.slice(index)
if ((before === undefined || /\s/.test(before)) && !/\s/.test(query)) {
return Bun.stringWidth(text.slice(0, index))
}
}
@@ -14,7 +14,10 @@ import { createEffect, createMemo, createResource, createSignal, onCleanup, onMo
import * as Locale from "@/util/locale"
import {
createPromptHistory,
displayCharAt,
displaySlice,
isExitCommand,
mentionTriggerIndex,
isNewCommand,
movePromptHistory,
promptCycle,
@@ -537,7 +540,7 @@ export function createPromptState(input: PromptInput): PromptState {
})
}
const restore = (value: RunPrompt, cursor = value.text.length) => {
const restore = (value: RunPrompt, cursor = Bun.stringWidth(value.text)) => {
draft = clonePrompt(value)
if (!area || area.isDestroyed) {
return
@@ -546,7 +549,7 @@ export function createPromptState(input: PromptInput): PromptState {
hide()
area.setText(value.text)
restoreParts(value.parts)
area.cursorOffset = Math.min(cursor, area.plainText.length)
area.cursorOffset = Math.min(cursor, Bun.stringWidth(area.plainText))
scheduleRows()
area.focus()
}
@@ -577,7 +580,7 @@ export function createPromptState(input: PromptInput): PromptState {
area.setText(text)
clearParts()
draft = { text: area.plainText, parts: [] }
area.cursorOffset = Math.min(text.length, area.plainText.length)
area.cursorOffset = Math.min(Bun.stringWidth(text), Bun.stringWidth(area.plainText))
scheduleRows()
area.focus()
}
@@ -610,12 +613,13 @@ export function createPromptState(input: PromptInput): PromptState {
}
if (visible() && mode() === "mention") {
if (cursor <= at() || /\s/.test(text.slice(at(), cursor))) {
const query = displaySlice(text, at(), cursor)
if (cursor <= at() || /\s/.test(query)) {
hide()
return
}
setQuery(text.slice(at() + 1, cursor))
setQuery(displaySlice(text, at() + 1, cursor))
return
}
@@ -623,19 +627,12 @@ export function createPromptState(input: PromptInput): PromptState {
return
}
const head = text.slice(0, cursor)
const idx = head.lastIndexOf("@")
if (idx === -1) {
return
}
const before = idx === 0 ? undefined : head[idx - 1]
const tail = head.slice(idx)
if ((before === undefined || /\s/.test(before)) && !/\s/.test(tail)) {
const idx = mentionTriggerIndex(text, cursor)
if (idx !== undefined) {
setAt(idx)
menu.reset()
setMode("mention")
setQuery(head.slice(idx + 1))
setQuery(displaySlice(text, idx + 1, cursor))
}
}
@@ -782,7 +779,7 @@ export function createPromptState(input: PromptInput): PromptState {
}
const cursor = area.cursorOffset
const tail = area.plainText.at(cursor)
const tail = displayCharAt(area.plainText, cursor)
const append = "@" + next.value + (tail === " " ? "" : " ")
area.cursorOffset = at()
const start = area.logicalCursor
@@ -941,7 +938,8 @@ export function createPromptState(input: PromptInput): PromptState {
}
const dir = up ? -1 : 1
if ((dir === -1 && area.cursorOffset === 0) || (dir === 1 && area.cursorOffset === area.plainText.length)) {
const endOffset = Bun.stringWidth(area.plainText)
if ((dir === -1 && area.cursorOffset === 0) || (dir === 1 && area.cursorOffset === endOffset)) {
move(dir, event)
return
}
@@ -955,7 +953,7 @@ export function createPromptState(input: PromptInput): PromptState {
? area.height - 1
: Math.max(0, (area.virtualLineCount ?? 1) - 1)
if (dir === 1 && area.visualCursor.visualRow === end) {
area.cursorOffset = area.plainText.length
area.cursorOffset = endOffset
}
}
@@ -12,6 +12,7 @@
// The leader-key cycle (promptCycle) uses a two-step pattern: first press
// arms the leader, second press within the timeout fires the action.
import type { KeyBinding } from "@opentui/core"
export { displayCharAt, displaySlice, mentionTriggerIndex } from "../prompt-display"
import { formatBinding, parseBindings } from "./keymap.shared"
import type { FooterKeybinds, RunPrompt } from "./types"
@@ -275,7 +276,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text:
return { state, apply: false }
}
if (dir === 1 && cursor !== text.length) {
if (dir === 1 && cursor !== Bun.stringWidth(text)) {
return { state, apply: false }
}
@@ -309,7 +310,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text:
index: null,
},
text: state.draft,
cursor: state.draft.length,
cursor: Bun.stringWidth(state.draft),
apply: true,
}
}
@@ -320,7 +321,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text:
index: idx,
},
text: state.items[idx].text,
cursor: dir === -1 ? 0 : state.items[idx].text.length,
cursor: dir === -1 ? 0 : Bun.stringWidth(state.items[idx].text),
apply: true,
}
}
@@ -9,7 +9,6 @@ import { Context, Effect, Layer } from "effect"
import { stringifyKeyStroke } from "@opentui/keymap"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { TuiKeybind } from "@/cli/cmd/tui/config/keybind"
import { makeRuntime } from "@/effect/run-service"
import { reusePendingTask } from "./runtime.shared"
import { resolveSession, sessionHistory } from "./session.shared"
import type { FooterKeybinds, RunDiffStyle, RunInput, RunPrompt, RunProvider } from "./types"
@@ -28,6 +27,8 @@ const DEFAULT_KEYBINDS: FooterKeybinds = {
inputNewline: [{ key: "shift+return,ctrl+return,alt+return,ctrl+j" }],
}
export const defaultKeybinds: FooterKeybinds = DEFAULT_KEYBINDS
export type ModelInfo = {
providers: RunProvider[]
variants: string[]
@@ -41,7 +42,8 @@ export type SessionInfo = {
}
type Config = Awaited<ReturnType<typeof TuiConfig.get>>
type BootService = {
export interface Interface {
readonly resolveModelInfo: (
sdk: RunInput["sdk"],
directory: string,
@@ -58,13 +60,13 @@ type BootService = {
const configTask: { current?: Promise<Config> } = {}
class Service extends Context.Service<Service, BootService>()("@opencode/RunBoot") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/RunBoot") {}
function loadConfig() {
return reusePendingTask(configTask, () => TuiConfig.get())
}
function emptyModelInfo(): ModelInfo {
export function emptyModelInfo(): ModelInfo {
return {
providers: [],
variants: [],
@@ -72,7 +74,7 @@ function emptyModelInfo(): ModelInfo {
}
}
function emptySessionInfo(): SessionInfo {
export function emptySessionInfo(): SessionInfo {
return {
first: true,
history: [],
@@ -105,7 +107,7 @@ function footerKeybinds(config: Config | undefined): FooterKeybinds {
}
}
const layer = Layer.effect(
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = Effect.fn("RunBoot.config")(() => Effect.promise(() => loadConfig().catch(() => undefined)))
@@ -192,31 +194,6 @@ const layer = Layer.effect(
}),
)
const runtime = makeRuntime(Service, layer)
export const defaultLayer = layer
// Fetches available variants and context limits for every provider/model pair.
export async function resolveModelInfo(
sdk: RunInput["sdk"],
directory: string,
model: RunInput["model"],
): Promise<ModelInfo> {
return runtime.runPromise((svc) => svc.resolveModelInfo(sdk, directory, model)).catch(() => emptyModelInfo())
}
// Fetches session messages to determine if this is the first turn and build prompt history.
export async function resolveSessionInfo(
sdk: RunInput["sdk"],
sessionID: string,
model: RunInput["model"],
): Promise<SessionInfo> {
return runtime.runPromise((svc) => svc.resolveSessionInfo(sdk, sessionID, model)).catch(() => emptySessionInfo())
}
// Reads keybind overrides from TUI config and merges them with defaults.
export async function resolveFooterKeybinds(): Promise<FooterKeybinds> {
return runtime.runPromise((svc) => svc.resolveFooterKeybinds()).catch(() => DEFAULT_KEYBINDS)
}
export async function resolveDiffStyle(): Promise<RunDiffStyle> {
return runtime.runPromise((svc) => svc.resolveDiffStyle()).catch(() => "auto")
}
export * as RunBoot from "./runtime.boot"
+68 -24
View File
@@ -14,13 +14,33 @@
// 4. runs the prompt queue until the footer closes.
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Effect, Layer } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
import { createRunDemo } from "./demo"
import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo, resolveSessionInfo } from "./runtime.boot"
import {
RunBoot,
defaultKeybinds,
emptyModelInfo,
emptySessionInfo,
type ModelInfo,
type SessionInfo,
} from "./runtime.boot"
import { createRuntimeLifecycle } from "./runtime.lifecycle"
import { recordRunSpanError, setRunSpanAttributes, withRunSpan } from "./otel"
import { trace } from "./trace"
import { cycleVariant, formatModelLabel, resolveSavedVariant, resolveVariant, saveVariant } from "./variant.shared"
import type { RunInput, RunPrompt, RunProvider } from "./types"
import { Variant, cycleVariant, formatModelLabel, resolveVariant } from "./variant.shared"
import type { RunDiffStyle, RunInput, RunPrompt, RunProvider } from "./types"
const BootLayer = Layer.mergeAll(RunBoot.defaultLayer, Variant.defaultLayer)
function persistVariant(model: RunInput["model"], variant: string | undefined) {
AppRuntime.runFork(
Effect.gen(function* () {
const variantSvc = yield* Variant.Service
yield* variantSvc.saveVariant(model, variant).pipe(Effect.orElseSucceed(() => undefined))
}).pipe(Effect.provide(BootLayer)),
)
}
/** @internal Exported for testing */
export { pickVariant, resolveVariant } from "./variant.shared"
@@ -169,25 +189,44 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
async (span) => {
const start = performance.now()
const log = trace()
const keybindTask = resolveFooterKeybinds()
const diffTask = resolveDiffStyle()
const earlyTask = AppRuntime.runPromise(
Effect.gen(function* () {
const boot = yield* RunBoot.Service
return yield* Effect.all(
{
keybinds: boot.resolveFooterKeybinds().pipe(Effect.orElseSucceed(() => defaultKeybinds)),
diffStyle: boot.resolveDiffStyle().pipe(Effect.orElseSucceed((): RunDiffStyle => "auto")),
},
{ concurrency: "unbounded" },
)
}).pipe(Effect.provide(BootLayer)),
)
const ctx = await input.boot()
const modelTask = resolveModelInfo(ctx.sdk, ctx.directory, ctx.model)
const sessionTask =
ctx.resume === true
? resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model)
: Promise.resolve({
first: true,
history: [],
variant: undefined,
})
const savedTask = resolveSavedVariant(ctx.model)
const [keybinds, diffStyle, session, savedVariant] = await Promise.all([
keybindTask,
diffTask,
sessionTask,
savedTask,
])
const modelTask = AppRuntime.runPromise(
Effect.gen(function* () {
const boot = yield* RunBoot.Service
return yield* boot.resolveModelInfo(ctx.sdk, ctx.directory, ctx.model)
}).pipe(Effect.provide(BootLayer)),
).catch((): ModelInfo => emptyModelInfo())
const sessionAndSavedTask = AppRuntime.runPromise(
Effect.gen(function* () {
const boot = yield* RunBoot.Service
const variantSvc = yield* Variant.Service
return yield* Effect.all(
{
session:
ctx.resume === true
? boot
.resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model)
.pipe(Effect.orElseSucceed((): SessionInfo => emptySessionInfo()))
: Effect.succeed<SessionInfo>({ first: true, history: [], variant: undefined }),
savedVariant: variantSvc.resolveSavedVariant(ctx.model).pipe(Effect.orElseSucceed(() => undefined)),
},
{ concurrency: "unbounded" },
)
}).pipe(Effect.provide(BootLayer)),
)
const [{ keybinds, diffStyle }, { session, savedVariant }] = await Promise.all([earlyTask, sessionAndSavedTask])
const state: RuntimeState = {
shown: !session.first,
aborting: false,
@@ -280,7 +319,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
}
state.activeVariant = cycleVariant(state.activeVariant, state.variants)
saveVariant(state.model, state.activeVariant)
persistVariant(state.model, state.activeVariant)
setRunSpanAttributes(span, {
"opencode.model.variant": state.activeVariant,
})
@@ -298,7 +337,12 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
state.model = model
state.activeVariant = undefined
state.variants = variantsFor(state.providers, model)
const switching = resolveSavedVariant(model).then((saved) => {
const switching = AppRuntime.runPromise(
Effect.gen(function* () {
const variantSvc = yield* Variant.Service
return yield* variantSvc.resolveSavedVariant(model).pipe(Effect.orElseSucceed(() => undefined))
}).pipe(Effect.provide(BootLayer)),
).then((saved) => {
const current = state.model
if (!current || current.providerID !== model.providerID || current.modelID !== model.modelID) {
return
@@ -343,7 +387,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
}
state.activeVariant = variant
saveVariant(state.model, state.activeVariant)
persistVariant(state.model, state.activeVariant)
setRunSpanAttributes(span, {
"opencode.model.variant": state.activeVariant,
})
@@ -9,7 +9,6 @@
import path from "path"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Context, Effect, Layer } from "effect"
import { makeRuntime } from "@/effect/run-service"
import { Global } from "@opencode-ai/core/global"
import { isRecord } from "@/util/record"
import { createSession, sessionVariant, type RunSession, type SessionMessages } from "./session.shared"
@@ -20,16 +19,13 @@ const MODEL_FILE = path.join(Global.Path.state, "model.json")
type ModelState = Record<string, unknown> & {
variant?: Record<string, string | undefined>
}
type VariantService = {
export interface Interface {
readonly resolveSavedVariant: (model: RunInput["model"]) => Effect.Effect<string | undefined>
readonly saveVariant: (model: RunInput["model"], variant: string | undefined) => Effect.Effect<void>
}
type VariantRuntime = {
resolveSavedVariant(model: RunInput["model"]): Promise<string | undefined>
saveVariant(model: RunInput["model"], variant: string | undefined): Promise<void>
}
class Service extends Context.Service<Service, VariantService>()("@opencode/RunVariant") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/RunVariant") {}
function modelKey(provider: string, model: string): string {
return `${provider}/${model}`
@@ -135,81 +131,62 @@ function state(value: unknown): ModelState {
}
}
function createLayer(fs = AppFileSystem.defaultLayer) {
return Layer.fresh(
Layer.effect(
Service,
Effect.gen(function* () {
const file = yield* AppFileSystem.Service
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const file = yield* AppFileSystem.Service
const read = Effect.fn("RunVariant.read")(function* () {
return yield* file.readJson(MODEL_FILE).pipe(
Effect.map(state),
Effect.catchCause(() => Effect.succeed(state(undefined))),
)
const read = Effect.fn("RunVariant.read")(function* () {
return yield* file.readJson(MODEL_FILE).pipe(
Effect.map(state),
Effect.catchCause(() => Effect.succeed(state(undefined))),
)
})
const resolveSavedVariant = Effect.fn("RunVariant.resolveSavedVariant")(function* (model: RunInput["model"]) {
if (!model) {
return undefined
}
return (yield* read()).variant?.[variantKey(model)]
})
const saveVariant = Effect.fn("RunVariant.saveVariant")(function* (
model: RunInput["model"],
variant: string | undefined,
) {
if (!model) {
return
}
const current = yield* read()
const next = {
...current.variant,
}
const key = variantKey(model)
if (variant) {
next[key] = variant
}
if (!variant) {
delete next[key]
}
yield* file
.writeJson(MODEL_FILE, {
...current,
variant: next,
})
.pipe(Effect.orElseSucceed(() => undefined))
})
const resolveSavedVariant = Effect.fn("RunVariant.resolveSavedVariant")(function* (model: RunInput["model"]) {
if (!model) {
return undefined
}
return Service.of({
resolveSavedVariant,
saveVariant,
})
}),
)
return (yield* read()).variant?.[variantKey(model)]
})
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
const saveVariant = Effect.fn("RunVariant.saveVariant")(function* (
model: RunInput["model"],
variant: string | undefined,
) {
if (!model) {
return
}
const current = yield* read()
const next = {
...current.variant,
}
const key = variantKey(model)
if (variant) {
next[key] = variant
}
if (!variant) {
delete next[key]
}
yield* file
.writeJson(MODEL_FILE, {
...current,
variant: next,
})
.pipe(Effect.orElseSucceed(() => undefined))
})
return Service.of({
resolveSavedVariant,
saveVariant,
})
}),
).pipe(Layer.provide(fs)),
)
}
/** @internal Exported for testing. */
export function createVariantRuntime(fs = AppFileSystem.defaultLayer): VariantRuntime {
const runtime = makeRuntime(Service, createLayer(fs))
return {
resolveSavedVariant: (model) => runtime.runPromise((svc) => svc.resolveSavedVariant(model)).catch(() => undefined),
saveVariant: (model, variant) => runtime.runPromise((svc) => svc.saveVariant(model, variant)).catch(() => {}),
}
}
const runtime = createVariantRuntime()
export async function resolveSavedVariant(model: RunInput["model"]): Promise<string | undefined> {
return runtime.resolveSavedVariant(model)
}
export function saveVariant(model: RunInput["model"], variant: string | undefined): void {
void runtime.saveVariant(model, variant)
}
export * as Variant from "./variant.shared"
+2 -10
View File
@@ -164,8 +164,8 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* (
Effect.gen(function* () {
const messages = yield* svc.messages({ sessionID: session.id })
let sessionCost = 0
let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
const sessionCost = session.cost ?? 0
const sessionTokens = session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
let sessionToolUsage: Record<string, number> = {}
let sessionModelUsage: Record<
string,
@@ -178,8 +178,6 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* (
for (const message of messages) {
if (message.info.role === "assistant") {
sessionCost += message.info.cost || 0
const modelKey = `${message.info.providerID}/${message.info.modelID}`
if (!sessionModelUsage[modelKey]) {
sessionModelUsage[modelKey] = {
@@ -192,12 +190,6 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* (
sessionModelUsage[modelKey].cost += message.info.cost || 0
if (message.info.tokens) {
sessionTokens.input += message.info.tokens.input || 0
sessionTokens.output += message.info.tokens.output || 0
sessionTokens.reasoning += message.info.tokens.reasoning || 0
sessionTokens.cache.read += message.info.tokens.cache?.read || 0
sessionTokens.cache.write += message.info.tokens.cache?.write || 0
sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0
sessionModelUsage[modelKey].tokens.output +=
(message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0)
@@ -20,6 +20,7 @@ import { useFrecency } from "./frecency"
import { useBindings } from "../../keymap"
import { Reference } from "@/reference/reference"
import type { Config } from "@/config/config"
import { displayCharAt, mentionTriggerIndex } from "@/cli/cmd/prompt-display"
function removeLineRange(input: string) {
const hashIndex = input.lastIndexOf("#")
@@ -159,7 +160,7 @@ export function Autocomplete(props: {
const input = props.input()
const currentCursorOffset = input.cursorOffset
const charAfterCursor = props.value.at(currentCursorOffset)
const charAfterCursor = displayCharAt(props.value, currentCursorOffset)
const needsSpace = charAfterCursor !== " "
const append = "@" + text + (needsSpace ? " " : "")
@@ -787,13 +788,8 @@ export function Autocomplete(props: {
}
// Check for "@" trigger - find the nearest "@" before cursor with no whitespace between
const text = value.slice(0, offset)
const idx = text.lastIndexOf("@")
if (idx === -1) return
const between = text.slice(idx)
const before = idx === 0 ? undefined : value[idx - 1]
if ((before === undefined || /\s/.test(before)) && !between.match(/\s/)) {
const idx = mentionTriggerIndex(value, offset)
if (idx !== undefined) {
show("@")
setStore("index", idx)
}
@@ -337,6 +337,7 @@ export function Prompt(props: PromptProps) {
const usage = createMemo(() => {
if (!props.sessionID) return
const session = sync.session.get(props.sessionID)
const msg = sync.data.message[props.sessionID] ?? []
const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
if (!last) return
@@ -347,7 +348,7 @@ export function Prompt(props: PromptProps) {
const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined
const cost = msg.reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0)
const cost = session?.cost ?? 0
return {
context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens),
cost: cost > 0 ? money.format(cost) : undefined,
@@ -2,39 +2,33 @@ import type { Event } from "@opencode-ai/sdk/v2"
import { useProject } from "./project"
import { useSDK } from "./sdk"
type EventMetadata = {
workspace: string | undefined
}
export function useEvent() {
const project = useProject()
const sdk = useSDK()
function subscribe(handler: (event: Event) => void) {
function subscribe(handler: (event: Event, metadata: EventMetadata) => void) {
return sdk.event.on("event", (event) => {
if (event.payload.type === "sync") {
return
}
// Special hack for truly global events
if (event.directory === "global") {
handler(event.payload)
}
if (project.workspace.current()) {
if (event.workspace === project.workspace.current()) {
handler(event.payload)
}
return
}
if (event.directory === project.instance.directory()) {
handler(event.payload)
if (event.directory === "global" || event.project === project.project()) {
handler(event.payload, { workspace: event.workspace })
}
})
}
function on<T extends Event["type"]>(type: T, handler: (event: Extract<Event, { type: T }>) => void) {
return subscribe((event) => {
function on<T extends Event["type"]>(
type: T,
handler: (event: Extract<Event, { type: T }>, metadata: EventMetadata) => void,
) {
return subscribe((event: Event, metadata: EventMetadata) => {
if (event.type !== type) return
handler(event as Extract<Event, { type: T }>)
handler(event as Extract<Event, { type: T }>, metadata)
})
}
@@ -113,7 +113,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const kv = useKV()
const fullSyncedSessions = new Set<string>()
let syncedWorkspace = project.workspace.current()
function sessionListQuery(): { scope?: "project"; path?: string } {
if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" }
@@ -131,7 +130,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
}
event.subscribe((event) => {
event.subscribe((event, { workspace }) => {
switch (event.type) {
case "server.instance.disposed":
void bootstrap()
@@ -346,7 +345,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
case "message.part.removed": {
const parts = store.part[event.properties.messageID]
const result = Binary.search(parts, event.properties.partID, (p) => p.id)
if (result.found)
if (result.found) {
setStore(
"part",
event.properties.messageID,
@@ -354,6 +353,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
draft.splice(result.index, 1)
}),
)
}
break
}
@@ -364,7 +364,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
case "vcs.branch.updated": {
setStore("vcs", { branch: event.properties.branch })
if (workspace === project.workspace.current()) {
setStore("vcs", { branch: event.properties.branch })
}
break
}
}
@@ -376,10 +378,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
async function bootstrap(input: { fatal?: boolean } = {}) {
const fatal = input.fatal ?? true
const workspace = project.workspace.current()
if (workspace !== syncedWorkspace) {
fullSyncedSessions.clear()
syncedWorkspace = workspace
}
const projectPromise = project.sync()
const sessionListPromise = projectPromise.then(() => listSessions())
@@ -13,7 +13,8 @@ const money = new Intl.NumberFormat("en-US", {
function View(props: { api: TuiPluginApi; session_id: string }) {
const theme = () => props.api.theme.current
const msg = createMemo(() => props.api.state.session.messages(props.session_id))
const cost = createMemo(() => msg().reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0))
const session = createMemo(() => props.api.state.session.get(props.session_id))
const cost = createMemo(() => session()?.cost ?? 0)
const state = createMemo(() => {
const last = msg().findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
@@ -438,9 +438,6 @@ function AssistantTool(props: { part: SessionMessageAssistantTool; sessionID: st
<Match when={props.part.name === "webfetch"}>
<WebFetch {...toolprops} />
</Match>
<Match when={props.part.name === "codesearch"}>
<CodeSearch {...toolprops} />
</Match>
<Match when={props.part.name === "websearch"}>
<WebSearch {...toolprops} />
</Match>
@@ -773,15 +770,6 @@ function WebFetch(props: ToolProps) {
)
}
function CodeSearch(props: ToolProps) {
return (
<InlineTool icon="◇" pending="Searching code..." complete={toolComplete(props.part)} part={props.part}>
Exa Code Search "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "}
<Show when={numberValue(props.metadata.results)}>{(results) => <>({results()} results)</>}</Show>
</InlineTool>
)
}
function WebSearch(props: ToolProps) {
const label = createMemo(() => webSearchProviderLabel(props.metadata.provider))
return (
@@ -147,6 +147,9 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
count() {
return sync.data.session.length
},
get(sessionID) {
return sync.session.get(sessionID)
},
diff(sessionID) {
return (sync.data.session_diff[sessionID] ?? []).flatMap((item) =>
item.file === undefined ? [] : [{ ...item, file: item.file }],
@@ -10,6 +10,7 @@ import {
onMount,
Show,
Switch,
untrack,
useContext,
} from "solid-js"
import { Dynamic } from "solid-js/web"
@@ -242,7 +243,7 @@ export function Session() {
createEffect(() => {
const sessionID = route.sessionID
void (async () => {
const previousWorkspace = project.workspace.current()
const previousWorkspace = untrack(() => project.workspace.current())
const result = await sdk.client.session.get({ sessionID }, { throwOnError: true })
if (!result.data) {
toast.show({
@@ -1984,11 +1985,11 @@ function WebFetch(props: ToolProps<typeof WebFetchTool>) {
}
function WebSearch(props: ToolProps<typeof WebSearchTool>) {
const metadata = props.metadata as { numResults?: number; provider?: unknown }
const metadata = () => props.metadata as { numResults?: number; provider?: unknown }
return (
<InlineTool icon="◈" pending="Searching web..." complete={props.input.query} part={props.part}>
{webSearchProviderLabel(metadata.provider)} "{props.input.query}"{" "}
<Show when={metadata.numResults}>({metadata.numResults} results)</Show>
{webSearchProviderLabel(metadata().provider)} "{props.input.query}"{" "}
<Show when={metadata().numResults}>({metadata().numResults} results)</Show>
</InlineTool>
)
}
@@ -42,7 +42,7 @@ export function SubagentFooter() {
const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined
const cost = msg.reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0)
const cost = session()?.cost ?? 0
const money = new Intl.NumberFormat("en-US", {
style: "currency",
+2 -2
View File
@@ -78,8 +78,8 @@ export function FormatError(input: unknown) {
].join("\n")
}
// UICancelledError: void (no data)
if (NamedError.hasName(input, "UICancelledError")) {
// UICancelledError: user cancelled an interactive CLI prompt
if (isTaggedError(input, "UICancelledError") || NamedError.hasName(input, "UICancelledError")) {
return ""
}
}
+2 -3
View File
@@ -1,6 +1,5 @@
import z from "zod"
import { EOL } from "os"
import { NamedError } from "@opencode-ai/core/util/error"
import { Schema } from "effect"
import { logo as glyphs } from "./logo"
const wordmark = [
@@ -10,7 +9,7 @@ const wordmark = [
`▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`,
]
export const CancelledError = NamedError.create("UICancelledError", z.void())
export class CancelledError extends Schema.TaggedErrorClass<CancelledError>()("UICancelledError", {}) {}
export const Style = {
TEXT_HIGHLIGHT: "\x1b[96m",
+15 -9
View File
@@ -2,7 +2,6 @@ import * as Log from "@opencode-ai/core/util/log"
import path from "path"
import { pathToFileURL } from "url"
import os from "os"
import z from "zod"
import { mergeDeep } from "remeda"
import { Global } from "@opencode-ai/core/global"
import fsNode from "fs/promises"
@@ -357,14 +356,11 @@ function writableGlobal(info: Info) {
return next
}
export const ConfigDirectoryTypoError = NamedError.create(
"ConfigDirectoryTypoError",
z.object({
path: z.string(),
dir: z.string(),
suggestion: z.string(),
}),
)
export const ConfigDirectoryTypoError = NamedError.create("ConfigDirectoryTypoError", {
path: Schema.String,
dir: Schema.String,
suggestion: Schema.String,
})
export const layer = Layer.effect(
Service,
@@ -409,6 +405,16 @@ export const layer = Layer.effect(
const loadGlobal = Effect.fnUntraced(function* () {
let result: Info = {}
// Seed the default global config with the schema for editor completion, but avoid writing when the user
// explicitly routes config through env-provided paths or content.
if (!Flag.OPENCODE_CONFIG && !Flag.OPENCODE_CONFIG_DIR && !Flag.OPENCODE_CONFIG_CONTENT) {
const file = globalConfigFile()
if (!existsSync(file)) {
yield* fs
.writeWithDirs(file, JSON.stringify({ $schema: "https://opencode.ai/config.json" }, null, 2))
.pipe(Effect.catch(() => Effect.void))
}
}
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json")))
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json")))
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc")))
+16 -14
View File
@@ -1,21 +1,23 @@
export * as ConfigError from "./error"
import z from "zod"
import { NamedError } from "@opencode-ai/core/util/error"
import { Schema } from "effect"
export const JsonError = NamedError.create(
"ConfigJsonError",
z.object({
path: z.string(),
message: z.string().optional(),
const Issue = Schema.StructWithRest(
Schema.Struct({
message: Schema.String,
path: Schema.Array(Schema.String),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
export const InvalidError = NamedError.create(
"ConfigInvalidError",
z.object({
path: z.string(),
issues: z.custom<z.core.$ZodIssue[]>().optional(),
message: z.string().optional(),
}),
)
export const JsonError = NamedError.create("ConfigJsonError", {
path: Schema.String,
message: Schema.optional(Schema.String),
})
export const InvalidError = NamedError.create("ConfigInvalidError", {
path: Schema.String,
issues: Schema.optional(Schema.Array(Issue)),
message: Schema.optional(Schema.String),
})
+5 -8
View File
@@ -1,6 +1,6 @@
import { NamedError } from "@opencode-ai/core/util/error"
import matter from "gray-matter"
import { z } from "zod"
import { Schema } from "effect"
import { Filesystem } from "@/util/filesystem"
export const FILE_REGEX = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
@@ -88,12 +88,9 @@ export async function parse(filePath: string) {
}
}
export const FrontmatterError = NamedError.create(
"ConfigFrontmatterError",
z.object({
path: z.string(),
message: z.string(),
}),
)
export const FrontmatterError = NamedError.create("ConfigFrontmatterError", {
path: Schema.String,
message: Schema.String,
})
export * as ConfigMarkdown from "./markdown"
+7 -4
View File
@@ -2,7 +2,6 @@ export * as ConfigParse from "./parse"
import { type ParseError as JsoncParseError, parse as parseJsoncImpl, printParseErrorCode } from "jsonc-parser"
import { Cause, Exit, Schema as EffectSchema, SchemaIssue } from "effect"
import type z from "zod"
import type { DeepMutable } from "@opencode-ai/core/schema"
import { InvalidError, JsonError } from "./error"
@@ -48,7 +47,7 @@ export function schema<S extends EffectSchema.Decoder<unknown, never>>(
keys: extra,
path: [],
message: `Unrecognized key${extra.length === 1 ? "" : "s"}: ${extra.join(", ")}`,
} as z.core.$ZodIssue,
},
],
})
}
@@ -61,8 +60,12 @@ export function schema<S extends EffectSchema.Decoder<unknown, never>>(
{
path: source,
issues: EffectSchema.isSchemaError(error)
? (SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues as z.core.$ZodIssue[])
: ([{ code: "custom", message: String(error), path: [] }] as z.core.$ZodIssue[]),
? SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues.map((issue) => ({
...issue,
message: issue.message,
path: issue.path?.map(String) ?? [],
}))
: [{ message: String(error), path: [] }],
},
{ cause: error },
)
@@ -27,7 +27,6 @@ const InputObject = Schema.StructWithRest(
question: Schema.optional(Action),
webfetch: Schema.optional(Action),
websearch: Schema.optional(Action),
codesearch: Schema.optional(Action),
repo_clone: Schema.optional(Rule),
repo_overview: Schema.optional(Rule),
lsp: Schema.optional(Rule),
+105 -3
View File
@@ -2,7 +2,9 @@ import { Context, Effect, Layer } from "effect"
import { Database } from "./storage/db"
import { DataMigrationTable } from "./data-migration.sql"
import * as Log from "@opencode-ai/core/util/log"
import { eq } from "drizzle-orm"
import { and, asc, eq, gt, inArray, sql } from "drizzle-orm"
import { MessageTable, SessionTable } from "./session/session.sql"
import type { SessionID } from "./session/schema"
export type Migration<R = never> = {
name: string
@@ -18,7 +20,105 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Da
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const migrations: Migration[] = []
const migrations: Migration[] = [
{
name: "session_usage_from_messages",
run: Effect.gen(function* () {
type Usage = {
cost: number
tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } }
}
for (let cursor: SessionID | undefined, page = 1; ; page++) {
const next = yield* Effect.gen(function* () {
const sessions = yield* Effect.sync(() =>
Database.use((db) =>
db
.select({ id: SessionTable.id })
.from(SessionTable)
.where(cursor ? gt(SessionTable.id, cursor) : undefined)
.orderBy(asc(SessionTable.id))
.limit(100)
.all(),
),
)
if (sessions.length === 0) return
yield* Effect.sync(() =>
Database.transaction((db) => {
const usageBySession = new Map<SessionID, Usage>(
sessions.map((session) => [
session.id,
{ cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } },
]),
)
for (const row of db
.select({
session_id: MessageTable.session_id,
cost: sql<number>`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.cost'), 0)), 0)`,
tokens_input: sql<number>`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.input'), 0)), 0)`,
tokens_output: sql<number>`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.output'), 0)), 0)`,
tokens_reasoning: sql<number>`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.reasoning'), 0)), 0)`,
tokens_cache_read: sql<number>`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.cache.read'), 0)), 0)`,
tokens_cache_write: sql<number>`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.cache.write'), 0)), 0)`,
})
.from(MessageTable)
.where(
and(
inArray(
MessageTable.session_id,
sessions.map((session) => session.id),
),
sql`json_extract(${MessageTable.data}, '$.role') = 'assistant'`,
),
)
.groupBy(MessageTable.session_id)
.all()) {
const current = usageBySession.get(row.session_id)
if (!current) continue
current.cost = row.cost
current.tokens.input = row.tokens_input
current.tokens.output = row.tokens_output
current.tokens.reasoning = row.tokens_reasoning
current.tokens.cache.read = row.tokens_cache_read
current.tokens.cache.write = row.tokens_cache_write
}
for (const [sessionID, value] of usageBySession) {
db.update(SessionTable)
.set({
cost: value.cost,
tokens_input: value.tokens.input,
tokens_output: value.tokens.output,
tokens_reasoning: value.tokens.reasoning,
tokens_cache_read: value.tokens.cache.read,
tokens_cache_write: value.tokens.cache.write,
time_updated: sql`${SessionTable.time_updated}`,
})
.where(eq(SessionTable.id, sessionID))
.run()
}
}),
)
return sessions.at(-1)?.id
}).pipe(
Effect.withSpan("DataMigration.sessionUsage.page", {
attributes: {
"data_migration.name": "session_usage_from_messages",
"data_migration.page": page,
"data_migration.cursor": cursor ?? "",
},
}),
)
if (!next) return
cursor = next
yield* Effect.sleep("10 millis")
}
}),
},
]
yield* Effect.gen(function* () {
if (migrations.length === 0) return
@@ -46,7 +146,9 @@ export const layer = Layer.effect(
)
}
}).pipe(
Effect.tapCause((cause) => Effect.logError("failed to run data migrations", { cause })),
Effect.tapCause((cause) =>
Effect.logError("failed to run data migrations").pipe(Effect.annotateLogs("cause", cause)),
),
Effect.ignore,
Effect.forkScoped,
)
@@ -55,6 +55,7 @@ import { SyncEvent } from "@/sync"
import { Npm } from "@opencode-ai/core/npm"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import { DataMigration } from "@/data-migration"
import { BackgroundJob } from "@/background/job"
export const AppLayer = Layer.mergeAll(
Npm.defaultLayer,
@@ -81,6 +82,7 @@ export const AppLayer = Layer.mergeAll(
Todo.defaultLayer,
Session.defaultLayer,
SessionStatus.defaultLayer,
BackgroundJob.defaultLayer,
SessionRunState.defaultLayer,
SessionProcessor.defaultLayer,
SessionCompaction.defaultLayer,
+1 -1
View File
@@ -181,7 +181,7 @@ export const make = <A, E = never>(
return [
Effect.gen(function* () {
yield* Fiber.interrupt(st.run.fiber)
yield* Deferred.await(st.run.done).pipe(Effect.exit, Effect.asVoid)
yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid)
yield* idleIfCurrent()
}),
{ _tag: "Idle" } as const,
+1
View File
@@ -1,6 +1,7 @@
import { randomBytes } from "crypto"
const prefixes = {
job: "job",
event: "evt",
session: "ses",
message: "msg",
+4 -8
View File
@@ -1,5 +1,4 @@
import { BusEvent } from "@/bus/bus-event"
import z from "zod"
import { Schema } from "effect"
import { NamedError } from "@opencode-ai/core/util/error"
import * as Log from "@opencode-ai/core/util/log"
@@ -24,14 +23,11 @@ export const Event = {
),
}
export const AlreadyInstalledError = NamedError.create("AlreadyInstalledError", z.object({}))
export const AlreadyInstalledError = NamedError.create("AlreadyInstalledError", {})
export const InstallFailedError = NamedError.create(
"InstallFailedError",
z.object({
stderr: z.string(),
}),
)
export const InstallFailedError = NamedError.create("InstallFailedError", {
stderr: Schema.String,
})
export function ide() {
if (process.env["TERM_PROGRAM"] === "vscode") {
+11 -7
View File
@@ -39,6 +39,7 @@ import { PluginCommand } from "./cli/cmd/plug"
import { Heap } from "./cli/heap"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process"
import { isRecord } from "@/util/record"
const processMetadata = ensureProcessMetadata("main")
@@ -203,13 +204,6 @@ try {
}
} catch (e) {
let data: Record<string, any> = {}
if (e instanceof NamedError) {
const obj = e.toObject()
Object.assign(data, {
...obj.data,
})
}
if (e instanceof Error) {
Object.assign(data, {
name: e.name,
@@ -219,6 +213,16 @@ try {
})
}
if (e instanceof NamedError) {
const obj = e.toObject()
if (isRecord(obj.data)) {
for (const [key, value] of Object.entries(obj.data)) {
if (key === "name" || key === "stack" || key === "cause") continue
data[key] = value
}
}
}
if (e instanceof ResolveMessage) {
Object.assign(data, {
name: e.name,
+3 -7
View File
@@ -7,7 +7,6 @@ import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types
import * as Log from "@opencode-ai/core/util/log"
import { Process } from "@/util/process"
import { LANGUAGE_EXTENSIONS } from "./language"
import z from "zod"
import { Schema } from "effect"
import type * as LSPServer from "./server"
import { NamedError } from "@opencode-ai/core/util/error"
@@ -32,12 +31,9 @@ export type Info = NonNullable<Awaited<ReturnType<typeof create>>>
export type Diagnostic = VSCodeDiagnostic
export const InitializeError = NamedError.create(
"LSPInitializeError",
z.object({
serverID: z.string(),
}),
)
export const InitializeError = NamedError.create("LSPInitializeError", {
serverID: Schema.String,
})
export const Event = {
Diagnostics: BusEvent.define(
+10 -15
View File
@@ -6,6 +6,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
import {
CallToolResultSchema,
ListToolsResultSchema,
ToolSchema,
type Tool as MCPToolDef,
ToolListChangedNotificationSchema,
@@ -14,7 +15,6 @@ import { Config } from "@/config/config"
import { ConfigMCP } from "../config/mcp"
import * as Log from "@opencode-ai/core/util/log"
import { NamedError } from "@opencode-ai/core/util/error"
import z from "zod/v4"
import { Installation } from "../installation"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { withTimeout } from "@/util/timeout"
@@ -35,13 +35,8 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
const log = Log.create({ service: "mcp" })
const DEFAULT_TIMEOUT = 30_000
const TolerantToolSchema = ToolSchema.extend({
outputSchema: z.unknown().optional(),
})
const TolerantListToolsResultSchema = z.looseObject({
tools: z.array(TolerantToolSchema),
nextCursor: z.string().optional(),
const TolerantListToolsResultSchema = ListToolsResultSchema.extend({
tools: ToolSchema.omit({ outputSchema: true }).array(),
})
export const Resource = Schema.Struct({
@@ -68,12 +63,9 @@ export const BrowserOpenFailed = BusEvent.define(
}),
)
export const Failed = NamedError.create(
"MCPFailed",
z.object({
name: z.string(),
}),
)
export const Failed = NamedError.create("MCPFailed", {
name: Schema.String,
})
type MCPClient = Client
@@ -140,7 +132,10 @@ function listTools(key: string, client: MCPClient, timeout: number) {
log.warn("failed to validate MCP tool output schemas, retrying without output schema validation", { key, error })
return Effect.tryPromise({
try: () => client.request({ method: "tools/list" }, TolerantListToolsResultSchema, { timeout }),
try: () =>
client.request({ method: "tools/list" }, TolerantListToolsResultSchema, {
timeout,
}),
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
}).pipe(
Effect.map((result) =>
+1 -1
View File
@@ -37,7 +37,7 @@ export const layer = Layer.effect(
const run = Effect.gen(function* () {
const ctx = yield* InstanceState.context
yield* Effect.logInfo("bootstrapping", { directory: ctx.directory })
yield* Effect.logInfo("bootstrapping").pipe(Effect.annotateLogs("directory", ctx.directory))
// everything depends on config so eager load it for nice traces
yield* config.get()
// Plugin can mutate config so it has to be initialized before anything else.
@@ -156,7 +156,9 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
Effect.gen(function* () {
const exit = yield* Deferred.await(item[1].deferred).pipe(Effect.exit)
if (Exit.isFailure(exit)) {
yield* Effect.logWarning("instance dispose failed", { key: item[0], cause: exit.cause })
yield* Effect.logWarning("instance dispose failed").pipe(
Effect.annotateLogs({ key: item[0], cause: exit.cause }),
)
yield* removeEntry(item[0], item[1])
return
}
+5 -5
View File
@@ -1,7 +1,7 @@
import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin"
import { Auth } from "@/auth"
import { InstanceState } from "@/effect/instance-state"
import { namedSchemaError } from "@/util/named-schema-error"
import { NamedError } from "@opencode-ai/core/util/error"
import { optionalOmitUndefined } from "@opencode-ai/core/schema"
import { Plugin } from "../plugin"
import { ProviderID } from "./schema"
@@ -64,13 +64,13 @@ export const CallbackInput = Schema.Struct({
})
export type CallbackInput = Schema.Schema.Type<typeof CallbackInput>
export const OauthMissing = namedSchemaError("ProviderAuthOauthMissing", { providerID: ProviderID })
export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", { providerID: ProviderID })
export const OauthCodeMissing = namedSchemaError("ProviderAuthOauthCodeMissing", { providerID: ProviderID })
export const OauthCodeMissing = NamedError.create("ProviderAuthOauthCodeMissing", { providerID: ProviderID })
export const OauthCallbackFailed = namedSchemaError("ProviderAuthOauthCallbackFailed", {})
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", {})
export const ValidationFailed = namedSchemaError("ProviderAuthValidationFailed", {
export const ValidationFailed = NamedError.create("ProviderAuthValidationFailed", {
field: Schema.String,
message: Schema.String,
})
+3 -1
View File
@@ -177,7 +177,9 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | HttpClie
yield* invalidate
}),
).pipe(
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause })),
Effect.tapCause((cause) =>
Effect.logError("Failed to fetch models.dev").pipe(Effect.annotateLogs("cause", cause)),
),
Effect.ignore,
)
})
+3 -3
View File
@@ -13,7 +13,7 @@ import { Auth } from "../auth"
import { Env } from "../env"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Flag } from "@opencode-ai/core/flag/flag"
import { namedSchemaError } from "@/util/named-schema-error"
import { NamedError } from "@opencode-ai/core/util/error"
import { iife } from "@/util/iife"
import { Global } from "@opencode-ai/core/global"
import path from "path"
@@ -1749,13 +1749,13 @@ export function parseModel(model: string) {
}
}
export const ModelNotFoundError = namedSchemaError("ProviderModelNotFoundError", {
export const ModelNotFoundError = NamedError.create("ProviderModelNotFoundError", {
providerID: ProviderID,
modelID: ModelID,
suggestions: Schema.optional(Schema.Array(Schema.String)),
})
export const InitError = namedSchemaError("ProviderInitError", {
export const InitError = NamedError.create("ProviderInitError", {
providerID: ProviderID,
})
+3 -1
View File
@@ -169,7 +169,9 @@ export const layer = Layer.effect(
).pipe(
Effect.asVoid,
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize reference repository", { name: reference.name, cause }),
Effect.logWarning("failed to materialize reference repository").pipe(
Effect.annotateLogs({ name: reference.name, cause }),
),
),
),
)
@@ -79,7 +79,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
const list = yield* registry.tools({
providerID: ctx.query.provider,
modelID: ctx.query.model,
agent: yield* agents.get(yield* agents.defaultAgent()),
agent: yield* agents.defaultInfo(),
})
return list.map((item) => ({
id: item.id,
@@ -299,7 +299,9 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
yield* promptSvc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID }).pipe(
Effect.catchCause((cause) =>
Effect.gen(function* () {
yield* Effect.logError("prompt_async failed", { sessionID: ctx.params.sessionID, cause })
yield* Effect.logError("prompt_async failed").pipe(
Effect.annotateLogs({ sessionID: ctx.params.sessionID, cause }),
)
yield* bus.publish(Session.Event.Error, {
sessionID: ctx.params.sessionID,
error: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(),
@@ -0,0 +1,14 @@
import { Schema } from "effect"
import { NamedError } from "@opencode-ai/core/util/error"
export const OutputLengthError = NamedError.create("MessageOutputLengthError", {})
export const AuthError = NamedError.create("ProviderAuthError", {
providerID: Schema.String,
message: Schema.String,
})
export const Shared = [AuthError.EffectSchema, NamedError.Unknown.EffectSchema, OutputLengthError.EffectSchema] as const
export const SharedSchema = Schema.Union(Shared)
export * as MessageError from "./message-error"
+8 -15
View File
@@ -23,8 +23,10 @@ import type { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
import { Effect, Schema, Types } from "effect"
import { NonNegativeInt } from "@opencode-ai/core/schema"
import { namedSchemaError } from "@/util/named-schema-error"
import * as EffectLogger from "@opencode-ai/core/effect/logger"
import { MessageError } from "./message-error"
import { AuthError, OutputLengthError } from "./message-error"
export { AuthError, OutputLengthError } from "./message-error"
/** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */
interface FetchDecompressionError extends Error {
@@ -36,17 +38,12 @@ interface FetchDecompressionError extends Error {
export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached media from tool result:"
export { isMedia }
export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {})
export const AbortedError = namedSchemaError("MessageAbortedError", { message: Schema.String })
export const StructuredOutputError = namedSchemaError("StructuredOutputError", {
export const AbortedError = NamedError.create("MessageAbortedError", { message: Schema.String })
export const StructuredOutputError = NamedError.create("StructuredOutputError", {
message: Schema.String,
retries: NonNegativeInt,
})
export const AuthError = namedSchemaError("ProviderAuthError", {
providerID: Schema.String,
message: Schema.String,
})
export const APIError = namedSchemaError("APIError", {
export const APIError = NamedError.create("APIError", {
message: Schema.String,
statusCode: Schema.optional(NonNegativeInt),
isRetryable: Schema.Boolean,
@@ -55,7 +52,7 @@ export const APIError = namedSchemaError("APIError", {
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
})
export type APIError = Schema.Schema.Type<typeof APIError.Schema>
export const ContextOverflowError = namedSchemaError("ContextOverflowError", {
export const ContextOverflowError = NamedError.create("ContextOverflowError", {
message: Schema.String,
responseBody: Schema.optional(Schema.String),
})
@@ -381,11 +378,7 @@ export type Part =
| CompactionPart
const AssistantErrorSchema = Schema.Union([
AuthError.EffectSchema,
Schema.Struct({ name: Schema.Literal("UnknownError"), data: Schema.Struct({ message: Schema.String }) }).annotate({
identifier: "UnknownError",
}),
OutputLengthError.EffectSchema,
...MessageError.Shared,
AbortedError.EffectSchema,
StructuredOutputError.EffectSchema,
ContextOverflowError.EffectSchema,
+4 -28
View File
@@ -2,33 +2,9 @@ import { Schema } from "effect"
import { SessionID } from "./schema"
import { ModelID, ProviderID } from "../provider/schema"
import { NonNegativeInt } from "@opencode-ai/core/schema"
import { namedSchemaError } from "@/util/named-schema-error"
export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {})
export const AuthError = namedSchemaError("ProviderAuthError", {
providerID: Schema.String,
message: Schema.String,
})
const AuthErrorEffect = Schema.Struct({
name: Schema.Literal("ProviderAuthError"),
data: Schema.Struct({
providerID: Schema.String,
message: Schema.String,
}),
})
const OutputLengthErrorEffect = Schema.Struct({
name: Schema.Literal("MessageOutputLengthError"),
data: Schema.Struct({}),
})
const UnknownErrorEffect = Schema.Struct({
name: Schema.Literal("UnknownError"),
data: Schema.Struct({
message: Schema.String,
}),
})
import { MessageError } from "./message-error"
import { AuthError, OutputLengthError } from "./message-error"
export { AuthError, OutputLengthError } from "./message-error"
export const ToolCall = Schema.Struct({
state: Schema.Literal("call"),
@@ -124,7 +100,7 @@ export const Info = Schema.Struct({
created: NonNegativeInt,
completed: Schema.optional(NonNegativeInt),
}),
error: Schema.optional(Schema.Union([AuthErrorEffect, UnknownErrorEffect, OutputLengthErrorEffect])),
error: Schema.optional(MessageError.SharedSchema),
sessionID: SessionID,
tool: Schema.Record(
Schema.String,
+53 -1
View File
@@ -1,6 +1,8 @@
import { NotFoundError } from "@/storage/storage"
import { eq } from "drizzle-orm"
import { and } from "drizzle-orm"
import { sql } from "drizzle-orm"
import type { TxOrDb } from "@/storage/db"
import { SyncEvent } from "@/sync"
import * as Session from "./session"
import { MessageV2 } from "./message-v2"
@@ -19,6 +21,29 @@ function foreign(err: unknown) {
export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> | null } : T
type Usage = Pick<MessageV2.StepFinishPart, "cost" | "tokens">
function usage(part: MessageV2.Part | (typeof PartTable.$inferSelect)["data"]): Usage | undefined {
if (part.type !== "step-finish") return undefined
if (!("cost" in part) || !("tokens" in part)) return undefined
return { cost: part.cost, tokens: part.tokens }
}
function applyUsage(db: TxOrDb, sessionID: Session.Info["id"], value: Usage, sign = 1) {
db.update(SessionTable)
.set({
cost: sql`${SessionTable.cost} + ${value.cost * sign}`,
tokens_input: sql`${SessionTable.tokens_input} + ${value.tokens.input * sign}`,
tokens_output: sql`${SessionTable.tokens_output} + ${value.tokens.output * sign}`,
tokens_reasoning: sql`${SessionTable.tokens_reasoning} + ${value.tokens.reasoning * sign}`,
tokens_cache_read: sql`${SessionTable.tokens_cache_read} + ${value.tokens.cache.read * sign}`,
tokens_cache_write: sql`${SessionTable.tokens_cache_write} + ${value.tokens.cache.write * sign}`,
time_updated: sql`${SessionTable.time_updated}`,
})
.where(eq(SessionTable.id, sessionID))
.run()
}
function grab<T extends object, K1 extends keyof T, X>(
obj: T,
field1: K1,
@@ -54,6 +79,12 @@ export function toPartialRow(info: DeepPartial<Session.Info>) {
summary_deletions: grab(info, "summary", (v) => grab(v, "deletions")),
summary_files: grab(info, "summary", (v) => grab(v, "files")),
summary_diffs: grab(info, "summary", (v) => grab(v, "diffs")),
cost: grab(info, "cost"),
tokens_input: grab(info, "tokens", (v) => grab(v, "input")),
tokens_output: grab(info, "tokens", (v) => grab(v, "output")),
tokens_reasoning: grab(info, "tokens", (v) => grab(v, "reasoning")),
tokens_cache_read: grab(info, "tokens", (v) => grab(v, "cache", (cache) => grab(cache, "read"))),
tokens_cache_write: grab(info, "tokens", (v) => grab(v, "cache", (cache) => grab(cache, "write"))),
revert: grab(info, "revert"),
permission: grab(info, "permission"),
time_created: grab(info, "time", (v) => grab(v, "created")),
@@ -80,7 +111,7 @@ export default [
const info = data.info
const row = db
.update(SessionTable)
.set(toPartialRow(info as Session.Patch))
.set({ time_updated: sql`${SessionTable.time_updated}`, ...toPartialRow(info as Session.Patch) })
.where(eq(SessionTable.id, data.sessionID))
.returning()
.get()
@@ -112,12 +143,28 @@ export default [
}),
SyncEvent.project(MessageV2.Event.Removed, (db, data) => {
for (const row of db
.select()
.from(PartTable)
.where(and(eq(PartTable.message_id, data.messageID), eq(PartTable.session_id, data.sessionID)))
.all()) {
const previous = usage(row.data)
if (previous) applyUsage(db, data.sessionID, previous, -1)
}
db.delete(MessageTable)
.where(and(eq(MessageTable.id, data.messageID), eq(MessageTable.session_id, data.sessionID)))
.run()
}),
SyncEvent.project(MessageV2.Event.PartRemoved, (db, data) => {
const row = db
.select()
.from(PartTable)
.where(and(eq(PartTable.id, data.partID), eq(PartTable.session_id, data.sessionID)))
.get()
const previous = row && usage(row.data)
if (previous) applyUsage(db, data.sessionID, previous, -1)
db.delete(PartTable)
.where(and(eq(PartTable.id, data.partID), eq(PartTable.session_id, data.sessionID)))
.run()
@@ -125,6 +172,7 @@ export default [
SyncEvent.project(MessageV2.Event.PartUpdated, (db, data) => {
const { id, messageID, sessionID, ...rest } = data.part
const row = db.select().from(PartTable).where(eq(PartTable.id, id)).get()
try {
db.insert(PartTable)
@@ -137,6 +185,10 @@ export default [
})
.onConflictDoUpdate({ target: PartTable.id, set: { data: rest } })
.run()
const previous = row && usage(row.data)
const next = usage(data.part)
if (previous) applyUsage(db, row.session_id, previous, -1)
if (next) applyUsage(db, sessionID, next)
} catch (err) {
if (!foreign(err)) throw err
log.warn("ignored late part update", { partID: id, messageID, sessionID })
+5 -5
View File
@@ -1083,8 +1083,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
})
const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) {
const agentName = input.agent || (yield* agents.defaultAgent())
const ag = yield* agents.get(agentName)
const agentName = input.agent
const ag = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo()
if (!ag) {
const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
@@ -1875,7 +1875,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
throw error
}
const agentName = cmd.agent ?? input.agent ?? (yield* agents.defaultAgent())
const agentName = cmd.agent ?? input.agent
const raw = input.arguments.match(argsRegex) ?? []
const args = raw.map((arg) => arg.replace(quoteTrimRegex, ""))
@@ -1928,7 +1928,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
yield* getModel(taskModel.providerID, taskModel.modelID, input.sessionID)
const agent = yield* agents.get(agentName)
const agent = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo()
if (!agent) {
const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
@@ -1952,7 +1952,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
]
: [...templateParts, ...(input.parts ?? [])]
const userAgent = isSubtask ? (input.agent ?? (yield* agents.defaultAgent())) : agentName
const userAgent = isSubtask ? (input.agent ?? (yield* agents.defaultInfo()).name) : agent.name
const userModel = isSubtask
? input.model
? Provider.parseModel(input.model)
+3 -2
View File
@@ -2,6 +2,7 @@ import type { NamedError } from "@opencode-ai/core/util/error"
import { Cause, Clock, Duration, Effect, Schedule } from "effect"
import { MessageV2 } from "./message-v2"
import { iife } from "@/util/iife"
import { isRecord } from "@/util/record"
export type Err = ReturnType<NamedError["toObject"]>
@@ -121,7 +122,7 @@ export function retryable(error: Err, provider: string) {
}
// Check for rate limit patterns in plain text error messages
const msg = error.data?.message
const msg = isRecord(error.data) ? error.data.message : undefined
if (typeof msg === "string") {
const lower = msg.toLowerCase()
if (
@@ -133,7 +134,7 @@ export function retryable(error: Err, provider: string) {
}
}
const json = parseJSON(error.data?.message)
const json = parseJSON(msg)
if (!json || typeof json !== "object") return undefined
const code = typeof json.code === "string" ? json.code : ""
+8 -2
View File
@@ -1,4 +1,4 @@
import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core"
import { sqliteTable, text, integer, index, primaryKey, real } from "drizzle-orm/sqlite-core"
import { ProjectTable } from "../project/project.sql"
import type { MessageV2 } from "./message-v2"
import type { SessionMessage } from "../v2/session-message"
@@ -10,7 +10,7 @@ import type { WorkspaceID } from "../control-plane/schema"
import { Timestamps } from "../storage/schema.sql"
type PartData = Omit<MessageV2.Part, "id" | "sessionID" | "messageID">
type InfoData = Omit<MessageV2.Info, "id" | "sessionID">
type InfoData<T extends MessageV2.Info = MessageV2.Info> = T extends unknown ? Omit<T, "id" | "sessionID"> : never
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
export const SessionTable = sqliteTable(
@@ -33,6 +33,12 @@ export const SessionTable = sqliteTable(
summary_deletions: integer(),
summary_files: integer(),
summary_diffs: text({ mode: "json" }).$type<Snapshot.FileDiff[]>(),
cost: real().notNull().default(0),
tokens_input: integer().notNull().default(0),
tokens_output: integer().notNull().default(0),
tokens_reasoning: integer().notNull().default(0),
tokens_cache_read: integer().notNull().default(0),
tokens_cache_write: integer().notNull().default(0),
revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(),
permission: text({ mode: "json" }).$type<Permission.Ruleset>(),
agent: text(),
+34
View File
@@ -87,6 +87,16 @@ export function fromRow(row: SessionRow): Info {
: undefined,
version: row.version,
summary,
cost: row.cost,
tokens: {
input: row.tokens_input,
output: row.tokens_output,
reasoning: row.tokens_reasoning,
cache: {
read: row.tokens_cache_read,
write: row.tokens_cache_write,
},
},
share,
revert,
permission: row.permission ?? undefined,
@@ -117,6 +127,12 @@ export function toRow(info: Info) {
summary_deletions: info.summary?.deletions,
summary_files: info.summary?.files,
summary_diffs: info.summary?.diffs,
cost: info.cost ?? 0,
tokens_input: (info.tokens ?? EmptyTokens).input,
tokens_output: (info.tokens ?? EmptyTokens).output,
tokens_reasoning: (info.tokens ?? EmptyTokens).reasoning,
tokens_cache_read: (info.tokens ?? EmptyTokens).cache.read,
tokens_cache_write: (info.tokens ?? EmptyTokens).cache.write,
revert: info.revert ?? null,
permission: info.permission,
time_created: info.time.created,
@@ -147,6 +163,18 @@ const Summary = Schema.Struct({
diffs: optionalOmitUndefined(Schema.Array(Snapshot.FileDiff)),
})
const Tokens = Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
reasoning: Schema.Finite,
cache: Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
}),
})
const EmptyTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
const Share = Schema.Struct({
url: Schema.String,
})
@@ -184,6 +212,8 @@ export const Info = Schema.Struct({
path: optionalOmitUndefined(Schema.String),
parentID: optionalOmitUndefined(SessionID),
summary: optionalOmitUndefined(Summary),
cost: optionalOmitUndefined(Schema.Finite),
tokens: optionalOmitUndefined(Tokens),
share: optionalOmitUndefined(Share),
title: Schema.String,
agent: optionalOmitUndefined(Schema.String),
@@ -281,6 +311,8 @@ const UpdatedInfo = Schema.Struct({
path: Schema.optional(Schema.NullOr(Schema.String)),
parentID: Schema.optional(Schema.NullOr(SessionID)),
summary: Schema.optional(Schema.NullOr(Summary)),
cost: Schema.optional(Schema.Finite),
tokens: Schema.optional(Tokens),
share: Schema.optional(UpdatedShare),
title: Schema.optional(Schema.NullOr(Schema.String)),
agent: Schema.optional(Schema.NullOr(Schema.String)),
@@ -503,6 +535,8 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
agent: input.agent,
model: input.model,
permission: input.permission,
cost: 0,
tokens: EmptyTokens,
time: {
created: Date.now(),
updated: Date.now(),
+32 -23
View File
@@ -1,6 +1,5 @@
import path from "path"
import { pathToFileURL } from "url"
import z from "zod"
import { Effect, Layer, Context, Schema } from "effect"
import { NamedError } from "@opencode-ai/core/util/error"
import type { Agent } from "@/agent/agent"
@@ -16,6 +15,7 @@ import { Glob } from "@opencode-ai/core/util/glob"
import * as Log from "@opencode-ai/core/util/log"
import { Discovery } from "./discovery"
import CUSTOMIZE_OPENCODE_SKILL_BODY from "./prompt/customize-opencode.md" with { type: "text" }
import { isRecord } from "@/util/record"
const log = Log.create({ service: "skill" })
const CLAUDE_EXTERNAL_DIR = ".claude"
@@ -41,23 +41,33 @@ export const Info = Schema.Struct({
})
export type Info = Schema.Schema.Type<typeof Info>
export const InvalidError = NamedError.create(
"SkillInvalidError",
z.object({
path: z.string(),
message: z.string().optional(),
issues: z.custom<z.core.$ZodIssue[]>().optional(),
const Issue = Schema.StructWithRest(
Schema.Struct({
message: Schema.String,
path: Schema.Array(Schema.String),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
export const NameMismatchError = NamedError.create(
"SkillNameMismatchError",
z.object({
path: z.string(),
expected: z.string(),
actual: z.string(),
}),
)
function isSkillFrontmatter(data: unknown): data is { name: string; description?: string } {
return (
isRecord(data) &&
typeof data.name === "string" &&
(data.description === undefined || typeof data.description === "string")
)
}
export const InvalidError = NamedError.create("SkillInvalidError", {
path: Schema.String,
message: Schema.optional(Schema.String),
issues: Schema.optional(Schema.Array(Issue)),
})
export const NameMismatchError = NamedError.create("SkillNameMismatchError", {
path: Schema.String,
expected: Schema.String,
actual: Schema.String,
})
type State = {
skills: Record<string, Info>
@@ -101,21 +111,20 @@ const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.I
if (!md) return
const parsed = z.object({ name: z.string(), description: z.string().optional() }).safeParse(md.data)
if (!parsed.success) return
if (!isSkillFrontmatter(md.data)) return
if (state.skills[parsed.data.name]) {
if (state.skills[md.data.name]) {
log.warn("duplicate skill name", {
name: parsed.data.name,
existing: state.skills[parsed.data.name].location,
name: md.data.name,
existing: state.skills[md.data.name].location,
duplicate: match,
})
}
state.dirs.add(path.dirname(match))
state.skills[parsed.data.name] = {
name: parsed.data.name,
description: parsed.data.description,
state.skills[md.data.name] = {
name: md.data.name,
description: md.data.description,
location: match,
content: md.content,
}
@@ -335,9 +335,9 @@ rules last.
everything" and is rarely what the user wants.
Known permission keys: `read, edit, glob, grep, list, bash, task,
external_directory, todowrite, question, webfetch, websearch, codesearch,
repo_clone, repo_overview, lsp, doom_loop, skill`. Some of these (`todowrite,
question, webfetch, websearch, codesearch, doom_loop`) only accept a flat
external_directory, todowrite, question, webfetch, websearch, repo_clone,
repo_overview, lsp, doom_loop, skill`. Some of these (`todowrite,
question, webfetch, websearch, doom_loop`) only accept a flat
action, not a per-pattern object.
`external_directory` patterns are filesystem paths (use `~/`, absolute paths,
+4 -7
View File
@@ -7,7 +7,6 @@ import { lazy } from "../util/lazy"
import { Global } from "@opencode-ai/core/global"
import * as Log from "@opencode-ai/core/util/log"
import { NamedError } from "@opencode-ai/core/util/error"
import z from "zod"
import path from "path"
import { readFileSync, readdirSync, existsSync } from "fs"
import { Flag } from "@opencode-ai/core/flag/flag"
@@ -15,15 +14,13 @@ import { InstallationChannel } from "@opencode-ai/core/installation/version"
import { InstanceState } from "@/effect/instance-state"
import { iife } from "@/util/iife"
import { init } from "#db"
import { Schema } from "effect"
declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined
export const NotFoundError = NamedError.create(
"NotFoundError",
z.object({
message: z.string(),
}),
)
export const NotFoundError = NamedError.create("NotFoundError", {
message: Schema.String,
})
const log = Log.create({ service: "db" })
@@ -216,6 +216,12 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
summary_deletions: data.summary?.deletions ?? null,
summary_files: data.summary?.files ?? null,
summary_diffs: data.summary?.diffs ?? null,
cost: 0,
tokens_input: 0,
tokens_output: 0,
tokens_reasoning: 0,
tokens_cache_read: 0,
tokens_cache_write: 0,
revert: data.revert ?? null,
permission: data.permission ?? null,
time_created: data.time?.created ?? now,
+3 -7
View File
@@ -2,7 +2,6 @@ import * as Log from "@opencode-ai/core/util/log"
import path from "path"
import { Global } from "@opencode-ai/core/global"
import { NamedError } from "@opencode-ai/core/util/error"
import z from "zod"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect"
import { NonNegativeInt } from "@opencode-ai/core/schema"
@@ -16,12 +15,9 @@ type Migration = (
git: Git.Interface,
) => Effect.Effect<void, AppFileSystem.Error>
export const NotFoundError = NamedError.create(
"NotFoundError",
z.object({
message: z.string(),
}),
)
export const NotFoundError = NamedError.create("NotFoundError", {
message: Schema.String,
})
export type Error = AppFileSystem.Error | InstanceType<typeof NotFoundError>
-63
View File
@@ -1,63 +0,0 @@
import { Effect, Schema } from "effect"
import { HttpClient } from "effect/unstable/http"
import * as Tool from "./tool"
import * as McpWebSearch from "./mcp-websearch"
import DESCRIPTION from "./codesearch.txt"
export const Parameters = Schema.Struct({
query: Schema.String.annotate({
description:
"Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'",
}),
tokensNum: Schema.Number.check(Schema.isGreaterThanOrEqualTo(1000))
.check(Schema.isLessThanOrEqualTo(50000))
.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(5000)))
.annotate({
description:
"Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.",
}),
})
export const CodeSearchTool = Tool.define(
"codesearch",
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
return {
description: DESCRIPTION,
parameters: Parameters,
execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) =>
Effect.gen(function* () {
yield* ctx.ask({
permission: "codesearch",
patterns: [params.query],
always: ["*"],
metadata: {
query: params.query,
tokensNum: params.tokensNum,
},
})
const result = yield* McpWebSearch.call(
http,
McpWebSearch.EXA_URL,
"get_code_context_exa",
McpWebSearch.CodeArgs,
{
query: params.query,
tokensNum: params.tokensNum,
},
"30 seconds",
)
return {
output:
result ??
"No code snippets or documentation found. Please try a different query, be more specific about the library or programming concept, or check the spelling of framework names.",
title: `Code search: ${params.query}`,
metadata: {},
}
}).pipe(Effect.orDie),
}
}),
)
-12
View File
@@ -1,12 +0,0 @@
- Search and get relevant context for any programming task using Exa Code API
- Provides the highest quality and freshest context for libraries, SDKs, and APIs
- Use this tool for ANY question or task related to programming
- Returns comprehensive code examples, documentation, and API references
- Optimized for finding specific programming patterns and solutions
Usage notes:
- Adjustable token count (1000-50000) for focused or comprehensive results
- Default 5000 tokens provides balanced context for most queries
- Use lower values for specific questions, higher values for comprehensive documentation
- Supports queries about frameworks, libraries, APIs, and programming concepts
- Examples: 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware'
+11 -10
View File
@@ -54,19 +54,20 @@ export const GrepTool = Tool.define(
})
const ins = yield* InstanceState.context
const search = AppFileSystem.resolve(
path.isAbsolute(params.path ?? ins.directory)
? (params.path ?? ins.directory)
: path.join(ins.directory, params.path ?? "."),
)
yield* reference.ensure(search)
const requested = path.isAbsolute(params.path ?? ins.directory)
? (params.path ?? ins.directory)
: path.join(ins.directory, params.path ?? ".")
yield* reference.ensure(requested)
const requestedInfo = yield* fs.stat(requested).pipe(Effect.catch(() => Effect.succeed(undefined)))
yield* assertExternalDirectoryEffect(ctx, requested, {
bypass: yield* reference.contains(requested),
kind: requestedInfo?.type === "Directory" ? "directory" : "file",
})
const search = AppFileSystem.resolve(requested)
const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
const cwd = info?.type === "Directory" ? search : path.dirname(search)
const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)]
yield* assertExternalDirectoryEffect(ctx, search, {
bypass: yield* reference.contains(search),
kind: info?.type === "Directory" ? "directory" : "file",
})
const result = yield* rg.search({
cwd,
@@ -48,11 +48,6 @@ export const SearchArgs = Schema.Struct({
contextMaxCharacters: Schema.optional(Schema.Number),
})
export const CodeArgs = Schema.Struct({
query: Schema.String,
tokensNum: Schema.Number,
})
export const ParallelSearchArgs = Schema.Struct({
objective: Schema.String,
search_queries: Schema.Array(Schema.String),
+1 -4
View File
@@ -22,7 +22,6 @@ import { Plugin } from "../plugin"
import { Provider } from "@/provider/provider"
import { ProviderID, type ModelID } from "../provider/schema"
import { WebSearchTool } from "./websearch"
import { CodeSearchTool } from "./codesearch"
import { RepoCloneTool } from "./repo_clone"
import { RepoOverviewTool } from "./repo_overview"
import { Flag } from "@opencode-ai/core/flag/flag"
@@ -120,7 +119,6 @@ export const layer: Layer.Layer<
const plan = yield* PlanExitTool
const webfetch = yield* WebFetchTool
const websearch = yield* WebSearchTool
const codesearch = yield* CodeSearchTool
const repoClone = yield* RepoCloneTool
const repoOverview = yield* RepoOverviewTool
const shell = yield* ShellTool
@@ -224,7 +222,6 @@ export const layer: Layer.Layer<
fetch: Tool.init(webfetch),
todo: Tool.init(todo),
search: Tool.init(websearch),
code: Tool.init(codesearch),
repo_clone: Tool.init(repoClone),
repo_overview: Tool.init(repoOverview),
skill: Tool.init(skilltool),
@@ -249,7 +246,7 @@ export const layer: Layer.Layer<
tool.fetch,
tool.todo,
tool.search,
...(Flag.OPENCODE_EXPERIMENTAL_SCOUT ? [tool.code, tool.repo_clone, tool.repo_overview] : []),
...(Flag.OPENCODE_EXPERIMENTAL_SCOUT ? [tool.repo_clone, tool.repo_overview] : []),
tool.skill,
tool.patch,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []),
@@ -1,51 +0,0 @@
import { Schema } from "effect"
/**
* Create a Schema-backed NamedError-shaped class.
*
* Drop-in replacement for `NamedError.create(tag, zodShape)` but backed by
* `Schema.Struct` under the hood. The wire shape emitted by the derived
* `.Schema` is still `{ name: tag, data: {...fields} }` so the generated
* OpenAPI/SDK output is byte-identical to the original NamedError schema.
*
* Preserves the existing surface:
* - static `Schema` (Effect schema of the wire shape)
* - static `isInstance(x)`
* - instance `toObject()` returning `{ name, data }`
* - `new X({ ...data }, { cause })`
*/
export function namedSchemaError<Tag extends string, Fields extends Schema.Struct.Fields>(tag: Tag, fields: Fields) {
const dataSchema = Schema.Struct(fields)
// Wire shape matches the original NamedError output so the SDK stays stable.
const effectSchema = Schema.Struct({
name: Schema.Literal(tag),
data: dataSchema,
}).annotate({ identifier: tag })
type Data = Schema.Schema.Type<typeof dataSchema>
class NamedSchemaError extends Error {
static readonly Schema = effectSchema
static readonly EffectSchema = effectSchema
static readonly tag = tag
public static isInstance(input: unknown): input is NamedSchemaError {
return typeof input === "object" && input !== null && "name" in input && (input as { name: unknown }).name === tag
}
public override readonly name: Tag = tag
public readonly data: Data
constructor(data: Data, options?: ErrorOptions) {
super(tag, options)
this.data = data
}
toObject(): { name: Tag; data: Data } {
return { name: tag, data: this.data }
}
}
Object.defineProperty(NamedSchemaError, "name", { value: tag })
return NamedSchemaError
}
+20
View File
@@ -29,6 +29,16 @@ export class Info extends Schema.Class<Info>("Session.Info")({
path: optionalOmitUndefined(Schema.String),
agent: optionalOmitUndefined(Schema.String),
model: Modelv2.Ref.pipe(optionalOmitUndefined),
cost: Schema.Finite,
tokens: Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
reasoning: Schema.Finite,
cache: Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
}),
}),
time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis,
updated: V2Schema.DateTimeUtcFromMillis,
@@ -136,6 +146,16 @@ export const layer = Layer.effect(
variant: Modelv2.VariantID.make(row.model.variant ?? "default"),
}
: undefined,
cost: row.cost,
tokens: {
input: row.tokens_input,
output: row.tokens_output,
reasoning: row.tokens_reasoning,
cache: {
read: row.tokens_cache_read,
write: row.tokens_cache_write,
},
},
time: {
created: DateTime.makeUnsafe(row.time_created),
updated: DateTime.makeUnsafe(row.time_updated),
+21 -43
View File
@@ -1,4 +1,3 @@
import z from "zod"
import { NamedError } from "@opencode-ai/core/util/error"
import { Global } from "@opencode-ai/core/global"
import { InstanceLayer } from "@/project/instance-layer"
@@ -65,54 +64,33 @@ export const ResetInput = Schema.Struct({
}).annotate({ identifier: "WorktreeResetInput" })
export type ResetInput = Schema.Schema.Type<typeof ResetInput>
export const NotGitError = NamedError.create(
"WorktreeNotGitError",
z.object({
message: z.string(),
}),
)
export const NotGitError = NamedError.create("WorktreeNotGitError", {
message: Schema.String,
})
export const NameGenerationFailedError = NamedError.create(
"WorktreeNameGenerationFailedError",
z.object({
message: z.string(),
}),
)
export const NameGenerationFailedError = NamedError.create("WorktreeNameGenerationFailedError", {
message: Schema.String,
})
export const CreateFailedError = NamedError.create(
"WorktreeCreateFailedError",
z.object({
message: z.string(),
}),
)
export const CreateFailedError = NamedError.create("WorktreeCreateFailedError", {
message: Schema.String,
})
export const StartCommandFailedError = NamedError.create(
"WorktreeStartCommandFailedError",
z.object({
message: z.string(),
}),
)
export const StartCommandFailedError = NamedError.create("WorktreeStartCommandFailedError", {
message: Schema.String,
})
export const RemoveFailedError = NamedError.create(
"WorktreeRemoveFailedError",
z.object({
message: z.string(),
}),
)
export const RemoveFailedError = NamedError.create("WorktreeRemoveFailedError", {
message: Schema.String,
})
export const ResetFailedError = NamedError.create(
"WorktreeResetFailedError",
z.object({
message: z.string(),
}),
)
export const ResetFailedError = NamedError.create("WorktreeResetFailedError", {
message: Schema.String,
})
export const ListFailedError = NamedError.create(
"WorktreeListFailedError",
z.object({
message: z.string(),
}),
)
export const ListFailedError = NamedError.create("WorktreeListFailedError", {
message: Schema.String,
})
function slugify(input: string) {
return input
File diff suppressed because it is too large Load Diff
@@ -18,110 +18,85 @@
* permissions are passed through, and Plan Mode's restrictions live on the
* agent, not the session.
*/
import { test, expect, afterEach } from "bun:test"
import { expect } from "bun:test"
import { Effect } from "effect"
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
import { WithInstance } from "../../src/project/with-instance"
import { Agent } from "../../src/agent/agent"
import { deriveSubagentSessionPermission } from "../../src/agent/subagent-permissions"
import { Permission } from "../../src/permission"
import { testEffect } from "../lib/effect"
afterEach(async () => {
await disposeAllInstances()
})
function load<A>(dir: string, fn: (svc: Agent.Interface) => Effect.Effect<A>) {
return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer)))
}
const it = testEffect(Agent.defaultLayer)
// `deriveSubagentSessionPermission` is imported from production. The test
// exercises the actual helper that task.ts uses to build the subagent's
// session permission, so any regression in that helper trips this test.
test("[#26514] subagent spawned from plan mode inherits read-only restriction (edit denied)", async () => {
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const planAgent = await load(tmp.path, (svc) => svc.get("plan"))
const generalAgent = await load(tmp.path, (svc) => svc.get("general"))
it.instance("[#26514] subagent spawned from plan mode inherits read-only restriction (edit denied)", () =>
Effect.gen(function* () {
const planAgent = yield* Agent.Service.use((svc) => svc.get("plan"))
const generalAgent = yield* Agent.Service.use((svc) => svc.get("general"))
expect(planAgent).toBeDefined()
expect(generalAgent).toBeDefined()
// Sanity: the plan agent itself blocks edit. (Note: `write` and
// `apply_patch` route through the `edit` permission at the runtime
// tool layer — see Permission.disabled / EDIT_TOOLS.)
expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny")
expect(planAgent).toBeDefined()
expect(generalAgent).toBeDefined()
// Sanity: the plan agent itself blocks edit. (Note: `write` and
// `apply_patch` route through the `edit` permission at the runtime
// tool layer — see Permission.disabled / EDIT_TOOLS.)
expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny")
// Simulate the plan-mode parent session: in real flow the plan
// session's `permission` field is empty (Plan Mode lives on the agent
// ruleset, not the session). So we pass [] through as the parent
// session permission, exactly like the actual code path.
const parentSessionPermission: Permission.Ruleset = []
// Simulate the plan-mode parent session: in real flow the plan
// session's `permission` field is empty (Plan Mode lives on the agent
// ruleset, not the session). So we pass [] through as the parent
// session permission, exactly like the actual code path.
const parentSessionPermission: Permission.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission,
parentAgent: planAgent,
subagent: generalAgent!,
})
const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission,
parentAgent: planAgent,
subagent: generalAgent!,
})
// Mirror the runtime evaluation in session/prompt.ts (~line 410, 639):
// ruleset: Permission.merge(agent.permission, session.permission ?? [])
const effective = Permission.merge(generalAgent!.permission, subagentSessionPermission)
// Mirror the runtime evaluation in session/prompt.ts (~line 410, 639):
// ruleset: Permission.merge(agent.permission, session.permission ?? [])
const effective = Permission.merge(generalAgent!.permission, subagentSessionPermission)
expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny")
expect(Permission.evaluate("edit", "/another/path/index.tsx", effective).action).toBe("deny")
},
})
})
expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny")
expect(Permission.evaluate("edit", "/another/path/index.tsx", effective).action).toBe("deny")
}),
)
test("[#26514] explore subagent launched from plan mode also stays read-only", async () => {
it.instance("[#26514] explore subagent launched from plan mode also stays read-only", () =>
// Sibling check: even though `explore` is intrinsically read-only, the
// bug surface is the same. Including this case to document that the fix
// should propagate the parent **agent** permissions, not just deny edit
// when the subagent happens to already deny it.
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const planAgent = await load(tmp.path, (svc) => svc.get("plan"))
const explore = await load(tmp.path, (svc) => svc.get("explore"))
expect(planAgent).toBeDefined()
expect(explore).toBeDefined()
Effect.gen(function* () {
const planAgent = yield* Agent.Service.use((svc) => svc.get("plan"))
const explore = yield* Agent.Service.use((svc) => svc.get("explore"))
expect(planAgent).toBeDefined()
expect(explore).toBeDefined()
const parentSessionPermission: Permission.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission,
parentAgent: planAgent,
subagent: explore!,
})
const effective = Permission.merge(explore!.permission, subagentSessionPermission)
const parentSessionPermission: Permission.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission,
parentAgent: planAgent,
subagent: explore!,
})
const effective = Permission.merge(explore!.permission, subagentSessionPermission)
// Already deny — sanity check.
expect(Permission.evaluate("edit", "/x.ts", effective).action).toBe("deny")
},
})
})
// Already deny — sanity check.
expect(Permission.evaluate("edit", "/x.ts", effective).action).toBe("deny")
}),
)
test("[#26514] custom user subagent launched from plan mode bypasses Plan Mode read-only", async () => {
it.instance(
"[#26514] custom user subagent launched from plan mode bypasses Plan Mode read-only",
// The most damaging case: a user-defined subagent with default
// permissions (allow-by-default, like `general`). The subagent must NOT
// be able to edit when the parent agent is `plan`.
await using tmp = await tmpdir({
config: {
agent: {
my_subagent: {
description: "A user-defined subagent",
mode: "subagent",
},
},
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const planAgent = await load(tmp.path, (svc) => svc.get("plan"))
const my = await load(tmp.path, (svc) => svc.get("my_subagent"))
() =>
Effect.gen(function* () {
const planAgent = yield* Agent.Service.use((svc) => svc.get("plan"))
const my = yield* Agent.Service.use((svc) => svc.get("my_subagent"))
expect(planAgent).toBeDefined()
expect(my).toBeDefined()
@@ -136,6 +111,15 @@ test("[#26514] custom user subagent launched from plan mode bypasses Plan Mode r
// BUG: on origin/dev edit resolves to "allow" because the plan
// agent's `edit: deny *` rule never reaches the subagent.
expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny")
}),
{
config: {
agent: {
my_subagent: {
description: "A user-defined subagent",
mode: "subagent",
},
},
},
})
})
},
)

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