Compare commits

..

6 Commits

Author SHA1 Message Date
Dax Raad 8921a7a26b core: split v2 design notes into topic docs for easier review 2026-04-05 20:09:45 -04:00
Dax Raad 03b7c2d3fb core: add message shape design options for v2 prompt hooks
Document two approaches for handling synthetic messages in prompt hooks:
- Option 1: Separate PromptMessage type for lightweight prompt surgery
- Option 2: PromptEditor API with append/prepend/insert mutators

This enables plugin developers to inject instructions or context into prompts without manually fabricating message IDs and timestamps. The design supports resumable sessions while keeping the API simple for common prompt manipulation use cases.
2026-04-05 19:12:27 -04:00
Dax Raad 163a9e85b5 more explorations 2026-04-05 17:56:01 -04:00
Dax Raad 8bf3107879 core: sort skills alphabetically in tool descriptions
Skills are now displayed in alphabetical order when listing available
skills, making it easier for users to locate specific skills in the
output.
2026-04-04 18:45:21 -04:00
Dax Raad cd3da22afd core: refactor tool system to remove agent context from initialization
Simplify tool initialization by removing unnecessary agent context parameter from
tool.init() calls. This makes tool behavior more predictable and consistent
regardless of which agent is using them.

Replace hardcoded named tool references (registry.named.task, registry.named.read)
with a cleaner fromID() lookup method that works for any tool.

Update TaskTool to display all available subagents without permission-based
filtering, making it easier to discover and use subagents from any context.
2026-04-04 18:30:35 -04:00
Dax Raad 389d9deee9 sync 2026-04-04 16:18:01 -04:00
79 changed files with 1338 additions and 2305 deletions
+19 -18
View File
@@ -9,6 +9,7 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"heap-snapshot-toolkit": "1.1.3",
"typescript": "catalog:",
},
"devDependencies": {
@@ -26,7 +27,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -80,7 +81,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -114,7 +115,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -141,7 +142,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -165,7 +166,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -189,7 +190,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -222,7 +223,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -254,7 +255,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -283,7 +284,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -299,7 +300,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.3.15",
"version": "1.3.13",
"bin": {
"opencode": "./bin/opencode",
},
@@ -371,7 +372,6 @@
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"minimatch": "10.0.3",
"npm-package-arg": "13.0.2",
"open": "10.1.2",
"opencode-gitlab-auth": "2.0.1",
"opencode-poe-auth": "0.0.1",
@@ -413,7 +413,6 @@
"@types/bun": "catalog:",
"@types/cross-spawn": "catalog:",
"@types/mime-types": "3.0.1",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "^7.5.8",
"@types/turndown": "5.0.5",
@@ -430,7 +429,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -464,7 +463,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -479,7 +478,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -514,7 +513,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -562,7 +561,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"zod": "catalog:",
},
@@ -573,7 +572,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -3234,6 +3233,8 @@
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
"heap-snapshot-toolkit": ["heap-snapshot-toolkit@1.1.3", "", {}, "sha512-joThu2rEsDu8/l4arupRDI1qP4CZXNG+J6Wr348vnbLGSiBkwRdqZ6aOHl5BzEiC+Dc8OTbMlmWjD0lbXD5K2Q=="],
"hey-listen": ["hey-listen@1.0.8", "", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="],
"hono": ["hono@4.10.7", "", {}, "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-LRhPPrOKCGUSCEWTpAxPdWKTKVNkg82WrvD25cP3jts=",
"aarch64-linux": "sha256-sbNxkil47n+B7v6ds5EYFybLytXUyRlu0Cpka0ZmDx4=",
"aarch64-darwin": "sha256-5+99gtpIHGygMW3VBAexNhmaORgI8LCxPk/Gf1fW/ds=",
"x86_64-darwin": "sha256-LqnvZGGnQaRxIoowOr5gf6lFgDhbgQhVPiAcRTtU6fE="
"x86_64-linux": "sha256-0jwPCu2Lod433GPQLHN8eEkhfpPviDFfkFJmuvkRdlE=",
"aarch64-linux": "sha256-Qi0IkGkaIBKZsPLTO8kaTbCVL0cEfVOm/Y/6VUVI9TY=",
"aarch64-darwin": "sha256-1eZBBLgYVkjg5RYN/etR1Mb5UjU3VelElBB5ug5hQdc=",
"x86_64-darwin": "sha256-jdXgA+kZb/foFHR40UiPif6rsA2GDVCCVHnJR3jBUGI="
}
}
+1
View File
@@ -90,6 +90,7 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"heap-snapshot-toolkit": "1.1.3",
"typescript": "catalog:"
},
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.3.15",
"version": "1.3.13",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.3.15",
"version": "1.3.13",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.3.15",
"version": "1.3.13",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.3.15",
"version": "1.3.13",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.3.15",
"version": "1.3.13",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.3.15",
"version": "1.3.13",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.3.15",
"version": "1.3.13",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.3.15"
version = "1.3.13"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.3.15",
"version": "1.3.13",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -3
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.3.15",
"version": "1.3.13",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -54,7 +54,6 @@
"@types/bun": "catalog:",
"@types/cross-spawn": "catalog:",
"@types/mime-types": "3.0.1",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "^7.5.8",
"@types/turndown": "5.0.5",
@@ -136,7 +135,6 @@
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"minimatch": "10.0.3",
"npm-package-arg": "13.0.2",
"open": "10.1.2",
"opencode-gitlab-auth": "2.0.1",
"opencode-poe-auth": "0.0.1",
-1
View File
@@ -209,7 +209,6 @@ for (const item of targets) {
conditions: ["browser"],
tsconfig: "./tsconfig.json",
plugins: [plugin],
external: ["node-gyp"],
compile: {
autoloadBunfig: false,
autoloadDotenv: false,
+1 -17
View File
@@ -235,27 +235,11 @@ Once individual tools are effectified, change `Tool.Info` (`tool/tool.ts`) so `i
2. Update `Tool.define()` factory to work with Effects
3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing
### Tool migration details
Until the tool interface itself returns `Effect`, use this transitional pattern for migrated tools:
- `Tool.defineEffect(...)` should `yield*` the services the tool depends on and close over them in the returned tool definition.
- Keep the bridge at the Promise boundary only. Prefer a single `Effect.runPromise(...)` in the temporary `async execute(...)` implementation, and move the inner logic into `Effect.fn(...)` helpers instead of scattering `runPromise` islands through the tool body.
- If a tool starts requiring new services, wire them into `ToolRegistry.defaultLayer` so production callers resolve the same dependencies as tests.
Tool tests should use the existing Effect helpers in `packages/opencode/test/lib/effect.ts`:
- Use `testEffect(...)` / `it.live(...)` instead of creating fake local wrappers around effectful tools.
- Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* Effect.promise(() => info.init())`.
- Run tests inside a real instance with `provideTmpdirInstance(...)` or `provideInstance(tmpdirScoped(...))` so instance-scoped services resolve exactly as they do in production.
This keeps migrated tool tests aligned with the production service graph today, and makes the eventual `Tool.Info``Effect` cleanup mostly mechanical later.
Individual tools, ordered by value:
- [ ] `apply_patch.ts` — HIGH: multi-step orchestration, error accumulation, Bus events
- [ ] `bash.ts` — HIGH: shell orchestration, quoting, timeout handling, output capture
- [x] `read.ts` — HIGH: streaming I/O, readline, binary detection → FileSystem + Stream
- [ ] `read.ts` — HIGH: streaming I/O, readline, binary detection → FileSystem + Stream
- [ ] `edit.ts` — HIGH: multi-step diff/format/publish pipeline, FileWatcher lock
- [ ] `grep.ts` — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handling
- [ ] `write.ts` — MEDIUM: permission checks, diagnostics polling, Bus events
@@ -1,8 +1,4 @@
# 2.0
What we would change if we could
## Keybindings vs. Keymappings
# Keybindings vs. Keymappings
Make it `keymappings`, closer to neovim. Can be layered like `<leader>abc`. Commands don't define their binding, but have an id that a key can be mapped to like
+136
View File
@@ -0,0 +1,136 @@
# Message Shape
Problem:
- stored messages need enough data to replay and resume a session later
- prompt hooks often just want to append a synthetic user/assistant message
- today that means faking ids, timestamps, and request metadata
## Option 1: Two Message Shapes
Keep `User` / `Assistant` for stored history, but clean them up.
```ts
type User = {
role: "user"
time: { created: number }
request: {
agent: string
model: ModelRef
variant?: string
format?: OutputFormat
system?: string
tools?: Record<string, boolean>
}
}
type Assistant = {
role: "assistant"
run: { agent: string; model: ModelRef; path: { cwd: string; root: string } }
usage: { cost: number; tokens: Tokens }
result: { finish?: string; error?: Error; structured?: unknown; kind: "reply" | "summary" }
}
```
Add a separate transient `PromptMessage` for prompt surgery.
```ts
type PromptMessage = {
role: "user" | "assistant"
parts: PromptPart[]
}
```
Plugin hook example:
```ts
prompt.push({
role: "user",
parts: [{ type: "text", text: "Summarize the tool output above and continue." }],
})
```
Tradeoff: prompt hooks get easy lightweight messages, but there are now two message shapes.
## Option 2: Prompt Mutators
Keep `User` / `Assistant` as the stored history model.
Prompt hooks do not build messages directly. The runtime gives them prompt mutators.
```ts
type PromptEditor = {
append(input: { role: "user" | "assistant"; parts: PromptPart[] }): void
prepend(input: { role: "user" | "assistant"; parts: PromptPart[] }): void
appendTo(target: "last-user" | "last-assistant", parts: PromptPart[]): void
insertAfter(messageID: string, input: { role: "user" | "assistant"; parts: PromptPart[] }): void
insertBefore(messageID: string, input: { role: "user" | "assistant"; parts: PromptPart[] }): void
}
```
Plugin hook examples:
```ts
prompt.append({
role: "user",
parts: [{ type: "text", text: "Summarize the tool output above and continue." }],
})
```
```ts
prompt.appendTo("last-user", [{ type: "text", text: BUILD_SWITCH }])
```
Tradeoff: avoids a second full message type and avoids fake ids/timestamps, but moves more magic into the hook API.
## Option 3: Separate Turn State
Move execution settings out of `User` and into a separate turn/request object.
```ts
type Turn = {
id: string
request: {
agent: string
model: ModelRef
variant?: string
format?: OutputFormat
system?: string
tools?: Record<string, boolean>
}
}
type User = {
role: "user"
turnID: string
time: { created: number }
}
type Assistant = {
role: "assistant"
turnID: string
usage: { cost: number; tokens: Tokens }
result: { finish?: string; error?: Error; structured?: unknown; kind: "reply" | "summary" }
}
```
Examples:
```ts
const turn = {
request: {
agent: "build",
model: { providerID: "openai", modelID: "gpt-5" },
},
}
```
```ts
const msg = {
role: "user",
turnID: turn.id,
parts: [{ type: "text", text: "Summarize the tool output above and continue." }],
}
```
Tradeoff: stored messages get much smaller and cleaner, but replay now has to join messages with turn state and prompt hooks still need a way to pick which turn they belong to.
+8 -47
View File
@@ -52,11 +52,6 @@ export type AccountOrgs = {
orgs: readonly Org[]
}
export type ActiveOrg = {
account: Info
org: Org
}
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
config: Schema.Record(Schema.String, Schema.Json),
}) {}
@@ -142,7 +137,6 @@ const mapAccountServiceError =
export namespace Account {
export interface Interface {
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
readonly activeOrg: () => Effect.Effect<Option.Option<ActiveOrg>, AccountError>
readonly list: () => Effect.Effect<Info[], AccountError>
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
@@ -285,31 +279,19 @@ export namespace Account {
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
)
const activeOrg = Effect.fn("Account.activeOrg")(function* () {
const activeAccount = yield* repo.active()
if (Option.isNone(activeAccount)) return Option.none<ActiveOrg>()
const account = activeAccount.value
if (!account.active_org_id) return Option.none<ActiveOrg>()
const accountOrgs = yield* orgs(account.id)
const org = accountOrgs.find((item) => item.id === account.active_org_id)
if (!org) return Option.none<ActiveOrg>()
return Option.some({ account, org })
})
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
const accounts = yield* repo.list()
return yield* Effect.forEach(
const [errors, results] = yield* Effect.partition(
accounts,
(account) =>
orgs(account.id).pipe(
Effect.catch(() => Effect.succeed([] as readonly Org[])),
Effect.map((orgs) => ({ account, orgs })),
),
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
{ concurrency: 3 },
)
for (const error of errors) {
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
Effect.annotateLogs({ error: String(error) }),
)
}
return results
})
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
@@ -414,7 +396,6 @@ export namespace Account {
return Service.of({
active: repo.active,
activeOrg,
list: repo.list,
orgsByAccount,
remove: repo.remove,
@@ -436,26 +417,6 @@ export namespace Account {
return Option.getOrUndefined(await runPromise((service) => service.active()))
}
export async function list(): Promise<Info[]> {
return runPromise((service) => service.list())
}
export async function activeOrg(): Promise<ActiveOrg | undefined> {
return Option.getOrUndefined(await runPromise((service) => service.activeOrg()))
}
export async function orgsByAccount(): Promise<readonly AccountOrgs[]> {
return runPromise((service) => service.orgsByAccount())
}
export async function orgs(accountID: AccountID): Promise<readonly Org[]> {
return runPromise((service) => service.orgs(accountID))
}
export async function switchOrg(accountID: AccountID, orgID: OrgID) {
return runPromise((service) => service.use(accountID, Option.some(orgID)))
}
export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
const t = await runPromise((service) => service.token(accountID))
return Option.getOrUndefined(t)
+1 -1
View File
@@ -71,7 +71,7 @@ export const AgentCommand = cmd({
async function getAvailableTools(agent: Agent.Info) {
const model = agent.model ?? (await Provider.defaultModel())
return ToolRegistry.tools(model, agent)
return ToolRegistry.tools(model)
}
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
+1 -21
View File
@@ -36,7 +36,6 @@ import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list"
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
import { DialogConsoleOrg } from "@tui/component/dialog-console-org"
import { KeybindProvider, useKeybind } from "@tui/context/keybind"
import { ThemeProvider, useTheme } from "@tui/context/theme"
import { Home } from "@tui/routes/home"
@@ -125,16 +124,14 @@ import type { EventSource } from "./context/sdk"
import { DialogVariant } from "./component/dialog-variant"
function rendererConfig(_config: TuiConfig.Info): CliRendererConfig {
const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true)
return {
externalOutputMode: "passthrough",
targetFps: 60,
gatherStats: false,
exitOnCtrlC: false,
useKittyKeyboard: { events: process.platform === "win32" },
autoFocus: false,
openConsoleOnError: false,
useMouse: mouseEnabled,
consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
onCopySelection: (text) => {
@@ -632,23 +629,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
},
category: "Provider",
},
...(sync.data.console_state.switchableOrgCount > 1
? [
{
title: "Switch org",
value: "console.org.switch",
suggested: Boolean(sync.data.console_state.activeOrgName),
slash: {
name: "org",
aliases: ["orgs", "switch-org"],
},
onSelect: () => {
dialog.replace(() => <DialogConsoleOrg />)
},
category: "Provider",
},
]
: []),
{
title: "View status",
keybind: "status_view",
@@ -1,103 +0,0 @@
import { createResource, createMemo } from "solid-js"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useSDK } from "@tui/context/sdk"
import { useDialog } from "@tui/ui/dialog"
import { useToast } from "@tui/ui/toast"
import { useTheme } from "@tui/context/theme"
import type { ExperimentalConsoleListOrgsResponse } from "@opencode-ai/sdk/v2"
type OrgOption = ExperimentalConsoleListOrgsResponse["orgs"][number]
const accountHost = (url: string) => {
try {
return new URL(url).host
} catch {
return url
}
}
const accountLabel = (item: Pick<OrgOption, "accountEmail" | "accountUrl">) =>
`${item.accountEmail} ${accountHost(item.accountUrl)}`
export function DialogConsoleOrg() {
const sdk = useSDK()
const dialog = useDialog()
const toast = useToast()
const { theme } = useTheme()
const [orgs] = createResource(async () => {
const result = await sdk.client.experimental.console.listOrgs({}, { throwOnError: true })
return result.data?.orgs ?? []
})
const current = createMemo(() => orgs()?.find((item) => item.active))
const options = createMemo(() => {
const listed = orgs()
if (listed === undefined) {
return [
{
title: "Loading orgs...",
value: "loading",
onSelect: () => {},
},
]
}
if (listed.length === 0) {
return [
{
title: "No orgs found",
value: "empty",
onSelect: () => {},
},
]
}
return listed
.toSorted((a, b) => {
const activeAccountA = a.active ? 0 : 1
const activeAccountB = b.active ? 0 : 1
if (activeAccountA !== activeAccountB) return activeAccountA - activeAccountB
const accountCompare = accountLabel(a).localeCompare(accountLabel(b))
if (accountCompare !== 0) return accountCompare
return a.orgName.localeCompare(b.orgName)
})
.map((item) => ({
title: item.orgName,
value: item,
category: accountLabel(item),
categoryView: (
<box flexDirection="row" gap={2}>
<text fg={theme.accent}>{item.accountEmail}</text>
<text fg={theme.textMuted}>{accountHost(item.accountUrl)}</text>
</box>
),
onSelect: async () => {
if (item.active) {
dialog.clear()
return
}
await sdk.client.experimental.console.switchOrg(
{
accountID: item.accountID,
orgID: item.orgID,
},
{ throwOnError: true },
)
await sdk.client.instance.dispose()
toast.show({
message: `Switched to ${item.orgName}`,
variant: "info",
})
dialog.clear()
},
}))
})
return <DialogSelect<string | OrgOption> title="Switch org" options={options()} current={current()} />
}
@@ -8,7 +8,6 @@ import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { DialogVariant } from "./dialog-variant"
import { useKeybind } from "../context/keybind"
import * as fuzzysort from "fuzzysort"
import { consoleManagedProviderLabel } from "@tui/util/provider-origin"
export function useConnected() {
const sync = useSync()
@@ -47,11 +46,7 @@ export function DialogModel(props: { providerID?: string }) {
key: item,
value: { providerID: provider.id, modelID: model.id },
title: model.name ?? item.modelID,
description: consoleManagedProviderLabel(
sync.data.console_state.consoleManagedProviders,
provider.id,
provider.name,
),
description: provider.name,
category,
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
@@ -89,9 +84,7 @@ export function DialogModel(props: { providerID?: string }) {
description: favorites.some((item) => item.providerID === provider.id && item.modelID === model)
? "(Favorite)"
: undefined,
category: connected()
? consoleManagedProviderLabel(sync.data.console_state.consoleManagedProviders, provider.id, provider.name)
: undefined,
category: connected() ? provider.name : undefined,
disabled: provider.id === "opencode" && model.includes("-nano"),
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect() {
@@ -139,11 +132,7 @@ export function DialogModel(props: { providerID?: string }) {
props.providerID ? sync.data.provider.find((x) => x.id === props.providerID) : null,
)
const title = createMemo(() => {
const value = provider()
if (!value) return "Select model"
return consoleManagedProviderLabel(sync.data.console_state.consoleManagedProviders, value.id, value.name)
})
const title = createMemo(() => provider()?.name ?? "Select model")
function onSelect(providerID: string, modelID: string) {
local.model.set({ providerID, modelID }, { recent: true })
@@ -13,7 +13,6 @@ import { DialogModel } from "./dialog-model"
import { useKeyboard } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
import { useToast } from "../ui/toast"
import { CONSOLE_MANAGED_ICON, isConsoleManagedProvider } from "@tui/util/provider-origin"
const PROVIDER_PRIORITY: Record<string, number> = {
opencode: 0,
@@ -29,111 +28,87 @@ export function createDialogProviderOptions() {
const dialog = useDialog()
const sdk = useSDK()
const toast = useToast()
const { theme } = useTheme()
const options = createMemo(() => {
return pipe(
sync.data.provider_next.all,
sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99),
map((provider) => {
const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, provider.id)
const connected = sync.data.provider_next.connected.includes(provider.id)
return {
title: provider.name,
value: provider.id,
description: {
opencode: "(Recommended)",
anthropic: "(API key)",
openai: "(ChatGPT Plus/Pro or API key)",
"opencode-go": "Low cost subscription for everyone",
}[provider.id],
footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined,
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
gutter: consoleManaged ? (
<text fg={theme.textMuted}>{CONSOLE_MANAGED_ICON}</text>
) : connected ? (
<text fg={theme.success}></text>
) : undefined,
async onSelect() {
if (consoleManaged) return
const methods = sync.data.provider_auth[provider.id] ?? [
{
type: "api",
label: "API key",
},
]
let index: number | null = 0
if (methods.length > 1) {
index = await new Promise<number | null>((resolve) => {
dialog.replace(
() => (
<DialogSelect
title="Select auth method"
options={methods.map((x, index) => ({
title: x.label,
value: index,
}))}
onSelect={(option) => resolve(option.value)}
/>
),
() => resolve(null),
)
})
}
if (index == null) return
const method = methods[index]
if (method.type === "oauth") {
let inputs: Record<string, string> | undefined
if (method.prompts?.length) {
const value = await PromptsMethod({
dialog,
prompts: method.prompts,
})
if (!value) return
inputs = value
}
const result = await sdk.client.provider.oauth.authorize({
providerID: provider.id,
method: index,
inputs,
})
if (result.error) {
toast.show({
variant: "error",
message: JSON.stringify(result.error),
})
dialog.clear()
return
}
if (result.data?.method === "code") {
dialog.replace(() => (
<CodeMethod
providerID={provider.id}
title={method.label}
index={index}
authorization={result.data!}
map((provider) => ({
title: provider.name,
value: provider.id,
description: {
opencode: "(Recommended)",
anthropic: "(API key)",
openai: "(ChatGPT Plus/Pro or API key)",
"opencode-go": "Low cost subscription for everyone",
}[provider.id],
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
async onSelect() {
const methods = sync.data.provider_auth[provider.id] ?? [
{
type: "api",
label: "API key",
},
]
let index: number | null = 0
if (methods.length > 1) {
index = await new Promise<number | null>((resolve) => {
dialog.replace(
() => (
<DialogSelect
title="Select auth method"
options={methods.map((x, index) => ({
title: x.label,
value: index,
}))}
onSelect={(option) => resolve(option.value)}
/>
))
}
if (result.data?.method === "auto") {
dialog.replace(() => (
<AutoMethod
providerID={provider.id}
title={method.label}
index={index}
authorization={result.data!}
/>
))
}
),
() => resolve(null),
)
})
}
if (index == null) return
const method = methods[index]
if (method.type === "oauth") {
let inputs: Record<string, string> | undefined
if (method.prompts?.length) {
const value = await PromptsMethod({
dialog,
prompts: method.prompts,
})
if (!value) return
inputs = value
}
if (method.type === "api") {
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
const result = await sdk.client.provider.oauth.authorize({
providerID: provider.id,
method: index,
inputs,
})
if (result.error) {
toast.show({
variant: "error",
message: JSON.stringify(result.error),
})
dialog.clear()
return
}
},
}
}),
if (result.data?.method === "code") {
dialog.replace(() => (
<CodeMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
))
}
if (result.data?.method === "auto") {
dialog.replace(() => (
<AutoMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
))
}
}
if (method.type === "api") {
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
}
},
})),
)
})
return options
@@ -35,7 +35,6 @@ import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
import { CONSOLE_MANAGED_ICON, consoleManagedProviderLabel } from "@tui/util/provider-origin"
export type PromptProps = {
sessionID?: string
@@ -95,15 +94,6 @@ export function Prompt(props: PromptProps) {
const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? [])
const [auto, setAuto] = createSignal<AutocompleteRef>()
const activeOrgName = createMemo(() => sync.data.console_state.activeOrgName)
const canSwitchOrgs = createMemo(() => sync.data.console_state.switchableOrgCount > 1)
const currentProviderLabel = createMemo(() => {
const current = local.model.current()
const provider = local.model.parsed().provider
if (!current) return provider
return consoleManagedProviderLabel(sync.data.console_state.consoleManagedProviders, current.providerID, provider)
})
const hasRightContent = createMemo(() => Boolean(props.right || activeOrgName()))
function promptModelWarning() {
toast.show({
@@ -841,20 +831,8 @@ export function Prompt(props: PromptProps) {
return !!current
})
const suggestion = createMemo(() => {
if (!props.sessionID) return
if (store.mode !== "normal") return
if (store.prompt.input) return
const current = status()
if (current.type !== "idle") return
const value = current.suggestion?.trim()
if (!value) return
return value
})
const placeholderText = createMemo(() => {
if (props.showPlaceholder === false) return undefined
if (suggestion()) return suggestion()
if (store.mode === "shell") {
if (!shell().length) return undefined
const example = shell()[store.placeholder % shell().length]
@@ -945,16 +923,6 @@ export function Prompt(props: PromptProps) {
e.preventDefault()
return
}
if (!store.prompt.input && e.name === "right" && !e.ctrl && !e.meta && !e.shift && !e.super) {
const value = suggestion()
if (value) {
input.setText(value)
setStore("prompt", "input", value)
input.gotoBufferEnd()
e.preventDefault()
return
}
}
// Check clipboard for images before terminal-handled paste runs.
// This helps terminals that forward Ctrl+V to the app; Windows
// Terminal 1.25+ usually handles Ctrl+V before this path.
@@ -1127,7 +1095,7 @@ export function Prompt(props: PromptProps) {
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
{local.model.parsed().model}
</text>
<text fg={theme.textMuted}>{currentProviderLabel()}</text>
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
<Show when={showVariant()}>
<text fg={theme.textMuted}>·</text>
<text>
@@ -1137,22 +1105,7 @@ export function Prompt(props: PromptProps) {
</box>
</Show>
</box>
<Show when={hasRightContent()}>
<box flexDirection="row" gap={1} alignItems="center">
{props.right}
<Show when={activeOrgName()}>
<text
fg={theme.textMuted}
onMouseUp={() => {
if (!canSwitchOrgs()) return
command.trigger("console.org.switch")
}}
>
{`${CONSOLE_MANAGED_ICON} ${activeOrgName()}`}
</text>
</Show>
</box>
</Show>
{props.right}
</box>
</box>
</box>
@@ -29,7 +29,6 @@ import { batch, onMount } from "solid-js"
import { Log } from "@/util/log"
import type { Path } from "@opencode-ai/sdk"
import type { Workspace } from "@opencode-ai/sdk/v2"
import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
@@ -39,7 +38,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
provider: Provider[]
provider_default: Record<string, string>
provider_next: ProviderListResponse
console_state: ConsoleStateType
provider_auth: Record<string, ProviderAuthMethod[]>
agent: Agent[]
command: Command[]
@@ -83,7 +81,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
default: {},
connected: [],
},
console_state: emptyConsoleState,
provider_auth: {},
config: {},
status: "loading",
@@ -233,7 +230,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
case "session.status": {
setStore("session_status", event.properties.sessionID, reconcile(event.properties.status))
setStore("session_status", event.properties.sessionID, event.properties.status)
break
}
@@ -368,10 +365,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
// blocking - include session.list when continuing a session
const providersPromise = sdk.client.config.providers({}, { throwOnError: true })
const providerListPromise = sdk.client.provider.list({}, { throwOnError: true })
const consoleStatePromise = sdk.client.experimental.console
.get({}, { throwOnError: true })
.then((x) => ConsoleState.parse(x.data))
.catch(() => emptyConsoleState)
const agentsPromise = sdk.client.app.agents({}, { throwOnError: true })
const configPromise = sdk.client.config.get({}, { throwOnError: true })
const blockingRequests: Promise<unknown>[] = [
@@ -386,7 +379,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
.then(() => {
const providersResponse = providersPromise.then((x) => x.data!)
const providerListResponse = providerListPromise.then((x) => x.data!)
const consoleStateResponse = consoleStatePromise
const agentsResponse = agentsPromise.then((x) => x.data ?? [])
const configResponse = configPromise.then((x) => x.data!)
const sessionListResponse = args.continue ? sessionListPromise : undefined
@@ -394,23 +386,20 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return Promise.all([
providersResponse,
providerListResponse,
consoleStateResponse,
agentsResponse,
configResponse,
...(sessionListResponse ? [sessionListResponse] : []),
]).then((responses) => {
const providers = responses[0]
const providerList = responses[1]
const consoleState = responses[2]
const agents = responses[3]
const config = responses[4]
const sessions = responses[5]
const agents = responses[2]
const config = responses[3]
const sessions = responses[4]
batch(() => {
setStore("provider", reconcile(providers.providers))
setStore("provider_default", reconcile(providers.default))
setStore("provider_next", reconcile(providerList))
setStore("console_state", reconcile(consoleState))
setStore("agent", reconcile(agents))
setStore("config", reconcile(config))
if (sessions !== undefined) setStore("session", reconcile(sessions))
@@ -422,7 +411,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
// non-blocking
Promise.all([
...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))),
sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))),
sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))),
@@ -38,7 +38,6 @@ export interface DialogSelectOption<T = any> {
description?: string
footer?: JSX.Element | string
category?: string
categoryView?: JSX.Element
disabled?: boolean
bg?: RGBA
gutter?: JSX.Element
@@ -292,16 +291,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<>
<Show when={category}>
<box paddingTop={index() > 0 ? 1 : 0} paddingLeft={3}>
<Show
when={options[0]?.categoryView}
fallback={
<text fg={theme.accent} attributes={TextAttributes.BOLD}>
{category}
</text>
}
>
{options[0]?.categoryView}
</Show>
<text fg={theme.accent} attributes={TextAttributes.BOLD}>
{category}
</text>
</box>
</Show>
<For each={options}>
@@ -1,20 +0,0 @@
export const CONSOLE_MANAGED_ICON = "⌂"
const contains = (consoleManagedProviders: string[] | ReadonlySet<string>, providerID: string) =>
Array.isArray(consoleManagedProviders)
? consoleManagedProviders.includes(providerID)
: consoleManagedProviders.has(providerID)
export const isConsoleManagedProvider = (consoleManagedProviders: string[] | ReadonlySet<string>, providerID: string) =>
contains(consoleManagedProviders, providerID)
export const consoleManagedProviderSuffix = (
consoleManagedProviders: string[] | ReadonlySet<string>,
providerID: string,
) => (contains(consoleManagedProviders, providerID) ? ` ${CONSOLE_MANAGED_ICON}` : "")
export const consoleManagedProviderLabel = (
consoleManagedProviders: string[] | ReadonlySet<string>,
providerID: string,
providerName: string,
) => `${providerName}${consoleManagedProviderSuffix(consoleManagedProviders, providerID)}`
+11 -35
View File
@@ -33,7 +33,6 @@ import { Account } from "@/account"
import { isRecord } from "@/util/record"
import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem"
import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@/filesystem"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
@@ -1051,13 +1050,11 @@ export namespace Config {
config: Info
directories: string[]
deps: Promise<void>[]
consoleState: ConsoleState
}
export interface Interface {
readonly get: () => Effect.Effect<Info>
readonly getGlobal: () => Effect.Effect<Info>
readonly getConsoleState: () => Effect.Effect<ConsoleState>
readonly update: (config: Info) => Effect.Effect<void>
readonly updateGlobal: (config: Info) => Effect.Effect<Info>
readonly invalidate: (wait?: boolean) => Effect.Effect<void>
@@ -1263,8 +1260,6 @@ export namespace Config {
const auth = yield* authSvc.all().pipe(Effect.orDie)
let result: Info = {}
const consoleManagedProviders = new Set<string>()
let activeOrgName: string | undefined
const scope = (source: string): PluginScope => {
if (source.startsWith("http://") || source.startsWith("https://")) return "global"
@@ -1376,31 +1371,26 @@ export namespace Config {
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
const activeOrg = Option.getOrUndefined(
yield* accountSvc.activeOrg().pipe(Effect.catch(() => Effect.succeed(Option.none()))),
)
if (activeOrg) {
const active = Option.getOrUndefined(yield* accountSvc.active().pipe(Effect.orDie))
if (active?.active_org_id) {
yield* Effect.gen(function* () {
const [configOpt, tokenOpt] = yield* Effect.all(
[accountSvc.config(activeOrg.account.id, activeOrg.org.id), accountSvc.token(activeOrg.account.id)],
[accountSvc.config(active.id, active.active_org_id!), accountSvc.token(active.id)],
{ concurrency: 2 },
)
if (Option.isSome(tokenOpt)) {
process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value
Env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value)
const token = Option.getOrUndefined(tokenOpt)
if (token) {
process.env["OPENCODE_CONSOLE_TOKEN"] = token
Env.set("OPENCODE_CONSOLE_TOKEN", token)
}
activeOrgName = activeOrg.org.name
if (Option.isSome(configOpt)) {
const source = `${activeOrg.account.url}/api/config`
const next = yield* loadConfig(JSON.stringify(configOpt.value), {
const config = Option.getOrUndefined(configOpt)
if (config) {
const source = `${active.url}/api/config`
const next = yield* loadConfig(JSON.stringify(config), {
dir: path.dirname(source),
source,
})
for (const providerID of Object.keys(next.provider ?? {})) {
consoleManagedProviders.add(providerID)
}
merge(source, next, "global")
}
}).pipe(
@@ -1466,11 +1456,6 @@ export namespace Config {
config: result,
directories,
deps,
consoleState: {
consoleManagedProviders: Array.from(consoleManagedProviders),
activeOrgName,
switchableOrgCount: 0,
},
}
})
@@ -1488,10 +1473,6 @@ export namespace Config {
return yield* InstanceState.use(state, (s) => s.directories)
})
const getConsoleState = Effect.fn("Config.getConsoleState")(function* () {
return yield* InstanceState.use(state, (s) => s.consoleState)
})
const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
yield* InstanceState.useEffect(state, (s) => Effect.promise(() => Promise.all(s.deps).then(() => undefined)))
})
@@ -1547,7 +1528,6 @@ export namespace Config {
return Service.of({
get,
getGlobal,
getConsoleState,
update,
updateGlobal,
invalidate,
@@ -1573,10 +1553,6 @@ export namespace Config {
return runPromise((svc) => svc.getGlobal())
}
export async function getConsoleState() {
return runPromise((svc) => svc.getConsoleState())
}
export async function update(config: Info) {
return runPromise((svc) => svc.update(config))
}
@@ -1,15 +0,0 @@
import z from "zod"
export const ConsoleState = z.object({
consoleManagedProviders: z.array(z.string()),
activeOrgName: z.string().optional(),
switchableOrgCount: z.number().int().nonnegative(),
})
export type ConsoleState = z.infer<typeof ConsoleState>
export const emptyConsoleState: ConsoleState = {
consoleManagedProviders: [],
activeOrgName: undefined,
switchableOrgCount: 0,
}
@@ -22,7 +22,6 @@ export const TuiOptions = z.object({
.enum(["auto", "stacked"])
.optional()
.describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
mouse: z.boolean().optional().describe("Enable or disable mouse capture (default: true)"),
})
export const TuiInfo = z
+2 -12
View File
@@ -188,23 +188,13 @@ export namespace AppFileSystem {
export function normalizePath(p: string): string {
if (process.platform !== "win32") return p
const resolved = pathResolve(windowsPath(p))
try {
return realpathSync.native(resolved)
return realpathSync.native(p)
} catch {
return resolved
return p
}
}
export function normalizePathPattern(p: string): string {
if (process.platform !== "win32") return p
if (p === "*") return p
const match = p.match(/^(.*)[\\/]\*$/)
if (!match) return normalizePath(p)
const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1]
return join(normalizePath(dir), "*")
}
export function resolve(p: string): string {
const resolved = pathResolve(windowsPath(p))
try {
-2
View File
@@ -31,7 +31,6 @@ export namespace Flag {
export const OPENCODE_ENABLE_EXPERIMENTAL_MODELS = truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS")
export const OPENCODE_DISABLE_AUTOCOMPACT = truthy("OPENCODE_DISABLE_AUTOCOMPACT")
export const OPENCODE_DISABLE_MODELS_FETCH = truthy("OPENCODE_DISABLE_MODELS_FETCH")
export const OPENCODE_DISABLE_MOUSE = truthy("OPENCODE_DISABLE_MOUSE")
export const OPENCODE_DISABLE_CLAUDE_CODE = truthy("OPENCODE_DISABLE_CLAUDE_CODE")
export const OPENCODE_DISABLE_CLAUDE_CODE_PROMPT =
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT")
@@ -73,7 +72,6 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
export const OPENCODE_EXPERIMENTAL_WORKSPACES = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES")
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
export const OPENCODE_EXPERIMENTAL_NEXT_PROMPT = truthy("OPENCODE_EXPERIMENTAL_NEXT_PROMPT")
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
export const OPENCODE_DISABLE_EMBEDDED_WEB_UI = truthy("OPENCODE_DISABLE_EMBEDDED_WEB_UI")
+1 -9
View File
@@ -11,7 +11,6 @@ import { Arborist } from "@npmcli/arborist"
export namespace Npm {
const log = Log.create({ service: "npm" })
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
export const InstallFailedError = NamedError.create(
"NpmInstallFailedError",
@@ -20,13 +19,8 @@ export namespace Npm {
}),
)
export function sanitize(pkg: string) {
if (!illegal) return pkg
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
}
function directory(pkg: string) {
return path.join(Global.Path.cache, "packages", sanitize(pkg))
return path.join(Global.Path.cache, "packages", pkg)
}
function resolveEntryPoint(name: string, dir: string) {
@@ -73,7 +67,6 @@ export namespace Npm {
binLinks: true,
progress: false,
savePrefix: "",
ignoreScripts: true,
})
const tree = await arborist.loadVirtual().catch(() => {})
if (tree) {
@@ -113,7 +106,6 @@ export namespace Npm {
binLinks: true,
progress: false,
savePrefix: "",
ignoreScripts: true,
})
await arb.reify().catch(() => {})
}
+6 -22
View File
@@ -1,6 +1,5 @@
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import npa from "npm-package-arg"
import semver from "semver"
import { Npm } from "@/npm"
import { Filesystem } from "@/util/filesystem"
@@ -13,24 +12,11 @@ export function isDeprecatedPlugin(spec: string) {
return DEPRECATED_PLUGIN_PACKAGES.some((pkg) => spec.includes(pkg))
}
function parse(spec: string) {
try {
return npa(spec)
} catch {}
}
export function parsePluginSpecifier(spec: string) {
const hit = parse(spec)
if (hit?.type === "alias" && !hit.name) {
const sub = (hit as npa.AliasResult).subSpec
if (sub?.name) {
const version = !sub.rawSpec || sub.rawSpec === "*" ? "latest" : sub.rawSpec
return { pkg: sub.name, version }
}
}
if (!hit?.name) return { pkg: spec, version: "" }
if (hit.raw === hit.name) return { pkg: hit.name, version: "latest" }
return { pkg: hit.name, version: hit.rawSpec }
const lastAt = spec.lastIndexOf("@")
const pkg = lastAt > 0 ? spec.substring(0, lastAt) : spec
const version = lastAt > 0 ? spec.substring(lastAt + 1) : "latest"
return { pkg, version }
}
export type PluginSource = "file" | "npm"
@@ -204,11 +190,9 @@ export async function checkPluginCompatibility(target: string, opencodeVersion:
}
}
export async function resolvePluginTarget(spec: string) {
export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) {
if (isPathPluginSpec(spec)) return resolvePathPluginTarget(spec)
const hit = parse(spec)
const pkg = hit?.name && hit.raw === hit.name ? `${hit.name}@latest` : spec
const result = await Npm.add(pkg)
const result = await Npm.add(parsed.pkg + "@" + parsed.version)
return result.directory
}
@@ -8,116 +8,13 @@ import { Instance } from "../../project/instance"
import { Project } from "../../project/project"
import { MCP } from "../../mcp"
import { Session } from "../../session"
import { Config } from "../../config/config"
import { ConsoleState } from "../../config/console-state"
import { Account, AccountID, OrgID } from "../../account"
import { zodToJsonSchema } from "zod-to-json-schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { WorkspaceRoutes } from "./workspace"
const ConsoleOrgOption = z.object({
accountID: z.string(),
accountEmail: z.string(),
accountUrl: z.string(),
orgID: z.string(),
orgName: z.string(),
active: z.boolean(),
})
const ConsoleOrgList = z.object({
orgs: z.array(ConsoleOrgOption),
})
const ConsoleSwitchBody = z.object({
accountID: z.string(),
orgID: z.string(),
})
export const ExperimentalRoutes = lazy(() =>
new Hono()
.get(
"/console",
describeRoute({
summary: "Get active Console provider metadata",
description: "Get the active Console org name and the set of provider IDs managed by that Console org.",
operationId: "experimental.console.get",
responses: {
200: {
description: "Active Console provider metadata",
content: {
"application/json": {
schema: resolver(ConsoleState),
},
},
},
},
}),
async (c) => {
const [consoleState, groups] = await Promise.all([Config.getConsoleState(), Account.orgsByAccount()])
return c.json({
...consoleState,
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
})
},
)
.get(
"/console/orgs",
describeRoute({
summary: "List switchable Console orgs",
description: "Get the available Console orgs across logged-in accounts, including the current active org.",
operationId: "experimental.console.listOrgs",
responses: {
200: {
description: "Switchable Console orgs",
content: {
"application/json": {
schema: resolver(ConsoleOrgList),
},
},
},
},
}),
async (c) => {
const [groups, active] = await Promise.all([Account.orgsByAccount(), Account.active()])
const orgs = groups.flatMap((group) =>
group.orgs.map((org) => ({
accountID: group.account.id,
accountEmail: group.account.email,
accountUrl: group.account.url,
orgID: org.id,
orgName: org.name,
active: !!active && active.id === group.account.id && active.active_org_id === org.id,
})),
)
return c.json({ orgs })
},
)
.post(
"/console/switch",
describeRoute({
summary: "Switch active Console org",
description: "Persist a new active Console account/org selection for the current local OpenCode state.",
operationId: "experimental.console.switchOrg",
responses: {
200: {
description: "Switch success",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
validator("json", ConsoleSwitchBody),
async (c) => {
const body = c.req.valid("json")
await Account.switchOrg(AccountID.make(body.accountID), OrgID.make(body.orgID))
return c.json(true)
},
)
.get(
"/tool/ids",
describeRoute({
+22 -5
View File
@@ -2,7 +2,6 @@ import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { Session } from "."
import { SessionID, MessageID, PartID } from "./schema"
import { Instance } from "../project/instance"
import { Provider } from "../provider/provider"
import { MessageV2 } from "./message-v2"
import z from "zod"
@@ -219,7 +218,6 @@ When constructing the summary, try to stick to this template:
const prompt = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n")
const msgs = structuredClone(messages)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { stripMedia: true })
const ctx = yield* InstanceState.context
const msg: MessageV2.Assistant = {
id: MessageID.ascending(),
@@ -261,10 +259,29 @@ When constructing the summary, try to stick to this template:
tools: {},
system: [],
messages: [
...modelMessages,
...msgs,
{
role: "user",
content: [{ type: "text", text: prompt }],
info: {
role: "user",
sessionID: input.sessionID,
id: MessageID.ascending(),
time: { created: Date.now() },
agent: agent.name,
model: {
modelID: model.id,
providerID: model.providerID,
},
},
parts: [
{
type: "text",
text: prompt,
sessionID: input.sessionID,
messageID: MessageID.ascending(),
id: PartID.ascending(),
synthetic: true,
},
],
},
],
model,
+1 -1
View File
@@ -278,7 +278,7 @@ export namespace Session {
const tokens = {
total,
input: adjustedInputTokens,
output: outputTokens - reasoningTokens,
output: outputTokens,
reasoning: reasoningTokens,
cache: {
write: cacheWriteInputTokens,
+20 -27
View File
@@ -1,7 +1,6 @@
import { Provider } from "@/provider/provider"
import { Log } from "@/util/log"
import { Cause, Effect, Layer, Record, ServiceMap } from "effect"
import * as Queue from "effect/Queue"
import { Effect, Layer, Record, ServiceMap } from "effect"
import * as Stream from "effect/Stream"
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai"
import { mergeDeep, pipe } from "remeda"
@@ -10,13 +9,14 @@ import { ProviderTransform } from "@/provider/transform"
import { Config } from "@/config/config"
import { Instance } from "@/project/instance"
import type { Agent } from "@/agent/agent"
import type { MessageV2 } from "./message-v2"
import { MessageV2 } from "./message-v2"
import { Plugin } from "@/plugin"
import { SystemPrompt } from "./system"
import { Flag } from "@/flag/flag"
import { Permission } from "@/permission"
import { Auth } from "@/auth"
import { Installation } from "@/installation"
import { iife } from "@/util/iife"
export namespace LLM {
const log = Log.create({ service: "llm" })
@@ -30,7 +30,7 @@ export namespace LLM {
agent: Agent.Info
permission?: Permission.Ruleset
system: string[]
messages: ModelMessage[]
messages: MessageV2.WithParts[]
small?: boolean
tools: Record<string, Tool>
retries?: number
@@ -146,19 +146,18 @@ export namespace LLM {
}
const isWorkflow = language instanceof GitLabWorkflowLanguageModel
const messages = isOpenaiOauth
? input.messages
: isWorkflow
? input.messages
: [
...system.map(
(x): ModelMessage => ({
role: "system",
content: x,
}),
),
...input.messages,
]
const messages = await iife(async () => {
if (isOpenaiOauth || isWorkflow) return MessageV2.toModelMessages(input.messages, input.model)
return [
...system.map(
(x): ModelMessage => ({
role: "system",
content: x,
}),
),
...(await MessageV2.toModelMessages(input.messages, input.model)),
]
})
const params = await Plugin.trigger(
"chat.params",
@@ -198,7 +197,7 @@ export namespace LLM {
? undefined
: ProviderTransform.maxOutputTokens(input.model)
const tools = await resolveTools(input)
const tools = resolveTools(input)
// LiteLLM and some Anthropic proxies require the tools parameter to be present
// when message history contains tool calls, even if no tools are being used.
@@ -242,7 +241,7 @@ export namespace LLM {
try {
const result = await t.execute!(JSON.parse(argsJson), {
toolCallId: _requestID,
messages: input.messages,
messages: [],
abortSignal: input.abort,
})
const output = typeof result === "string" ? result : (result?.output ?? JSON.stringify(result))
@@ -346,13 +345,7 @@ export namespace LLM {
// Check if messages contain any tool-call content
// Used to determine if a dummy tool should be added for LiteLLM proxy compatibility
export function hasToolCalls(messages: ModelMessage[]): boolean {
for (const msg of messages) {
if (!Array.isArray(msg.content)) continue
for (const part of msg.content) {
if (part.type === "tool-call" || part.type === "tool-result") return true
}
}
return false
export function hasToolCalls(messages: MessageV2.WithParts[]): boolean {
return messages.some((msg) => msg.parts.some((part) => part.type === "tool"))
}
}
+11 -93
View File
@@ -11,7 +11,6 @@ import { Provider } from "../provider/provider"
import { ModelID, ProviderID } from "../provider/schema"
import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai"
import { SessionCompaction } from "./compaction"
import { Instance } from "../project/instance"
import { Bus } from "../bus"
import { ProviderTransform } from "../provider/transform"
import { SystemPrompt } from "./system"
@@ -20,12 +19,10 @@ import { Plugin } from "../plugin"
import PROMPT_PLAN from "../session/prompt/plan.txt"
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
import MAX_STEPS from "../session/prompt/max-steps.txt"
import PROMPT_SUGGEST_NEXT from "../session/prompt/suggest-next.txt"
import { ToolRegistry } from "../tool/registry"
import { Runner } from "@/effect/runner"
import { MCP } from "../mcp"
import { LSP } from "../lsp"
import { ReadTool } from "../tool/read"
import { FileTime } from "../file/time"
import { Flag } from "../flag/flag"
import { ulid } from "ulid"
@@ -38,7 +35,6 @@ import { ConfigMarkdown } from "../config/markdown"
import { SessionSummary } from "./summary"
import { NamedError } from "@opencode-ai/util/error"
import { SessionProcessor } from "./processor"
import { TaskTool } from "@/tool/task"
import { Tool } from "@/tool/tool"
import { Permission } from "@/permission"
import { SessionStatus } from "./status"
@@ -51,6 +47,8 @@ import { Process } from "@/util/process"
import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { TaskTool } from "@/tool/task"
import { ReadTool } from "@/tool/read"
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -250,80 +248,6 @@ export namespace SessionPrompt {
)
})
const suggest = Effect.fn("SessionPrompt.suggest")(function* (input: {
session: Session.Info
sessionID: SessionID
message: MessageV2.WithParts
}) {
if (input.session.parentID) return
const message = input.message.info
if (message.role !== "assistant") return
if (message.error) return
if (!message.finish) return
if (["tool-calls", "unknown"].includes(message.finish)) return
if ((yield* status.get(input.sessionID)).type !== "idle") return
// Use the same model for prompt-cache hit on the conversation prefix
const model = yield* Effect.promise(async () =>
Provider.getModel(message.providerID, message.modelID).catch(() => undefined),
)
if (!model) return
const ag = yield* agents.get(message.agent ?? "code")
if (!ag) return
// Full message history so the cached KV from the main conversation is reused
const msgs = yield* MessageV2.filterCompactedEffect(input.sessionID)
const real = (item: MessageV2.WithParts) =>
item.info.role === "user" && !item.parts.every((part) => "synthetic" in part && part.synthetic)
const parent = msgs.find((item) => item.info.id === message.parentID)
const user = parent && real(parent) ? parent.info : msgs.findLast((item) => real(item))?.info
if (!user || user.role !== "user") return
// Rebuild system prompt identical to the main loop for cache hit
const skills = yield* Effect.promise(() => SystemPrompt.skills(ag))
const env = yield* Effect.promise(() => SystemPrompt.environment(model))
const instructions = yield* instruction.system().pipe(Effect.orDie)
const modelMsgs = yield* Effect.promise(() => MessageV2.toModelMessages(msgs, model))
const system = [...env, ...(skills ? [skills] : []), ...instructions]
const text = yield* Effect.promise(async (signal) => {
const result = await LLM.stream({
agent: ag,
user,
system,
small: false,
tools: {},
model,
abort: signal,
sessionID: input.sessionID,
retries: 1,
toolChoice: "none",
// Append suggestion instruction after the full conversation
messages: [...modelMsgs, { role: "user" as const, content: PROMPT_SUGGEST_NEXT }],
})
return result.text
})
const line = text
.replace(/<think>[\s\S]*?<\/think>\s*/g, "")
.split("\n")
.map((item) => item.trim())
.find((item) => item.length > 0)
?.replace(/^["'`]+|["'`]+$/g, "")
if (!line) return
const tag = line
.toUpperCase()
.replace(/[\s-]+/g, "_")
.replace(/[^A-Z_]/g, "")
if (tag === "NO_SUGGESTION") return
const suggestion = line.length > 110 ? line.slice(0, 107) + "..." : line
if ((yield* status.get(input.sessionID)).type !== "idle") return
yield* status.suggest(input.sessionID, suggestion)
})
const insertReminders = Effect.fn("SessionPrompt.insertReminders")(function* (input: {
messages: MessageV2.WithParts[]
agent: Agent.Info
@@ -508,10 +432,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the
),
})
for (const item of yield* registry.tools(
{ modelID: ModelID.make(input.model.api.id), providerID: input.model.providerID },
input.agent,
)) {
for (const item of yield* registry.tools({
modelID: ModelID.make(input.model.api.id),
providerID: input.model.providerID,
})) {
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
tools[item.id] = tool({
id: item.id as any,
@@ -635,7 +559,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}) {
const { task, model, lastUser, sessionID, session, msgs } = input
const ctx = yield* InstanceState.context
const taskTool = yield* Effect.promise(() => registry.named.task.init())
const taskTool = yield* registry.fromID(TaskTool.id)
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
id: MessageID.ascending(),
@@ -658,7 +582,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
sessionID: assistantMessage.sessionID,
type: "tool",
callID: ulid(),
tool: registry.named.task.id,
tool: TaskTool.id,
state: {
status: "running",
input: {
@@ -1185,7 +1109,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
},
]
const read = yield* Effect.promise(() => registry.named.read.init()).pipe(
const read = yield* registry.fromID(ReadTool.id).pipe(
Effect.flatMap((t) =>
provider.getModel(info.model.providerID, info.model.modelID).pipe(
Effect.flatMap((mdl) =>
@@ -1249,7 +1173,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (part.mime === "application/x-directory") {
const args = { filePath: filepath }
const result = yield* Effect.promise(() => registry.named.read.init()).pipe(
const result = yield* registry.fromID(ReadTool.id).pipe(
Effect.flatMap((t) =>
Effect.promise(() =>
t.execute(args, {
@@ -1394,13 +1318,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}
if (input.noReply === true) return message
const result = yield* loop({ sessionID: input.sessionID })
yield* suggest({
session,
sessionID: input.sessionID,
message: result,
}).pipe(Effect.ignore, Effect.forkIn(scope))
return result
return yield* loop({ sessionID: input.sessionID })
},
)
@@ -82,6 +82,25 @@ If the `AGENTS.md` is empty or insufficient, you may check `README`/`README.md`
If you modified any files/styles/structures/configurations/workflows/... mentioned in `AGENTS.md` files, you MUST update the corresponding `AGENTS.md` files to keep them up-to-date.
# Skills
Skills are reusable, composable capabilities that enhance your abilities. Each skill is a self-contained directory with a `SKILL.md` file that contains instructions, examples, and/or reference material.
## What are skills?
Skills are modular extensions that provide:
- Specialized knowledge: Domain-specific expertise (e.g., PDF processing, data analysis)
- Workflow patterns: Best practices for common tasks
- Tool integrations: Pre-configured tool chains for specific operations
- Reference material: Documentation, templates, and examples
## How to use skills
Identify the skills that are likely to be useful for the tasks you are currently working on, use the `skill` tool to load a skill for detailed instructions, guidelines, scripts and more.
Only load skill details when needed to conserve the context window.
# Ultimate Reminders
At any time, you should be HELPFUL, CONCISE, and ACCURATE. Be thorough in your actions — test what you build, verify what you change — not in your explanations.
@@ -1,21 +0,0 @@
You are generating a suggested next user message for the current conversation.
Goal:
- Suggest a useful next step that keeps momentum.
Rules:
- Output exactly one line, 110 characters max. Be concise.
- Write as the user speaking to the assistant (for example: "Can you...", "Help me...", "Let's...").
- Match the user's tone and language; keep it natural and human.
- Prefer a concrete action over a broad question.
- If the conversation is vague or small-talk, steer toward a practical starter request.
- If there is no meaningful or appropriate next step to suggest, output exactly: NO_SUGGESTION
- Avoid corporate or robotic phrasing.
- Avoid asking multiple discovery questions in one sentence.
- Do not include quotes, labels, markdown, or explanations.
Examples:
- Greeting context -> "Can you scan this repo and suggest the best first task to tackle?"
- Bug-fix context -> "Can you reproduce this bug and propose the smallest safe fix?"
- Feature context -> "Let's implement this incrementally; start with the MVP version first."
- Conversation is complete -> "NO_SUGGESTION"
+1 -17
View File
@@ -11,7 +11,6 @@ export namespace SessionStatus {
.union([
z.object({
type: z.literal("idle"),
suggestion: z.string().optional(),
}),
z.object({
type: z.literal("retry"),
@@ -49,7 +48,6 @@ export namespace SessionStatus {
readonly get: (sessionID: SessionID) => Effect.Effect<Info>
readonly list: () => Effect.Effect<Map<SessionID, Info>>
readonly set: (sessionID: SessionID, status: Info) => Effect.Effect<void>
readonly suggest: (sessionID: SessionID, suggestion: string) => Effect.Effect<void>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionStatus") {}
@@ -83,17 +81,7 @@ export namespace SessionStatus {
data.set(sessionID, status)
})
const suggest = Effect.fn("SessionStatus.suggest")(function* (sessionID: SessionID, suggestion: string) {
const data = yield* InstanceState.get(state)
const current = data.get(sessionID)
if (current && current.type !== "idle") return
const status: Info = { type: "idle", suggestion }
// only publish Status so the TUI sees the suggestion;
// skip Event.Idle to avoid spurious plugin notifications
yield* bus.publish(Event.Status, { sessionID, status })
})
return Service.of({ get, list, set, suggest })
return Service.of({ get, list, set })
}),
)
@@ -111,8 +99,4 @@ export namespace SessionStatus {
export async function set(sessionID: SessionID, status: Info) {
return runPromise((svc) => svc.set(sessionID, status))
}
export async function suggest(sessionID: SessionID, suggestion: string) {
return runPromise((svc) => svc.suggest(sessionID, suggestion))
}
}
+15 -9
View File
@@ -239,22 +239,28 @@ export namespace Skill {
export function fmt(list: Info[], opts: { verbose: boolean }) {
if (list.length === 0) return "No skills are currently available."
if (opts.verbose) {
return [
"<available_skills>",
...list.flatMap((skill) => [
" <skill>",
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
" </skill>",
]),
...list
.sort((a, b) => a.name.localeCompare(b.name))
.flatMap((skill) => [
" <skill>",
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
" </skill>",
]),
"</available_skills>",
].join("\n")
}
return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
return [
"## Available Skills",
...list
.toSorted((a, b) => a.name.localeCompare(b.name))
.map((skill) => `- **${skill.name}**: ${skill.description}`),
].join("\n")
}
const { runPromise } = makeRuntime(Service, defaultLayer)
+17 -15
View File
@@ -50,6 +50,22 @@ const FILES = new Set([
const FLAGS = new Set(["-destination", "-literalpath", "-path"])
const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"])
const Parameters = z.object({
command: z.string().describe("The command to execute"),
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
workdir: z
.string()
.describe(
`The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`,
)
.optional(),
description: z
.string()
.describe(
"Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
),
})
type Part = {
type: string
text: string
@@ -452,21 +468,7 @@ export const BashTool = Tool.define("bash", async () => {
.replaceAll("${chaining}", chain)
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
parameters: z.object({
command: z.string().describe("The command to execute"),
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
workdir: z
.string()
.describe(
`The working directory to run the command in. Defaults to ${Instance.directory}. Use this instead of 'cd' commands.`,
)
.optional(),
description: z
.string()
.describe(
"Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
),
}),
parameters: Parameters,
async execute(params, ctx) {
const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory
if (params.timeout !== undefined && params.timeout < 0) {
+13 -11
View File
@@ -7,20 +7,22 @@ import DESCRIPTION from "./batch.txt"
const DISALLOWED = new Set(["batch"])
const FILTERED_FROM_SUGGESTIONS = new Set(["invalid", "patch", ...DISALLOWED])
const Parameters = z.object({
tool_calls: z
.array(
z.object({
tool: z.string().describe("The name of the tool to execute"),
parameters: z.object({}).loose().describe("Parameters for the tool"),
}),
)
.min(1, "Provide at least one tool call")
.describe("Array of tool calls to execute in parallel"),
})
export const BatchTool = Tool.define("batch", async () => {
return {
description: DESCRIPTION,
parameters: z.object({
tool_calls: z
.array(
z.object({
tool: z.string().describe("The name of the tool to execute"),
parameters: z.object({}).loose().describe("Parameters for the tool"),
}),
)
.min(1, "Provide at least one tool call")
.describe("Array of tool calls to execute in parallel"),
}),
parameters: Parameters,
formatValidationError(error) {
const formattedErrors = error.issues
.map((issue) => {
@@ -1,8 +1,7 @@
import path from "path"
import { Effect } from "effect"
import type { Tool } from "./tool"
import { Instance } from "../project/instance"
import { AppFileSystem } from "../filesystem"
import { Filesystem } from "@/util/filesystem"
type Kind = "file" | "directory"
@@ -16,14 +15,14 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string
if (options?.bypass) return
const full = process.platform === "win32" ? AppFileSystem.normalizePath(target) : target
const full = process.platform === "win32" ? Filesystem.normalizePath(target) : target
if (Instance.containsPath(full)) return
const kind = options?.kind ?? "file"
const dir = kind === "directory" ? full : path.dirname(full)
const glob =
process.platform === "win32"
? AppFileSystem.normalizePathPattern(path.join(dir, "*"))
? Filesystem.normalizePathPattern(path.join(dir, "*"))
: path.join(dir, "*").replaceAll("\\", "/")
await ctx.ask({
@@ -36,11 +35,3 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string
},
})
}
export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirectory")(function* (
ctx: Tool.Context,
target?: string,
options?: Options,
) {
yield* Effect.promise(() => assertExternalDirectory(ctx, target, options))
})
+1 -1
View File
@@ -41,6 +41,6 @@ export const QuestionTool = Tool.defineEffect<typeof parameters, Metadata, Quest
},
}
},
} satisfies Tool.Def<typeof parameters, Metadata>
}
}),
)
+192 -228
View File
@@ -1,17 +1,16 @@
import z from "zod"
import { Effect, Scope } from "effect"
import { createReadStream } from "fs"
import { open } from "fs/promises"
import * as fs from "fs/promises"
import * as path from "path"
import { createInterface } from "readline"
import { Tool } from "./tool"
import { AppFileSystem } from "../filesystem"
import { LSP } from "../lsp"
import { FileTime } from "../file/time"
import DESCRIPTION from "./read.txt"
import { Instance } from "../project/instance"
import { assertExternalDirectoryEffect } from "./external-directory"
import { assertExternalDirectory } from "./external-directory"
import { Instruction } from "../session/instruction"
import { Filesystem } from "../util/filesystem"
const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
@@ -19,257 +18,222 @@ const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
const MAX_BYTES = 50 * 1024
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
const parameters = z.object({
filePath: z.string().describe("The absolute path to the file or directory to read"),
offset: z.coerce.number().describe("The line number to start reading from (1-indexed)").optional(),
limit: z.coerce.number().describe("The maximum number of lines to read (defaults to 2000)").optional(),
})
export const ReadTool = Tool.define("read", {
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file or directory to read"),
offset: z.coerce.number().describe("The line number to start reading from (1-indexed)").optional(),
limit: z.coerce.number().describe("The maximum number of lines to read (defaults to 2000)").optional(),
}),
async execute(params, ctx) {
if (params.offset !== undefined && params.offset < 1) {
throw new Error("offset must be greater than or equal to 1")
}
let filepath = params.filePath
if (!path.isAbsolute(filepath)) {
filepath = path.resolve(Instance.directory, filepath)
}
if (process.platform === "win32") {
filepath = Filesystem.normalizePath(filepath)
}
const title = path.relative(Instance.worktree, filepath)
export const ReadTool = Tool.defineEffect(
"read",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const instruction = yield* Instruction.Service
const lsp = yield* LSP.Service
const time = yield* FileTime.Service
const scope = yield* Scope.Scope
const stat = Filesystem.stat(filepath)
const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) {
await assertExternalDirectory(ctx, filepath, {
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]),
kind: stat?.isDirectory() ? "directory" : "file",
})
await ctx.ask({
permission: "read",
patterns: [filepath],
always: ["*"],
metadata: {},
})
if (!stat) {
const dir = path.dirname(filepath)
const base = path.basename(filepath)
const items = yield* fs.readDirectory(dir).pipe(
Effect.map((items) =>
items
const suggestions = await fs
.readdir(dir)
.then((entries) =>
entries
.filter(
(item) =>
item.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(item.toLowerCase()),
(entry) =>
entry.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(entry.toLowerCase()),
)
.map((item) => path.join(dir, item))
.map((entry) => path.join(dir, entry))
.slice(0, 3),
),
Effect.catch(() => Effect.succeed([] as string[])),
)
if (items.length > 0) {
return yield* Effect.fail(
new Error(`File not found: ${filepath}\n\nDid you mean one of these?\n${items.join("\n")}`),
)
.catch(() => [])
if (suggestions.length > 0) {
throw new Error(`File not found: ${filepath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`)
}
return yield* Effect.fail(new Error(`File not found: ${filepath}`))
})
throw new Error(`File not found: ${filepath}`)
}
const list = Effect.fn("ReadTool.list")(function* (filepath: string) {
const items = yield* fs.readDirectoryEntries(filepath)
return yield* Effect.forEach(
items,
Effect.fnUntraced(function* (item) {
if (item.type === "directory") return item.name + "/"
if (item.type !== "symlink") return item.name
const target = yield* fs
.stat(path.join(filepath, item.name))
.pipe(Effect.catch(() => Effect.succeed(undefined)))
if (target?.type === "Directory") return item.name + "/"
return item.name
}),
{ concurrency: "unbounded" },
).pipe(Effect.map((items: string[]) => items.sort((a, b) => a.localeCompare(b))))
})
const warm = Effect.fn("ReadTool.warm")(function* (filepath: string, sessionID: Tool.Context["sessionID"]) {
yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope))
yield* time.read(sessionID, filepath)
})
const run = Effect.fn("ReadTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
if (params.offset !== undefined && params.offset < 1) {
return yield* Effect.fail(new Error("offset must be greater than or equal to 1"))
}
let filepath = params.filePath
if (!path.isAbsolute(filepath)) {
filepath = path.resolve(Instance.directory, filepath)
}
if (process.platform === "win32") {
filepath = AppFileSystem.normalizePath(filepath)
}
const title = path.relative(Instance.worktree, filepath)
const stat = yield* fs.stat(filepath).pipe(
Effect.catchIf(
(err) => "reason" in err && err.reason._tag === "NotFound",
() => Effect.succeed(undefined),
),
)
yield* assertExternalDirectoryEffect(ctx, filepath, {
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]),
kind: stat?.type === "Directory" ? "directory" : "file",
})
yield* Effect.promise(() =>
ctx.ask({
permission: "read",
patterns: [filepath],
always: ["*"],
metadata: {},
if (stat.isDirectory()) {
const dirents = await fs.readdir(filepath, { withFileTypes: true })
const entries = await Promise.all(
dirents.map(async (dirent) => {
if (dirent.isDirectory()) return dirent.name + "/"
if (dirent.isSymbolicLink()) {
const target = await fs.stat(path.join(filepath, dirent.name)).catch(() => undefined)
if (target?.isDirectory()) return dirent.name + "/"
}
return dirent.name
}),
)
entries.sort((a, b) => a.localeCompare(b))
if (!stat) return yield* miss(filepath)
const limit = params.limit ?? DEFAULT_READ_LIMIT
const offset = params.offset ?? 1
const start = offset - 1
const sliced = entries.slice(start, start + limit)
const truncated = start + sliced.length < entries.length
if (stat.type === "Directory") {
const items = yield* list(filepath)
const limit = params.limit ?? DEFAULT_READ_LIMIT
const offset = params.offset ?? 1
const start = offset - 1
const sliced = items.slice(start, start + limit)
const truncated = start + sliced.length < items.length
return {
title,
output: [
`<path>${filepath}</path>`,
`<type>directory</type>`,
`<entries>`,
sliced.join("\n"),
truncated
? `\n(Showing ${sliced.length} of ${items.length} entries. Use 'offset' parameter to read beyond entry ${offset + sliced.length})`
: `\n(${items.length} entries)`,
`</entries>`,
].join("\n"),
metadata: {
preview: sliced.slice(0, 20).join("\n"),
truncated,
loaded: [] as string[],
},
}
}
const loaded = yield* instruction.resolve(ctx.messages, filepath, ctx.messageID)
const mime = AppFileSystem.mimeType(filepath)
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
const isPdf = mime === "application/pdf"
if (isImage || isPdf) {
const msg = `${isImage ? "Image" : "PDF"} read successfully`
return {
title,
output: msg,
metadata: {
preview: msg,
truncated: false,
loaded: loaded.map((item) => item.filepath),
},
attachments: [
{
type: "file" as const,
mime,
url: `data:${mime};base64,${Buffer.from(yield* fs.readFile(filepath)).toString("base64")}`,
},
],
}
}
if (yield* Effect.promise(() => isBinaryFile(filepath, Number(stat.size)))) {
return yield* Effect.fail(new Error(`Cannot read binary file: ${filepath}`))
}
const file = yield* Effect.promise(() =>
lines(filepath, { limit: params.limit ?? DEFAULT_READ_LIMIT, offset: params.offset ?? 1 }),
)
if (file.count < file.offset && !(file.count === 0 && file.offset === 1)) {
return yield* Effect.fail(
new Error(`Offset ${file.offset} is out of range for this file (${file.count} lines)`),
)
}
let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>" + "\n"].join("\n")
output += file.raw.map((line, i) => `${i + file.offset}: ${line}`).join("\n")
const last = file.offset + file.raw.length - 1
const next = last + 1
const truncated = file.more || file.cut
if (file.cut) {
output += `\n\n(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${file.offset}-${last}. Use offset=${next} to continue.)`
} else if (file.more) {
output += `\n\n(Showing lines ${file.offset}-${last} of ${file.count}. Use offset=${next} to continue.)`
} else {
output += `\n\n(End of file - total ${file.count} lines)`
}
output += "\n</content>"
yield* warm(filepath, ctx.sessionID)
if (loaded.length > 0) {
output += `\n\n<system-reminder>\n${loaded.map((item) => item.content).join("\n\n")}\n</system-reminder>`
}
const output = [
`<path>${filepath}</path>`,
`<type>directory</type>`,
`<entries>`,
sliced.join("\n"),
truncated
? `\n(Showing ${sliced.length} of ${entries.length} entries. Use 'offset' parameter to read beyond entry ${offset + sliced.length})`
: `\n(${entries.length} entries)`,
`</entries>`,
].join("\n")
return {
title,
output,
metadata: {
preview: file.raw.slice(0, 20).join("\n"),
preview: sliced.slice(0, 20).join("\n"),
truncated,
loaded: loaded.map((item) => item.filepath),
loaded: [] as string[],
},
}
}
const instructions = await Instruction.resolve(ctx.messages, filepath, ctx.messageID)
// Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files)
const mime = Filesystem.mimeType(filepath)
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
const isPdf = mime === "application/pdf"
if (isImage || isPdf) {
const msg = `${isImage ? "Image" : "PDF"} read successfully`
return {
title,
output: msg,
metadata: {
preview: msg,
truncated: false,
loaded: instructions.map((i) => i.filepath),
},
attachments: [
{
type: "file",
mime,
url: `data:${mime};base64,${Buffer.from(await Filesystem.readBytes(filepath)).toString("base64")}`,
},
],
}
}
const isBinary = await isBinaryFile(filepath, Number(stat.size))
if (isBinary) throw new Error(`Cannot read binary file: ${filepath}`)
const stream = createReadStream(filepath, { encoding: "utf8" })
const rl = createInterface({
input: stream,
// Note: we use the crlfDelay option to recognize all instances of CR LF
// ('\r\n') in file as a single line break.
crlfDelay: Infinity,
})
const limit = params.limit ?? DEFAULT_READ_LIMIT
const offset = params.offset ?? 1
const start = offset - 1
const raw: string[] = []
let bytes = 0
let lines = 0
let truncatedByBytes = false
let hasMoreLines = false
try {
for await (const text of rl) {
lines += 1
if (lines <= start) continue
if (raw.length >= limit) {
hasMoreLines = true
continue
}
const line = text.length > MAX_LINE_LENGTH ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text
const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0)
if (bytes + size > MAX_BYTES) {
truncatedByBytes = true
hasMoreLines = true
break
}
raw.push(line)
bytes += size
}
} finally {
rl.close()
stream.destroy()
}
if (lines < offset && !(lines === 0 && offset === 1)) {
throw new Error(`Offset ${offset} is out of range for this file (${lines} lines)`)
}
const content = raw.map((line, index) => {
return `${index + offset}: ${line}`
})
const preview = raw.slice(0, 20).join("\n")
let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>"].join("\n")
output += content.join("\n")
const totalLines = lines
const lastReadLine = offset + raw.length - 1
const nextOffset = lastReadLine + 1
const truncated = hasMoreLines || truncatedByBytes
if (truncatedByBytes) {
output += `\n\n(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${offset}-${lastReadLine}. Use offset=${nextOffset} to continue.)`
} else if (hasMoreLines) {
output += `\n\n(Showing lines ${offset}-${lastReadLine} of ${totalLines}. Use offset=${nextOffset} to continue.)`
} else {
output += `\n\n(End of file - total ${totalLines} lines)`
}
output += "\n</content>"
// just warms the lsp client
LSP.touchFile(filepath, false)
await FileTime.read(ctx.sessionID, filepath)
if (instructions.length > 0) {
output += `\n\n<system-reminder>\n${instructions.map((i) => i.content).join("\n\n")}\n</system-reminder>`
}
return {
description: DESCRIPTION,
parameters,
async execute(params: z.infer<typeof parameters>, ctx) {
return Effect.runPromise(run(params, ctx).pipe(Effect.orDie))
title,
output,
metadata: {
preview,
truncated,
loaded: instructions.map((i) => i.filepath),
},
}
}),
)
async function lines(filepath: string, opts: { limit: number; offset: number }) {
const stream = createReadStream(filepath, { encoding: "utf8" })
const rl = createInterface({
input: stream,
// Note: we use the crlfDelay option to recognize all instances of CR LF
// ('\r\n') in file as a single line break.
crlfDelay: Infinity,
})
const start = opts.offset - 1
const raw: string[] = []
let bytes = 0
let count = 0
let cut = false
let more = false
try {
for await (const text of rl) {
count += 1
if (count <= start) continue
if (raw.length >= opts.limit) {
more = true
continue
}
const line = text.length > MAX_LINE_LENGTH ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text
const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0)
if (bytes + size > MAX_BYTES) {
cut = true
more = true
break
}
raw.push(line)
bytes += size
}
} finally {
rl.close()
stream.destroy()
}
return { raw, count, cut, more, offset: opts.offset }
}
},
})
async function isBinaryFile(filepath: string, fileSize: number): Promise<boolean> {
const ext = path.extname(filepath).toLowerCase()
@@ -310,7 +274,7 @@ async function isBinaryFile(filepath: string, fileSize: number): Promise<boolean
if (fileSize === 0) return false
const fh = await open(filepath, "r")
const fh = await fs.open(filepath, "r")
try {
const sampleSize = Math.min(4096, fileSize)
const bytes = Buffer.alloc(sampleSize)
+127 -166
View File
@@ -12,10 +12,8 @@ import { WebFetchTool } from "./webfetch"
import { WriteTool } from "./write"
import { InvalidTool } from "./invalid"
import { SkillTool } from "./skill"
import type { Agent } from "../agent/agent"
import { Tool } from "./tool"
import { Config } from "../config/config"
import path from "path"
import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
import z from "zod"
import { Plugin } from "../plugin"
@@ -35,60 +33,42 @@ import { makeRuntime } from "@/effect/run-service"
import { Env } from "../env"
import { Question } from "../question"
import { Todo } from "../session/todo"
import { LSP } from "../lsp"
import { FileTime } from "../file/time"
import { Instruction } from "../session/instruction"
import { AppFileSystem } from "../filesystem"
import path from "path"
export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" })
type State = {
custom: Tool.Info[]
custom: Tool.Def[]
builtin: Tool.Def[]
}
export interface Interface {
readonly ids: () => Effect.Effect<string[]>
readonly named: {
task: Tool.Info
read: Tool.Info
}
readonly tools: (
model: { providerID: ProviderID; modelID: ModelID },
agent?: Agent.Info,
) => Effect.Effect<(Tool.Def & { id: string })[]>
readonly all: () => Effect.Effect<Tool.Def[]>
readonly tools: (model: { providerID: ProviderID; modelID: ModelID }) => Effect.Effect<Tool.Def[]>
readonly fromID: (id: string) => Effect.Effect<Tool.Def>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ToolRegistry") {}
export const layer: Layer.Layer<
Service,
never,
| Config.Service
| Plugin.Service
| Question.Service
| Todo.Service
| LSP.Service
| FileTime.Service
| Instruction.Service
| AppFileSystem.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const plugin = yield* Plugin.Service
export const layer: Layer.Layer<Service, never, Config.Service | Plugin.Service | Question.Service | Todo.Service> =
Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const plugin = yield* Plugin.Service
const build = <T extends Tool.Info>(tool: T | Effect.Effect<T, never, any>) =>
Effect.isEffect(tool) ? tool : Effect.succeed(tool)
const build = <T extends Tool.Info>(tool: T | Effect.Effect<T, never, any>) =>
Effect.isEffect(tool) ? tool.pipe(Effect.flatMap(Tool.init)) : Tool.init(tool)
const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) {
const custom: Tool.Info[] = []
const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) {
const custom: Tool.Def[] = []
function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
return {
id,
init: async (initCtx) => ({
function fromPlugin(id: string, def: ToolDefinition): Tool.Def {
return {
id,
parameters: z.object(def.args),
description: def.description,
execute: async (args, toolCtx) => {
@@ -98,139 +78,127 @@ export namespace ToolRegistry {
worktree: ctx.worktree,
} as unknown as PluginToolContext
const result = await def.execute(args as any, pluginCtx)
const out = await Truncate.output(result, {}, initCtx?.agent)
const out = await Truncate.output(result, {})
return {
title: "",
output: out.truncated ? out.content : result,
metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined },
}
},
}),
}
}
}
const dirs = yield* config.directories()
const matches = dirs.flatMap((dir) =>
Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
)
if (matches.length) yield* config.waitForDependencies()
for (const match of matches) {
const namespace = path.basename(match, path.extname(match))
const mod = yield* Effect.promise(
() => import(process.platform === "win32" ? match : pathToFileURL(match).href),
const dirs = yield* config.directories()
const matches = dirs.flatMap((dir) =>
Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
)
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
if (matches.length) yield* config.waitForDependencies()
for (const match of matches) {
const namespace = path.basename(match, path.extname(match))
const mod = yield* Effect.promise(
() => import(process.platform === "win32" ? match : pathToFileURL(match).href),
)
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
}
}
}
const plugins = yield* plugin.list()
for (const p of plugins) {
for (const [id, def] of Object.entries(p.tool ?? {})) {
custom.push(fromPlugin(id, def))
const plugins = yield* plugin.list()
for (const p of plugins) {
for (const [id, def] of Object.entries(p.tool ?? {})) {
custom.push(fromPlugin(id, def))
}
}
}
return { custom }
}),
)
const cfg = yield* config.get()
const question =
["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
const invalid = yield* build(InvalidTool)
const ask = yield* build(QuestionTool)
const bash = yield* build(BashTool)
const read = yield* build(ReadTool)
const glob = yield* build(GlobTool)
const grep = yield* build(GrepTool)
const edit = yield* build(EditTool)
const write = yield* build(WriteTool)
const task = yield* build(TaskTool)
const fetch = yield* build(WebFetchTool)
const todo = yield* build(TodoWriteTool)
const search = yield* build(WebSearchTool)
const code = yield* build(CodeSearchTool)
const skill = yield* build(SkillTool)
const patch = yield* build(ApplyPatchTool)
const lsp = yield* build(LspTool)
const batch = yield* build(BatchTool)
const plan = yield* build(PlanExitTool)
const all = Effect.fn("ToolRegistry.all")(function* (custom: Tool.Info[]) {
const cfg = yield* config.get()
const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
return [
invalid,
...(question ? [ask] : []),
bash,
read,
glob,
grep,
edit,
write,
task,
fetch,
todo,
search,
code,
skill,
patch,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [lsp] : []),
...(cfg.experimental?.batch_tool === true ? [batch] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [plan] : []),
...custom,
]
})
const ids = Effect.fn("ToolRegistry.ids")(function* () {
const s = yield* InstanceState.get(state)
const tools = yield* all(s.custom)
return tools.map((t) => t.id)
})
const tools = Effect.fn("ToolRegistry.tools")(function* (
model: { providerID: ProviderID; modelID: ModelID },
agent?: Agent.Info,
) {
const s = yield* InstanceState.get(state)
const allTools = yield* all(s.custom)
const filtered = allTools.filter((tool) => {
if (tool.id === "codesearch" || tool.id === "websearch") {
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
}
const usePatch =
!!Env.get("OPENCODE_E2E_LLM_URL") ||
(model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4"))
if (tool.id === "apply_patch") return usePatch
if (tool.id === "edit" || tool.id === "write") return !usePatch
return true
})
return yield* Effect.forEach(
filtered,
Effect.fnUntraced(function* (tool: Tool.Info) {
using _ = log.time(tool.id)
const next = yield* Effect.promise(() => tool.init({ agent }))
const output = {
description: next.description,
parameters: next.parameters,
}
yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
return {
id: tool.id,
description: output.description,
parameters: output.parameters,
execute: next.execute,
formatValidationError: next.formatValidationError,
custom,
builtin: yield* Effect.forEach(
[
InvalidTool,
BashTool,
ReadTool,
GlobTool,
GrepTool,
EditTool,
WriteTool,
TaskTool,
WebFetchTool,
TodoWriteTool,
WebSearchTool,
CodeSearchTool,
SkillTool,
ApplyPatchTool,
...(question ? [QuestionTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
...(cfg.experimental?.batch_tool === true ? [BatchTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []),
],
build,
{ concurrency: "unbounded" },
),
}
}),
{ concurrency: "unbounded" },
)
})
return Service.of({ ids, named: { task, read }, tools })
}),
)
const all = Effect.fn("ToolRegistry.all")(function* () {
const s = yield* InstanceState.get(state)
return [...s.builtin, ...s.custom] as Tool.Def[]
})
const fromID = Effect.fn("ToolRegistry.fromID")(function* (id: string) {
const allTools = yield* all()
const match = allTools.find((tool) => tool.id === id)
if (!match) return yield* Effect.die(`Tool not found: ${id}`)
return match
})
const ids = Effect.fn("ToolRegistry.ids")(function* () {
return yield* all().pipe(Effect.map((t) => t.map((x) => x.id)))
})
const tools = Effect.fn("ToolRegistry.tools")(function* (model: { providerID: ProviderID; modelID: ModelID }) {
const allTools = yield* all()
const filtered = allTools.filter((tool) => {
if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) {
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
}
const usePatch =
!!Env.get("OPENCODE_E2E_LLM_URL") ||
(model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4"))
if (tool.id === "apply_patch") return usePatch
if (tool.id === "edit" || tool.id === "write") return !usePatch
return true
})
return yield* Effect.forEach(
filtered,
Effect.fnUntraced(function* (tool: Tool.Def) {
using _ = log.time(tool.id)
const output = {
description: tool.description,
parameters: tool.parameters,
}
yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
return {
id: tool.id,
description: output.description,
parameters: output.parameters,
execute: tool.execute,
formatValidationError: tool.formatValidationError,
}
}),
{ concurrency: "unbounded" },
)
})
return Service.of({ ids, tools, all, fromID })
}),
)
export const defaultLayer = Layer.unwrap(
Effect.sync(() =>
@@ -239,10 +207,6 @@ export namespace ToolRegistry {
Layer.provide(Plugin.defaultLayer),
Layer.provide(Question.defaultLayer),
Layer.provide(Todo.defaultLayer),
Layer.provide(LSP.defaultLayer),
Layer.provide(FileTime.defaultLayer),
Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
),
),
)
@@ -253,13 +217,10 @@ export namespace ToolRegistry {
return runPromise((svc) => svc.ids())
}
export async function tools(
model: {
providerID: ProviderID
modelID: ModelID
},
agent?: Agent.Info,
): Promise<(Tool.Def & { id: string })[]> {
return runPromise((svc) => svc.tools(model, agent))
export async function tools(model: {
providerID: ProviderID
modelID: ModelID
}): Promise<(Tool.Def & { id: string })[]> {
return runPromise((svc) => svc.tools(model))
}
}
+7 -8
View File
@@ -6,9 +6,12 @@ import { Skill } from "../skill"
import { Ripgrep } from "../file/ripgrep"
import { iife } from "@/util/iife"
export const SkillTool = Tool.define("skill", async (ctx) => {
const list = await Skill.available(ctx?.agent)
const Parameters = z.object({
name: z.string().describe("The name of the skill from available_skills"),
})
export const SkillTool = Tool.define("skill", async () => {
const list = await Skill.all()
const description =
list.length === 0
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
@@ -33,14 +36,10 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
.join(", ")
const hint = examples.length > 0 ? ` (e.g., ${examples}, ...)` : ""
const parameters = z.object({
name: z.string().describe(`The name of the skill from available_skills${hint}`),
})
return {
description,
parameters,
async execute(params: z.infer<typeof parameters>, ctx) {
parameters: Parameters,
async execute(params: z.infer<typeof Parameters>, ctx) {
const skill = await Skill.get(params.name)
if (!skill) {
+3 -12
View File
@@ -4,13 +4,11 @@ import z from "zod"
import { Session } from "../session"
import { SessionID, MessageID } from "../session/schema"
import { MessageV2 } from "../session/message-v2"
import { Identifier } from "../id/id"
import { Agent } from "../agent/agent"
import { SessionPrompt } from "../session/prompt"
import { iife } from "@/util/iife"
import { defer } from "@/util/defer"
import { Config } from "../config/config"
import { Permission } from "@/permission"
const parameters = z.object({
description: z.string().describe("A short (3-5 words) description of the task"),
@@ -25,19 +23,12 @@ const parameters = z.object({
command: z.string().describe("The command that triggered this task").optional(),
})
export const TaskTool = Tool.define("task", async (ctx) => {
export const TaskTool = Tool.define("task", async () => {
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
// Filter agents by permissions if agent provided
const caller = ctx?.agent
const accessibleAgents = caller
? agents.filter((a) => Permission.evaluate("task", a.name, caller.permission).action !== "deny")
: agents
const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
const description = DESCRIPTION.replace(
"{agents}",
list
agents
.toSorted((a, b) => a.name.localeCompare(b.name))
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
.join("\n"),
)
+1 -1
View File
@@ -43,6 +43,6 @@ export const TodoWriteTool = Tool.defineEffect<typeof parameters, Metadata, Todo
},
}
},
} satisfies Tool.Def<typeof parameters, Metadata>
} satisfies Tool.DefWithoutID<typeof parameters, Metadata>
}),
)
+24 -12
View File
@@ -1,20 +1,16 @@
import z from "zod"
import { Effect } from "effect"
import type { MessageV2 } from "../session/message-v2"
import type { Agent } from "../agent/agent"
import type { Permission } from "../permission"
import type { SessionID, MessageID } from "../session/schema"
import { Truncate } from "./truncate"
import { Agent } from "@/agent/agent"
export namespace Tool {
interface Metadata {
[key: string]: any
}
export interface InitContext {
agent?: Agent.Info
}
export type Context<M extends Metadata = Metadata> = {
sessionID: SessionID
messageID: MessageID
@@ -26,7 +22,9 @@ export namespace Tool {
metadata(input: { title?: string; metadata?: M }): void
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Promise<void>
}
export interface Def<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string
description: string
parameters: Parameters
execute(
@@ -40,10 +38,14 @@ export namespace Tool {
}>
formatValidationError?(error: z.ZodError): string
}
export type DefWithoutID<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> = Omit<
Def<Parameters, M>,
"id"
>
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string
init: (ctx?: InitContext) => Promise<Def<Parameters, M>>
init: () => Promise<DefWithoutID<Parameters, M>>
}
export type InferParameters<T> =
@@ -57,10 +59,10 @@ export namespace Tool {
function wrap<Parameters extends z.ZodType, Result extends Metadata>(
id: string,
init: ((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>,
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
) {
return async (initCtx?: InitContext) => {
const toolInfo = init instanceof Function ? await init(initCtx) : { ...init }
return async () => {
const toolInfo = init instanceof Function ? await init() : { ...init }
const execute = toolInfo.execute
toolInfo.execute = async (args, ctx) => {
try {
@@ -78,7 +80,7 @@ export namespace Tool {
if (result.metadata.truncated !== undefined) {
return result
}
const truncated = await Truncate.output(result.output, {}, initCtx?.agent)
const truncated = await Truncate.output(result.output, {}, await Agent.get(ctx.agent))
return {
...result,
output: truncated.content,
@@ -95,7 +97,7 @@ export namespace Tool {
export function define<Parameters extends z.ZodType, Result extends Metadata>(
id: string,
init: ((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>,
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
): Info<Parameters, Result> {
return {
id,
@@ -105,8 +107,18 @@ export namespace Tool {
export function defineEffect<Parameters extends z.ZodType, Result extends Metadata, R>(
id: string,
init: Effect.Effect<((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>, never, R>,
init: Effect.Effect<(() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>, never, R>,
): Effect.Effect<Info<Parameters, Result>, never, R> {
return Effect.map(init, (next) => ({ id, init: wrap(id, next) }))
}
export function init(info: Info): Effect.Effect<Def, never, any> {
return Effect.gen(function* () {
const init = yield* Effect.promise(() => info.init())
return {
...init,
id: info.id,
}
})
}
}
+20 -20
View File
@@ -11,6 +11,25 @@ const API_CONFIG = {
DEFAULT_NUM_RESULTS: 8,
} as const
const Parameters = z.object({
query: z.string().describe("Websearch query"),
numResults: z.number().optional().describe("Number of search results to return (default: 8)"),
livecrawl: z
.enum(["fallback", "preferred"])
.optional()
.describe(
"Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
),
type: z
.enum(["auto", "fast", "deep"])
.optional()
.describe("Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search"),
contextMaxCharacters: z
.number()
.optional()
.describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
})
interface McpSearchRequest {
jsonrpc: string
id: number
@@ -42,26 +61,7 @@ export const WebSearchTool = Tool.define("websearch", async () => {
get description() {
return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString())
},
parameters: z.object({
query: z.string().describe("Websearch query"),
numResults: z.number().optional().describe("Number of search results to return (default: 8)"),
livecrawl: z
.enum(["fallback", "preferred"])
.optional()
.describe(
"Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
),
type: z
.enum(["auto", "fast", "deep"])
.optional()
.describe(
"Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
),
contextMaxCharacters: z
.number()
.optional()
.describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
}),
parameters: Parameters,
async execute(params, ctx) {
await ctx.ask({
permission: "websearch",
@@ -25,7 +25,6 @@ import { Npm } from "../../src/npm"
const emptyAccount = Layer.mock(Account.Service)({
active: () => Effect.succeed(Option.none()),
activeOrg: () => Effect.succeed(Option.none()),
})
const emptyAuth = Layer.mock(Auth.Service)({
@@ -283,21 +282,6 @@ test("resolves env templates in account config with account token", async () =>
active_org_id: OrgID.make("org-1"),
}),
),
activeOrg: () =>
Effect.succeed(
Option.some({
account: {
id: AccountID.make("account-1"),
email: "user@example.com",
url: "https://control.example.com",
active_org_id: OrgID.make("org-1"),
},
org: {
id: OrgID.make("org-1"),
name: "Example Org",
},
}),
),
config: () =>
Effect.succeed(
Option.some({
-18
View File
@@ -1,18 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Npm } from "../src/npm"
const win = process.platform === "win32"
describe("Npm.sanitize", () => {
test("keeps normal scoped package specs unchanged", () => {
expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
expect(Npm.sanitize("@opencode/acme@1.0.0")).toBe("@opencode/acme@1.0.0")
expect(Npm.sanitize("prettier")).toBe("prettier")
})
test("handles git https specs", () => {
const spec = "acme@git+https://github.com/opencode/acme.git"
const expected = win ? "acme@git+https_//github.com/opencode/acme.git" : spec
expect(Npm.sanitize(spec)).toBe(expected)
})
})
@@ -1,88 +0,0 @@
import { describe, expect, test } from "bun:test"
import { parsePluginSpecifier } from "../../src/plugin/shared"
describe("parsePluginSpecifier", () => {
test("parses standard npm package without version", () => {
expect(parsePluginSpecifier("acme")).toEqual({
pkg: "acme",
version: "latest",
})
})
test("parses standard npm package with version", () => {
expect(parsePluginSpecifier("acme@1.0.0")).toEqual({
pkg: "acme",
version: "1.0.0",
})
})
test("parses scoped npm package without version", () => {
expect(parsePluginSpecifier("@opencode/acme")).toEqual({
pkg: "@opencode/acme",
version: "latest",
})
})
test("parses scoped npm package with version", () => {
expect(parsePluginSpecifier("@opencode/acme@1.0.0")).toEqual({
pkg: "@opencode/acme",
version: "1.0.0",
})
})
test("parses package with git+https url", () => {
expect(parsePluginSpecifier("acme@git+https://github.com/opencode/acme.git")).toEqual({
pkg: "acme",
version: "git+https://github.com/opencode/acme.git",
})
})
test("parses scoped package with git+https url", () => {
expect(parsePluginSpecifier("@opencode/acme@git+https://github.com/opencode/acme.git")).toEqual({
pkg: "@opencode/acme",
version: "git+https://github.com/opencode/acme.git",
})
})
test("parses package with git+ssh url containing another @", () => {
expect(parsePluginSpecifier("acme@git+ssh://git@github.com/opencode/acme.git")).toEqual({
pkg: "acme",
version: "git+ssh://git@github.com/opencode/acme.git",
})
})
test("parses scoped package with git+ssh url containing another @", () => {
expect(parsePluginSpecifier("@opencode/acme@git+ssh://git@github.com/opencode/acme.git")).toEqual({
pkg: "@opencode/acme",
version: "git+ssh://git@github.com/opencode/acme.git",
})
})
test("parses unaliased git+ssh url", () => {
expect(parsePluginSpecifier("git+ssh://git@github.com/opencode/acme.git")).toEqual({
pkg: "git+ssh://git@github.com/opencode/acme.git",
version: "",
})
})
test("parses npm alias using the alias name", () => {
expect(parsePluginSpecifier("acme@npm:@opencode/acme@1.0.0")).toEqual({
pkg: "acme",
version: "npm:@opencode/acme@1.0.0",
})
})
test("parses bare npm protocol specifier using the target package", () => {
expect(parsePluginSpecifier("npm:@opencode/acme@1.0.0")).toEqual({
pkg: "@opencode/acme",
version: "1.0.0",
})
})
test("parses unversioned npm protocol specifier", () => {
expect(parsePluginSpecifier("npm:@opencode/acme")).toEqual({
pkg: "@opencode/acme",
version: "latest",
})
})
})
@@ -1080,7 +1080,7 @@ describe("session.getUsage", () => {
expect(result.tokens.cache.read).toBe(200)
})
test("separates reasoning tokens from output tokens", () => {
test("handles reasoning tokens", () => {
const model = createModel({ context: 100_000, output: 32_000 })
const result = Session.getUsage({
model,
@@ -1092,35 +1092,7 @@ describe("session.getUsage", () => {
},
})
expect(result.tokens.input).toBe(1000)
expect(result.tokens.output).toBe(400)
expect(result.tokens.reasoning).toBe(100)
expect(result.tokens.total).toBe(1500)
})
test("does not double count reasoning tokens in cost", () => {
const model = createModel({
context: 100_000,
output: 32_000,
cost: {
input: 0,
output: 15,
cache: { read: 0, write: 0 },
},
})
const result = Session.getUsage({
model,
usage: {
inputTokens: 0,
outputTokens: 1_000_000,
totalTokens: 1_000_000,
reasoningTokens: 250_000,
},
})
expect(result.tokens.output).toBe(750_000)
expect(result.tokens.reasoning).toBe(250_000)
expect(result.cost).toBe(15)
})
test("handles undefined optional values gracefully", () => {
+467 -389
View File
@@ -1,20 +1,12 @@
import { afterEach, describe, expect } from "bun:test"
import { Cause, Effect, Exit, Layer } from "effect"
import { afterEach, describe, expect, test } from "bun:test"
import path from "path"
import { Agent } from "../../src/agent/agent"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { AppFileSystem } from "../../src/filesystem"
import { FileTime } from "../../src/file/time"
import { LSP } from "../../src/lsp"
import { Permission } from "../../src/permission"
import { Instance } from "../../src/project/instance"
import { SessionID, MessageID } from "../../src/session/schema"
import { Instruction } from "../../src/session/instruction"
import { ReadTool } from "../../src/tool/read"
import { Tool } from "../../src/tool/tool"
import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { tmpdir } from "../fixture/fixture"
import { Permission } from "../../src/permission"
import { Agent } from "../../src/agent/agent"
import { SessionID, MessageID } from "../../src/session/schema"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
@@ -33,171 +25,173 @@ const ctx = {
ask: async () => {},
}
const it = testEffect(
Layer.mergeAll(
Agent.defaultLayer,
AppFileSystem.defaultLayer,
CrossSpawnSpawner.defaultLayer,
FileTime.defaultLayer,
Instruction.defaultLayer,
LSP.defaultLayer,
),
)
const init = Effect.fn("ReadToolTest.init")(function* () {
const info = yield* ReadTool
return yield* Effect.promise(() => info.init())
})
const run = Effect.fn("ReadToolTest.run")(function* (
args: Tool.InferParameters<typeof ReadTool>,
next: Tool.Context = ctx,
) {
const tool = yield* init()
return yield* Effect.promise(() => tool.execute(args, next))
})
const exec = Effect.fn("ReadToolTest.exec")(function* (
dir: string,
args: Tool.InferParameters<typeof ReadTool>,
next: Tool.Context = ctx,
) {
return yield* provideInstance(dir)(run(args, next))
})
const fail = Effect.fn("ReadToolTest.fail")(function* (
dir: string,
args: Tool.InferParameters<typeof ReadTool>,
next: Tool.Context = ctx,
) {
const exit = yield* exec(dir, args, next).pipe(Effect.exit)
if (Exit.isFailure(exit)) {
const err = Cause.squash(exit.cause)
return err instanceof Error ? err : new Error(String(err))
}
throw new Error("expected read to fail")
})
const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p)
const glob = (p: string) =>
process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/")
const put = Effect.fn("ReadToolTest.put")(function* (p: string, content: string | Buffer | Uint8Array) {
const fs = yield* AppFileSystem.Service
yield* fs.writeWithDirs(p, content)
})
const load = Effect.fn("ReadToolTest.load")(function* (p: string) {
const fs = yield* AppFileSystem.Service
return yield* fs.readFileString(p)
})
const asks = () => {
const items: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
return {
items,
next: {
...ctx,
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
items.push(req)
},
},
}
}
describe("tool.read external_directory permission", () => {
it.live("allows reading absolute path inside project directory", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* put(path.join(dir, "test.txt"), "hello world")
test("allows reading absolute path inside project directory", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "test.txt"), "hello world")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "test.txt") }, ctx)
expect(result.output).toContain("hello world")
},
})
})
const result = yield* exec(dir, { filePath: path.join(dir, "test.txt") })
expect(result.output).toContain("hello world")
}),
)
test("allows reading file in subdirectory inside project directory", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "subdir", "test.txt"), "nested content")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "subdir", "test.txt") }, ctx)
expect(result.output).toContain("nested content")
},
})
})
it.live("allows reading file in subdirectory inside project directory", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* put(path.join(dir, "subdir", "test.txt"), "nested content")
const result = yield* exec(dir, { filePath: path.join(dir, "subdir", "test.txt") })
expect(result.output).toContain("nested content")
}),
)
it.live("asks for external_directory permission when reading absolute path outside project", () =>
Effect.gen(function* () {
const outer = yield* tmpdirScoped()
const dir = yield* tmpdirScoped({ git: true })
yield* put(path.join(outer, "secret.txt"), "secret data")
const { items, next } = asks()
yield* exec(dir, { filePath: path.join(outer, "secret.txt") }, next)
const ext = items.find((item) => item.permission === "external_directory")
expect(ext).toBeDefined()
expect(ext!.patterns).toContain(glob(path.join(outer, "*")))
}),
)
test("asks for external_directory permission when reading absolute path outside project", async () => {
await using outerTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "secret.txt"), "secret data")
},
})
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
await read.execute({ filePath: path.join(outerTmp.path, "secret.txt") }, testCtx)
const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeDefined()
expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp.path, "*")))
},
})
})
if (process.platform === "win32") {
it.live("normalizes read permission paths on Windows", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
yield* put(path.join(dir, "test.txt"), "hello world")
const { items, next } = asks()
const target = path.join(dir, "test.txt")
const alt = target
.replace(/^[A-Za-z]:/, "")
.replaceAll("\\", "/")
.toLowerCase()
yield* exec(dir, { filePath: alt }, next)
const read = items.find((item) => item.permission === "read")
expect(read).toBeDefined()
expect(read!.patterns).toEqual([full(target)])
}),
)
test("normalizes read permission paths on Windows", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "test.txt"), "hello world")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
const target = path.join(tmp.path, "test.txt")
const alt = target
.replace(/^[A-Za-z]:/, "")
.replaceAll("\\", "/")
.toLowerCase()
await read.execute({ filePath: alt }, testCtx)
const readReq = requests.find((r) => r.permission === "read")
expect(readReq).toBeDefined()
expect(readReq!.patterns).toEqual([full(target)])
},
})
})
}
it.live("asks for directory-scoped external_directory permission when reading external directory", () =>
Effect.gen(function* () {
const outer = yield* tmpdirScoped()
const dir = yield* tmpdirScoped({ git: true })
yield* put(path.join(outer, "external", "a.txt"), "a")
test("asks for directory-scoped external_directory permission when reading external directory", async () => {
await using outerTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "external", "a.txt"), "a")
},
})
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
await read.execute({ filePath: path.join(outerTmp.path, "external") }, testCtx)
const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeDefined()
expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp.path, "external", "*")))
},
})
})
const { items, next } = asks()
test("asks for external_directory permission when reading relative path outside project", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
// This will fail because file doesn't exist, but we can check if permission was asked
await read.execute({ filePath: "../outside.txt" }, testCtx).catch(() => {})
const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeDefined()
},
})
})
yield* exec(dir, { filePath: path.join(outer, "external") }, next)
const ext = items.find((item) => item.permission === "external_directory")
expect(ext).toBeDefined()
expect(ext!.patterns).toContain(glob(path.join(outer, "external", "*")))
}),
)
it.live("asks for external_directory permission when reading relative path outside project", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const { items, next } = asks()
yield* fail(dir, { filePath: "../outside.txt" }, next)
const ext = items.find((item) => item.permission === "external_directory")
expect(ext).toBeDefined()
}),
)
it.live("does not ask for external_directory permission when reading inside project", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
yield* put(path.join(dir, "internal.txt"), "internal content")
const { items, next } = asks()
yield* exec(dir, { filePath: path.join(dir, "internal.txt") }, next)
const ext = items.find((item) => item.permission === "external_directory")
expect(ext).toBeUndefined()
}),
)
test("does not ask for external_directory permission when reading inside project", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "internal.txt"), "internal content")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
await read.execute({ filePath: path.join(tmp.path, "internal.txt") }, testCtx)
const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeUndefined()
},
})
})
})
describe("tool.read env file permissions", () => {
@@ -211,204 +205,261 @@ describe("tool.read env file permissions", () => {
["environment.ts", false],
]
for (const agentName of ["build", "plan"] as const) {
describe(`agent=${agentName}`, () => {
for (const [filename, shouldAsk] of cases) {
it.live(`${filename} asks=${shouldAsk}`, () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* put(path.join(dir, filename), "content")
const asked = yield* provideInstance(dir)(
Effect.gen(function* () {
const agent = yield* Agent.Service
const info = yield* agent.get(agentName)
let asked = false
const next = {
...ctx,
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
for (const pattern of req.patterns) {
const rule = Permission.evaluate(req.permission, pattern, info.permission)
if (rule.action === "ask" && req.permission === "read") {
asked = true
}
if (rule.action === "deny") {
throw new Permission.DeniedError({ ruleset: info.permission })
}
}
},
describe.each(["build", "plan"])("agent=%s", (agentName) => {
test.each(cases)("%s asks=%s", async (filename, shouldAsk) => {
await using tmp = await tmpdir({
init: (dir) => Bun.write(path.join(dir, filename), "content"),
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const agent = await Agent.get(agentName)
let askedForEnv = false
const ctxWithPermissions = {
...ctx,
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
for (const pattern of req.patterns) {
const rule = Permission.evaluate(req.permission, pattern, agent.permission)
if (rule.action === "ask" && req.permission === "read") {
askedForEnv = true
}
yield* run({ filePath: path.join(dir, filename) }, next)
return asked
}),
)
expect(asked).toBe(shouldAsk)
}),
)
}
if (rule.action === "deny") {
throw new Permission.DeniedError({ ruleset: agent.permission })
}
}
},
}
const read = await ReadTool.init()
await read.execute({ filePath: path.join(tmp.path, filename) }, ctxWithPermissions)
expect(askedForEnv).toBe(shouldAsk)
},
})
})
}
})
})
describe("tool.read truncation", () => {
it.live("truncates large file by bytes and sets truncated metadata", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const base = yield* load(path.join(FIXTURES_DIR, "models-api.json"))
const target = 60 * 1024
const content = base.length >= target ? base : base.repeat(Math.ceil(target / base.length))
yield* put(path.join(dir, "large.json"), content)
test("truncates large file by bytes and sets truncated metadata", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const base = await Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json"))
const target = 60 * 1024
const content = base.length >= target ? base : base.repeat(Math.ceil(target / base.length))
await Filesystem.write(path.join(dir, "large.json"), content)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "large.json") }, ctx)
expect(result.metadata.truncated).toBe(true)
expect(result.output).toContain("Output capped at")
expect(result.output).toContain("Use offset=")
},
})
})
const result = yield* exec(dir, { filePath: path.join(dir, "large.json") })
expect(result.metadata.truncated).toBe(true)
expect(result.output).toContain("Output capped at")
expect(result.output).toContain("Use offset=")
}),
)
test("truncates by line count when limit is specified", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
await Bun.write(path.join(dir, "many-lines.txt"), lines)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "many-lines.txt"), limit: 10 }, ctx)
expect(result.metadata.truncated).toBe(true)
expect(result.output).toContain("Showing lines 1-10 of 100")
expect(result.output).toContain("Use offset=11")
expect(result.output).toContain("line0")
expect(result.output).toContain("line9")
expect(result.output).not.toContain("line10")
},
})
})
it.live("truncates by line count when limit is specified", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
yield* put(path.join(dir, "many-lines.txt"), lines)
test("does not truncate small file", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "small.txt"), "hello world")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "small.txt") }, ctx)
expect(result.metadata.truncated).toBe(false)
expect(result.output).toContain("End of file")
},
})
})
const result = yield* exec(dir, { filePath: path.join(dir, "many-lines.txt"), limit: 10 })
expect(result.metadata.truncated).toBe(true)
expect(result.output).toContain("Showing lines 1-10 of 100")
expect(result.output).toContain("Use offset=11")
expect(result.output).toContain("line0")
expect(result.output).toContain("line9")
expect(result.output).not.toContain("line10")
}),
)
test("respects offset parameter", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const lines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`).join("\n")
await Bun.write(path.join(dir, "offset.txt"), lines)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "offset.txt"), offset: 10, limit: 5 }, ctx)
expect(result.output).toContain("10: line10")
expect(result.output).toContain("14: line14")
expect(result.output).not.toContain("9: line10")
expect(result.output).not.toContain("15: line15")
expect(result.output).toContain("line10")
expect(result.output).toContain("line14")
expect(result.output).not.toContain("line0")
expect(result.output).not.toContain("line15")
},
})
})
it.live("does not truncate small file", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* put(path.join(dir, "small.txt"), "hello world")
test("throws when offset is beyond end of file", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const lines = Array.from({ length: 3 }, (_, i) => `line${i + 1}`).join("\n")
await Bun.write(path.join(dir, "short.txt"), lines)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
await expect(
read.execute({ filePath: path.join(tmp.path, "short.txt"), offset: 4, limit: 5 }, ctx),
).rejects.toThrow("Offset 4 is out of range for this file (3 lines)")
},
})
})
const result = yield* exec(dir, { filePath: path.join(dir, "small.txt") })
expect(result.metadata.truncated).toBe(false)
expect(result.output).toContain("End of file")
}),
)
test("allows reading empty file at default offset", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "empty.txt"), "")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "empty.txt") }, ctx)
expect(result.metadata.truncated).toBe(false)
expect(result.output).toContain("End of file - total 0 lines")
},
})
})
it.live("respects offset parameter", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const lines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`).join("\n")
yield* put(path.join(dir, "offset.txt"), lines)
test("throws when offset > 1 for empty file", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "empty.txt"), "")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
await expect(read.execute({ filePath: path.join(tmp.path, "empty.txt"), offset: 2 }, ctx)).rejects.toThrow(
"Offset 2 is out of range for this file (0 lines)",
)
},
})
})
const result = yield* exec(dir, { filePath: path.join(dir, "offset.txt"), offset: 10, limit: 5 })
expect(result.output).toContain("10: line10")
expect(result.output).toContain("14: line14")
expect(result.output).not.toContain("9: line10")
expect(result.output).not.toContain("15: line15")
expect(result.output).toContain("line10")
expect(result.output).toContain("line14")
expect(result.output).not.toContain("line0")
expect(result.output).not.toContain("line15")
}),
)
test("does not mark final directory page as truncated", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Promise.all(
Array.from({ length: 10 }, (_, i) => Bun.write(path.join(dir, "dir", `file-${i + 1}.txt`), `line${i}`)),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "dir"), offset: 6, limit: 5 }, ctx)
expect(result.metadata.truncated).toBe(false)
expect(result.output).not.toContain("Showing 5 of 10 entries")
},
})
})
it.live("throws when offset is beyond end of file", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const lines = Array.from({ length: 3 }, (_, i) => `line${i + 1}`).join("\n")
yield* put(path.join(dir, "short.txt"), lines)
test("truncates long lines", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const longLine = "x".repeat(3000)
await Bun.write(path.join(dir, "long-line.txt"), longLine)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "long-line.txt") }, ctx)
expect(result.output).toContain("(line truncated to 2000 chars)")
expect(result.output.length).toBeLessThan(3000)
},
})
})
const err = yield* fail(dir, { filePath: path.join(dir, "short.txt"), offset: 4, limit: 5 })
expect(err.message).toContain("Offset 4 is out of range for this file (3 lines)")
}),
)
test("image files set truncated to false", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
// 1x1 red PNG
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
"base64",
)
await Bun.write(path.join(dir, "image.png"), png)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "image.png") }, ctx)
expect(result.metadata.truncated).toBe(false)
expect(result.attachments).toBeDefined()
expect(result.attachments?.length).toBe(1)
expect(result.attachments?.[0]).not.toHaveProperty("id")
expect(result.attachments?.[0]).not.toHaveProperty("sessionID")
expect(result.attachments?.[0]).not.toHaveProperty("messageID")
},
})
})
it.live("allows reading empty file at default offset", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* put(path.join(dir, "empty.txt"), "")
test("large image files are properly attached without error", async () => {
await Instance.provide({
directory: FIXTURES_DIR,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(FIXTURES_DIR, "large-image.png") }, ctx)
expect(result.metadata.truncated).toBe(false)
expect(result.attachments).toBeDefined()
expect(result.attachments?.length).toBe(1)
expect(result.attachments?.[0].type).toBe("file")
expect(result.attachments?.[0]).not.toHaveProperty("id")
expect(result.attachments?.[0]).not.toHaveProperty("sessionID")
expect(result.attachments?.[0]).not.toHaveProperty("messageID")
},
})
})
const result = yield* exec(dir, { filePath: path.join(dir, "empty.txt") })
expect(result.metadata.truncated).toBe(false)
expect(result.output).toContain("End of file - total 0 lines")
}),
)
it.live("throws when offset > 1 for empty file", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* put(path.join(dir, "empty.txt"), "")
const err = yield* fail(dir, { filePath: path.join(dir, "empty.txt"), offset: 2 })
expect(err.message).toContain("Offset 2 is out of range for this file (0 lines)")
}),
)
it.live("does not mark final directory page as truncated", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* Effect.forEach(
Array.from({ length: 10 }, (_, i) => i),
(i) => put(path.join(dir, "dir", `file-${i + 1}.txt`), `line${i}`),
{
concurrency: "unbounded",
},
)
const result = yield* exec(dir, { filePath: path.join(dir, "dir"), offset: 6, limit: 5 })
expect(result.metadata.truncated).toBe(false)
expect(result.output).not.toContain("Showing 5 of 10 entries")
}),
)
it.live("truncates long lines", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* put(path.join(dir, "long-line.txt"), "x".repeat(3000))
const result = yield* exec(dir, { filePath: path.join(dir, "long-line.txt") })
expect(result.output).toContain("(line truncated to 2000 chars)")
expect(result.output.length).toBeLessThan(3000)
}),
)
it.live("image files set truncated to false", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
"base64",
)
yield* put(path.join(dir, "image.png"), png)
const result = yield* exec(dir, { filePath: path.join(dir, "image.png") })
expect(result.metadata.truncated).toBe(false)
expect(result.attachments).toBeDefined()
expect(result.attachments?.length).toBe(1)
expect(result.attachments?.[0]).not.toHaveProperty("id")
expect(result.attachments?.[0]).not.toHaveProperty("sessionID")
expect(result.attachments?.[0]).not.toHaveProperty("messageID")
}),
)
it.live("large image files are properly attached without error", () =>
Effect.gen(function* () {
const result = yield* exec(FIXTURES_DIR, { filePath: path.join(FIXTURES_DIR, "large-image.png") })
expect(result.metadata.truncated).toBe(false)
expect(result.attachments).toBeDefined()
expect(result.attachments?.length).toBe(1)
expect(result.attachments?.[0].type).toBe("file")
expect(result.attachments?.[0]).not.toHaveProperty("id")
expect(result.attachments?.[0]).not.toHaveProperty("sessionID")
expect(result.attachments?.[0]).not.toHaveProperty("messageID")
}),
)
it.live(".fbs files (FlatBuffers schema) are read as text, not images", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const fbs = `namespace MyGame;
test(".fbs files (FlatBuffers schema) are read as text, not images", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
// FlatBuffers schema content
const fbsContent = `namespace MyGame;
table Monster {
pos:Vec3;
@@ -417,52 +468,79 @@ table Monster {
}
root_type Monster;`
yield* put(path.join(dir, "schema.fbs"), fbs)
const result = yield* exec(dir, { filePath: path.join(dir, "schema.fbs") })
expect(result.attachments).toBeUndefined()
expect(result.output).toContain("namespace MyGame")
expect(result.output).toContain("table Monster")
}),
)
await Bun.write(path.join(dir, "schema.fbs"), fbsContent)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "schema.fbs") }, ctx)
// Should be read as text, not as image
expect(result.attachments).toBeUndefined()
expect(result.output).toContain("namespace MyGame")
expect(result.output).toContain("table Monster")
},
})
})
})
describe("tool.read loaded instructions", () => {
it.live("loads AGENTS.md from parent directory and includes in metadata", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* put(path.join(dir, "subdir", "AGENTS.md"), "# Test Instructions\nDo something special.")
yield* put(path.join(dir, "subdir", "nested", "test.txt"), "test content")
const result = yield* exec(dir, { filePath: path.join(dir, "subdir", "nested", "test.txt") })
expect(result.output).toContain("test content")
expect(result.output).toContain("system-reminder")
expect(result.output).toContain("Test Instructions")
expect(result.metadata.loaded).toBeDefined()
expect(result.metadata.loaded).toContain(path.join(dir, "subdir", "AGENTS.md"))
}),
)
test("loads AGENTS.md from parent directory and includes in metadata", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Test Instructions\nDo something special.")
await Bun.write(path.join(dir, "subdir", "nested", "test.txt"), "test content")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "subdir", "nested", "test.txt") }, ctx)
expect(result.output).toContain("test content")
expect(result.output).toContain("system-reminder")
expect(result.output).toContain("Test Instructions")
expect(result.metadata.loaded).toBeDefined()
expect(result.metadata.loaded).toContain(path.join(tmp.path, "subdir", "AGENTS.md"))
},
})
})
})
describe("tool.read binary detection", () => {
it.live("rejects text extension files with null bytes", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const bytes = Buffer.from([0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x77, 0x6f, 0x72, 0x6c, 0x64])
yield* put(path.join(dir, "null-byte.txt"), bytes)
test("rejects text extension files with null bytes", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const bytes = Buffer.from([0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x77, 0x6f, 0x72, 0x6c, 0x64])
await Bun.write(path.join(dir, "null-byte.txt"), bytes)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
await expect(read.execute({ filePath: path.join(tmp.path, "null-byte.txt") }, ctx)).rejects.toThrow(
"Cannot read binary file",
)
},
})
})
const err = yield* fail(dir, { filePath: path.join(dir, "null-byte.txt") })
expect(err.message).toContain("Cannot read binary file")
}),
)
it.live("rejects known binary extensions", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* put(path.join(dir, "module.wasm"), "not really wasm")
const err = yield* fail(dir, { filePath: path.join(dir, "module.wasm") })
expect(err.message).toContain("Cannot read binary file")
}),
)
test("rejects known binary extensions", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "module.wasm"), "not really wasm")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
await expect(read.execute({ filePath: path.join(tmp.path, "module.wasm") }, ctx)).rejects.toThrow(
"Cannot read binary file",
)
},
})
})
})
+2 -3
View File
@@ -28,9 +28,8 @@ describe("tool.task", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
const first = await TaskTool.init({ agent: build })
const second = await TaskTool.init({ agent: build })
const first = await TaskTool.init()
const second = await TaskTool.init()
expect(first.description).toBe(second.description)
@@ -3,7 +3,6 @@ import z from "zod"
import { Tool } from "../../src/tool/tool"
const params = z.object({ input: z.string() })
const defaultArgs = { input: "test" }
function makeTool(id: string, executeFn?: () => void) {
return {
@@ -30,36 +29,6 @@ describe("Tool.define", () => {
expect(original.execute).toBe(originalExecute)
})
test("object-defined tool does not accumulate wrapper layers across init() calls", async () => {
let calls = 0
const tool = Tool.define(
"test-tool",
makeTool("test", () => calls++),
)
for (let i = 0; i < 100; i++) {
await tool.init()
}
const resolved = await tool.init()
calls = 0
let stack = ""
const exec = resolved.execute
resolved.execute = async (args: any, ctx: any) => {
const result = await exec.call(resolved, args, ctx)
stack = new Error().stack || ""
return result
}
await resolved.execute(defaultArgs, {} as any)
expect(calls).toBe(1)
const frames = stack.split("\n").filter((l) => l.includes("tool.ts")).length
expect(frames).toBeLessThan(5)
})
test("function-defined tool returns fresh objects and is unaffected", async () => {
const tool = Tool.define("test-fn-tool", () => Promise.resolve(makeTool("test")))
@@ -77,25 +46,4 @@ describe("Tool.define", () => {
expect(first).not.toBe(second)
})
test("validation still works after many init() calls", async () => {
const tool = Tool.define("test-validation", {
description: "validation test",
parameters: z.object({ count: z.number().int().positive() }),
async execute(args) {
return { title: "test", output: String(args.count), metadata: {} }
},
})
for (let i = 0; i < 100; i++) {
await tool.init()
}
const resolved = await tool.init()
const result = await resolved.execute({ count: 42 }, {} as any)
expect(result.output).toBe("42")
await expect(resolved.execute({ count: -1 }, {} as any)).rejects.toThrow("invalid arguments")
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.3.15",
"version": "1.3.13",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.3.15",
"version": "1.3.13",
"type": "module",
"license": "MIT",
"scripts": {
+2 -1
View File
@@ -77,5 +77,6 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp
workspace: config?.experimental_workspaceID,
}),
)
return new OpencodeClient({ client })
const result = new OpencodeClient({ client })
return result
}
+32
View File
@@ -0,0 +1,32 @@
import type { Part, UserMessage } from "./client.js"
export const message = {
user(input: Omit<UserMessage, "role" | "time" | "id"> & { parts: Omit<Part, "id" | "sessionID" | "messageID">[] }): {
info: UserMessage
parts: Part[]
} {
const { parts, ...rest } = input
const info: UserMessage = {
...rest,
id: "asdasd",
time: {
created: Date.now(),
},
role: "user",
}
return {
info,
parts: input.parts.map(
(part) =>
({
...part,
id: "asdasd",
messageID: info.id,
sessionID: info.sessionID,
}) as Part,
),
}
},
}
+21 -130
View File
@@ -24,9 +24,6 @@ import type {
EventTuiPromptAppend,
EventTuiSessionSelect,
EventTuiToastShow,
ExperimentalConsoleGetResponses,
ExperimentalConsoleListOrgsResponses,
ExperimentalConsoleSwitchOrgResponses,
ExperimentalResourceListResponses,
ExperimentalSessionListResponses,
ExperimentalWorkspaceCreateErrors,
@@ -984,13 +981,13 @@ export class Config2 extends HeyApiClient {
}
}
export class Console extends HeyApiClient {
export class Tool extends HeyApiClient {
/**
* Get active Console provider metadata
* List tool IDs
*
* Get the active Console org name and the set of provider IDs managed by that Console org.
* Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.
*/
public get<ThrowOnError extends boolean = false>(
public ids<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
@@ -1008,22 +1005,24 @@ export class Console extends HeyApiClient {
},
],
)
return (options?.client ?? this.client).get<ExperimentalConsoleGetResponses, unknown, ThrowOnError>({
url: "/experimental/console",
return (options?.client ?? this.client).get<ToolIdsResponses, ToolIdsErrors, ThrowOnError>({
url: "/experimental/tool/ids",
...options,
...params,
})
}
/**
* List switchable Console orgs
* List tools
*
* Get the available Console orgs across logged-in accounts, including the current active org.
* Get a list of available tools with their JSON schema parameters for a specific provider and model combination.
*/
public listOrgs<ThrowOnError extends boolean = false>(
parameters?: {
public list<ThrowOnError extends boolean = false>(
parameters: {
directory?: string
workspace?: string
provider: string
model: string
},
options?: Options<never, ThrowOnError>,
) {
@@ -1034,55 +1033,18 @@ export class Console extends HeyApiClient {
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "query", key: "provider" },
{ in: "query", key: "model" },
],
},
],
)
return (options?.client ?? this.client).get<ExperimentalConsoleListOrgsResponses, unknown, ThrowOnError>({
url: "/experimental/console/orgs",
return (options?.client ?? this.client).get<ToolListResponses, ToolListErrors, ThrowOnError>({
url: "/experimental/tool",
...options,
...params,
})
}
/**
* Switch active Console org
*
* Persist a new active Console account/org selection for the current local OpenCode state.
*/
public switchOrg<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
accountID?: string
orgID?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "body", key: "accountID" },
{ in: "body", key: "orgID" },
],
},
],
)
return (options?.client ?? this.client).post<ExperimentalConsoleSwitchOrgResponses, unknown, ThrowOnError>({
url: "/experimental/console/switch",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
}
export class Workspace extends HeyApiClient {
@@ -1277,11 +1239,6 @@ export class Resource extends HeyApiClient {
}
export class Experimental extends HeyApiClient {
private _console?: Console
get console(): Console {
return (this._console ??= new Console({ client: this.client }))
}
private _workspace?: Workspace
get workspace(): Workspace {
return (this._workspace ??= new Workspace({ client: this.client }))
@@ -1298,72 +1255,6 @@ export class Experimental extends HeyApiClient {
}
}
export class Tool extends HeyApiClient {
/**
* List tool IDs
*
* Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.
*/
public ids<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<ToolIdsResponses, ToolIdsErrors, ThrowOnError>({
url: "/experimental/tool/ids",
...options,
...params,
})
}
/**
* List tools
*
* Get a list of available tools with their JSON schema parameters for a specific provider and model combination.
*/
public list<ThrowOnError extends boolean = false>(
parameters: {
directory?: string
workspace?: string
provider: string
model: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "query", key: "provider" },
{ in: "query", key: "model" },
],
},
],
)
return (options?.client ?? this.client).get<ToolListResponses, ToolListErrors, ThrowOnError>({
url: "/experimental/tool",
...options,
...params,
})
}
}
export class Worktree extends HeyApiClient {
/**
* Remove worktree
@@ -4126,16 +4017,16 @@ export class OpencodeClient extends HeyApiClient {
return (this._config ??= new Config2({ client: this.client }))
}
private _experimental?: Experimental
get experimental(): Experimental {
return (this._experimental ??= new Experimental({ client: this.client }))
}
private _tool?: Tool
get tool(): Tool {
return (this._tool ??= new Tool({ client: this.client }))
}
private _experimental?: Experimental
get experimental(): Experimental {
return (this._experimental ??= new Experimental({ client: this.client }))
}
private _worktree?: Worktree
get worktree(): Worktree {
return (this._worktree ??= new Worktree({ client: this.client }))
-76
View File
@@ -126,7 +126,6 @@ export type EventPermissionReplied = {
export type SessionStatus =
| {
type: "idle"
suggestion?: string
}
| {
type: "retry"
@@ -2654,81 +2653,6 @@ export type ConfigProvidersResponses = {
export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses]
export type ExperimentalConsoleGetData = {
body?: never
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/experimental/console"
}
export type ExperimentalConsoleGetResponses = {
/**
* Active Console provider metadata
*/
200: {
consoleManagedProviders: Array<string>
activeOrgName?: string
switchableOrgCount: number
}
}
export type ExperimentalConsoleGetResponse = ExperimentalConsoleGetResponses[keyof ExperimentalConsoleGetResponses]
export type ExperimentalConsoleListOrgsData = {
body?: never
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/experimental/console/orgs"
}
export type ExperimentalConsoleListOrgsResponses = {
/**
* Switchable Console orgs
*/
200: {
orgs: Array<{
accountID: string
accountEmail: string
accountUrl: string
orgID: string
orgName: string
active: boolean
}>
}
}
export type ExperimentalConsoleListOrgsResponse =
ExperimentalConsoleListOrgsResponses[keyof ExperimentalConsoleListOrgsResponses]
export type ExperimentalConsoleSwitchOrgData = {
body?: {
accountID: string
orgID: string
}
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/experimental/console/switch"
}
export type ExperimentalConsoleSwitchOrgResponses = {
/**
* Switch success
*/
200: boolean
}
export type ExperimentalConsoleSwitchOrgResponse =
ExperimentalConsoleSwitchOrgResponses[keyof ExperimentalConsoleSwitchOrgResponses]
export type ToolIdsData = {
body?: never
path?: never
+3
View File
@@ -5,6 +5,9 @@ import { createOpencodeClient } from "./client.js"
import { createOpencodeServer } from "./server.js"
import type { ServerOptions } from "./server.js"
export * as data from "./data.js"
import * as data from "./data.js"
export async function createOpencode(options?: ServerOptions) {
const server = await createOpencodeServer({
...options,
-188
View File
@@ -1220,194 +1220,6 @@
]
}
},
"/experimental/console": {
"get": {
"operationId": "experimental.console.get",
"parameters": [
{
"in": "query",
"name": "directory",
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "workspace",
"schema": {
"type": "string"
}
}
],
"summary": "Get active Console provider metadata",
"description": "Get the active Console org name and the set of provider IDs managed by that Console org.",
"responses": {
"200": {
"description": "Active Console provider metadata",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"consoleManagedProviders": {
"type": "array",
"items": {
"type": "string"
}
},
"activeOrgName": {
"type": "string"
},
"switchableOrgCount": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
}
},
"required": ["consoleManagedProviders", "switchableOrgCount"]
}
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.console.get({\n ...\n})"
}
]
}
},
"/experimental/console/orgs": {
"get": {
"operationId": "experimental.console.listOrgs",
"parameters": [
{
"in": "query",
"name": "directory",
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "workspace",
"schema": {
"type": "string"
}
}
],
"summary": "List switchable Console orgs",
"description": "Get the available Console orgs across logged-in accounts, including the current active org.",
"responses": {
"200": {
"description": "Switchable Console orgs",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"orgs": {
"type": "array",
"items": {
"type": "object",
"properties": {
"accountID": {
"type": "string"
},
"accountEmail": {
"type": "string"
},
"accountUrl": {
"type": "string"
},
"orgID": {
"type": "string"
},
"orgName": {
"type": "string"
},
"active": {
"type": "boolean"
}
},
"required": ["accountID", "accountEmail", "accountUrl", "orgID", "orgName", "active"]
}
}
},
"required": ["orgs"]
}
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.console.listOrgs({\n ...\n})"
}
]
}
},
"/experimental/console/switch": {
"post": {
"operationId": "experimental.console.switchOrg",
"parameters": [
{
"in": "query",
"name": "directory",
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "workspace",
"schema": {
"type": "string"
}
}
],
"summary": "Switch active Console org",
"description": "Persist a new active Console account/org selection for the current local OpenCode state.",
"responses": {
"200": {
"description": "Switch success",
"content": {
"application/json": {
"schema": {
"type": "boolean"
}
}
}
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"accountID": {
"type": "string"
},
"orgID": {
"type": "string"
}
},
"required": ["accountID", "orgID"]
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.console.switchOrg({\n ...\n})"
}
]
}
},
"/experimental/tool/ids": {
"get": {
"operationId": "tool.ids",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.3.15",
"version": "1.3.13",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.3.15",
"version": "1.3.13",
"type": "module",
"license": "MIT",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.3.15",
"version": "1.3.13",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.3.15",
"version": "1.3.13",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
-1
View File
@@ -573,7 +573,6 @@ OpenCode can be configured using environment variables.
| `OPENCODE_DISABLE_CLAUDE_CODE_PROMPT` | boolean | Disable reading `~/.claude/CLAUDE.md` |
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | Disable loading `.claude/skills` |
| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | Disable fetching models from remote sources |
| `OPENCODE_DISABLE_MOUSE` | boolean | Disable mouse capture in the TUI |
| `OPENCODE_FAKE_VCS` | string | Fake VCS provider for testing purposes |
| `OPENCODE_DISABLE_FILETIME_CHECK` | boolean | Disable file time checking for optimization |
| `OPENCODE_CLIENT` | string | Client identifier (defaults to `cli`) |
+3 -2
View File
@@ -272,8 +272,7 @@ Use a dedicated `tui.json` (or `tui.jsonc`) file for TUI-specific settings.
"scroll_acceleration": {
"enabled": true
},
"diff_style": "auto",
"mouse": true
"diff_style": "auto"
}
```
@@ -281,6 +280,8 @@ Use `OPENCODE_TUI_CONFIG` to point to a custom TUI config file.
Legacy `theme`, `keybinds`, and `tui` keys in `opencode.json` are deprecated and automatically migrated when possible.
[Learn more about TUI configuration here](/docs/tui#configure).
---
### Server
+1 -3
View File
@@ -368,8 +368,7 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`).
"scroll_acceleration": {
"enabled": true
},
"diff_style": "auto",
"mouse": true
"diff_style": "auto"
}
```
@@ -382,7 +381,6 @@ This is separate from `opencode.json`, which configures server/runtime behavior.
- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.**
- `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `0.001`, supports decimal values). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.**
- `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout.
- `mouse` - Enable or disable mouse capture in the TUI (default: `true`). When disabled, the terminal's native mouse selection/scrolling behavior is preserved.
Use `OPENCODE_TUI_CONFIG` to load a custom TUI config path.
+8
View File
@@ -94,6 +94,8 @@ You can also access our models through the following API endpoints.
| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiMo V2 Pro Free | mimo-v2-pro-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiMo V2 Omni Free | mimo-v2-omni-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Qwen3.6 Plus Free | qwen3.6-plus-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -120,6 +122,8 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
| Model | Input | Output | Cached Read | Cached Write |
| --------------------------------- | ------ | ------- | ----------- | ------------ |
| Big Pickle | Free | Free | Free | - |
| MiMo V2 Pro Free | Free | Free | Free | - |
| MiMo V2 Omni Free | Free | Free | Free | - |
| Qwen3.6 Plus Free | Free | Free | Free | - |
| Nemotron 3 Super Free | Free | Free | Free | - |
| MiniMax M2.5 Free | Free | Free | Free | - |
@@ -165,6 +169,8 @@ Credit card fees are passed along at cost (4.4% + $0.30 per transaction); we don
The free models:
- MiniMax M2.5 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
- MiMo V2 Pro Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
- MiMo V2 Omni Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
- Qwen3.6 Plus Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
- Nemotron 3 Super Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
- Big Pickle is a stealth model that's free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
@@ -212,6 +218,8 @@ All our models are hosted in the US. Our providers follow a zero-retention polic
- Big Pickle: During its free period, collected data may be used to improve the model.
- MiniMax M2.5 Free: During its free period, collected data may be used to improve the model.
- MiMo V2 Pro Free: During its free period, collected data may be used to improve the model.
- MiMo V2 Omni Free: During its free period, collected data may be used to improve the model.
- Qwen3.6 Plus Free: During its free period, collected data may be used to improve the model.
- Nemotron 3 Super Free: During its free period, collected data may be used to improve the model.
- OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.3.15",
"version": "1.3.13",
"publisher": "sst-dev",
"repository": {
"type": "git",