Compare commits

...
Author SHA1 Message Date
Kit Langton 221aa8c19e test(opencode): clear otlp url in preload 2026-04-07 16:50:55 -04:00
Kit Langton 544d012515 fix(opencode): make leto export opt-in by url 2026-04-07 16:49:45 -04:00
Kit Langton ba41b6928f wip: wire local leto observability 2026-04-06 11:00:42 -04:00
Aiden Cline 8b8d4fa066 test: add regression test for double counting bug (#21053) 2026-04-04 16:40:28 -07:00
opencode-agent[bot] 6253ef0c27 chore: generate 2026-04-04 23:26:37 +00:00
Kit Langton c6ebc7ff7c fix(tui): only show org switch affordances when useful (#21054) 2026-04-04 19:25:37 -04:00
opencode-agent[bot] 985663620f chore: generate 2026-04-04 23:07:01 +00:00
Kit Langton c796b9a19e refactor(effect): move read tool onto defineEffect (#21016) 2026-04-04 19:06:00 -04:00
Kit Langton 6ea108a03b feat(tui): show console-managed providers (#20956) 2026-04-04 19:05:45 -04:00
Aiden Cline 280eb16e77 fix: ensure reasoning tokens arent double counted when calculating usage (#21047) 2026-04-04 15:29:53 -07:00
opencode 930e94a3ea release: v1.3.15 2026-04-04 20:54:20 +00:00
Dax 629e866ff0 fix(npm): Arborist reify fails on compiled binary — Bun pre-resolves node-gyp path at build time (#21040) 2026-04-04 16:27:20 -04:00
c08fa5675f refactor: remove redundant Kimi skill section (#20393)
Co-authored-by: dongyuxin <dongyuxin@dev.dongyuxin.msh-dev.svc.cluster.local>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-04 18:32:26 +00:00
opencode cc50b778eb release: v1.3.14 2026-04-04 18:32:17 +00:00
Kit Langton 00fa68b3a7 fix(ci): create JUnit output dirs before tests (#20959) 2026-04-03 22:47:20 -04:00
Kit LangtonandNate Williams 288eb044cb perf(opencode): batch snapshot diffFull blob reads (#20752)
Co-authored-by: Nate Williams <50088025+natewill@users.noreply.github.com>
2026-04-04 01:05:23 +00:00
Kit Langtonandluanweslley77 59ca4543d8 refactor(provider): stop custom loaders using facades (#20776)
Co-authored-by: luanweslley77 <213105503+luanweslley77@users.noreply.github.com>
2026-04-04 00:24:24 +00:00
opencode-agent[bot] 650d0dbe54 chore: generate 2026-04-03 22:55:05 +00:00
Sebastian a5ec741cff notes on v2 (#20941) 2026-04-04 00:54:03 +02:00
Aiden Cline fff98636f7 chore: rm models snapshot (#20929) 2026-04-03 19:44:50 +00:00
Kit Langton c72642dd35 test(ci): publish unit reports in actions (#20547) 2026-04-03 16:12:01 +00:00
f2d4ced8ea refactor(effect): build todowrite tool from Todo service (#20789)
Co-authored-by: Juan Pablo Carranza Hurtado <52012198+jpcarranza94@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 12:05:40 -04:00
86 changed files with 3139 additions and 62085 deletions
+31 -4
View File
@@ -15,6 +15,7 @@ concurrency:
permissions: permissions:
contents: read contents: read
checks: write
jobs: jobs:
unit: unit:
@@ -45,14 +46,40 @@ jobs:
git config --global user.email "bot@opencode.ai" git config --global user.email "bot@opencode.ai"
git config --global user.name "opencode" git config --global user.name "opencode"
- name: Cache Turbo
uses: actions/cache@v4
with:
path: node_modules/.cache/turbo
key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-
turbo-${{ runner.os }}-
- name: Run unit tests - name: Run unit tests
run: bun turbo test run: bun turbo test:ci
env: env:
# Bun 1.3.11 intermittently crashes on Windows during test teardown
# inside the native @parcel/watcher binding. Unit CI does not rely on
# the live watcher backend there, so disable it for that platform.
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Publish unit reports
if: always()
uses: mikepenz/action-junit-report@v6
with:
report_paths: packages/*/.artifacts/unit/junit.xml
check_name: "unit results (${{ matrix.settings.name }})"
detailed_summary: true
include_time_in_summary: true
fail_on_failure: false
- name: Upload unit artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }}
include-hidden-files: true
if-no-files-found: ignore
retention-days: 7
path: packages/*/.artifacts/unit/junit.xml
e2e: e2e:
name: e2e (${{ matrix.settings.name }}) name: e2e (${{ matrix.settings.name }})
strategy: strategy:
+16 -16
View File
@@ -26,7 +26,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -80,7 +80,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -114,7 +114,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -141,7 +141,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "3.0.64", "@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
@@ -165,7 +165,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -189,7 +189,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -222,7 +222,7 @@
}, },
"packages/desktop-electron": { "packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron", "name": "@opencode-ai/desktop-electron",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -254,7 +254,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
@@ -283,7 +283,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -299,7 +299,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.3.13", "version": "1.3.15",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -428,7 +428,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"zod": "catalog:", "zod": "catalog:",
@@ -462,7 +462,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
}, },
@@ -477,7 +477,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -512,7 +512,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -560,7 +560,7 @@
}, },
"packages/util": { "packages/util": {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"zod": "catalog:", "zod": "catalog:",
}, },
@@ -571,7 +571,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.3.13", "version": "1.3.15",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -15,6 +15,7 @@
"build": "vite build", "build": "vite build",
"serve": "vite preview", "serve": "vite preview",
"test": "bun run test:unit", "test": "bun run test:unit",
"test:ci": "mkdir -p .artifacts/unit && bun test --preload ./happydom.ts ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"test:unit": "bun test --preload ./happydom.ts ./src", "test:unit": "bun test --preload ./happydom.ts ./src",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src", "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test", "test:e2e": "playwright test",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.3.13", "version": "1.3.15",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.3.13", "version": "1.3.15",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.3.13", "version": "1.3.15",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop-electron", "name": "@opencode-ai/desktop-electron",
"private": true, "private": true,
"version": "1.3.13", "version": "1.3.15",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"homepage": "https://opencode.ai", "homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.3.13", "version": "1.3.15",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.3.13", "version": "1.3.15",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The open source coding agent." description = "The open source coding agent."
version = "1.3.13" version = "1.3.15"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode" repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-darwin-arm64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-darwin-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-linux-arm64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-linux-arm64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-linux-x64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-linux-x64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-windows-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.3.13", "version": "1.3.15",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.3.13", "version": "1.3.15",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@@ -9,6 +9,7 @@
"prepare": "effect-language-service patch || true", "prepare": "effect-language-service patch || true",
"typecheck": "tsgo --noEmit", "typecheck": "tsgo --noEmit",
"test": "bun test --timeout 30000", "test": "bun test --timeout 30000",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"build": "bun run script/build.ts", "build": "bun run script/build.ts",
"upgrade-opentui": "bun run script/upgrade-opentui.ts", "upgrade-opentui": "bun run script/upgrade-opentui.ts",
"dev": "bun run --conditions=browser ./src/index.ts", "dev": "bun run --conditions=browser ./src/index.ts",
+1
View File
@@ -209,6 +209,7 @@ for (const item of targets) {
conditions: ["browser"], conditions: ["browser"],
tsconfig: "./tsconfig.json", tsconfig: "./tsconfig.json",
plugins: [plugin], plugins: [plugin],
external: ["node-gyp"],
compile: { compile: {
autoloadBunfig: false, autoloadBunfig: false,
autoloadDotenv: false, autoloadDotenv: false,
+17 -1
View File
@@ -235,11 +235,27 @@ Once individual tools are effectified, change `Tool.Info` (`tool/tool.ts`) so `i
2. Update `Tool.define()` factory to work with Effects 2. Update `Tool.define()` factory to work with Effects
3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing 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: Individual tools, ordered by value:
- [ ] `apply_patch.ts` — HIGH: multi-step orchestration, error accumulation, Bus events - [ ] `apply_patch.ts` — HIGH: multi-step orchestration, error accumulation, Bus events
- [ ] `bash.ts` — HIGH: shell orchestration, quoting, timeout handling, output capture - [ ] `bash.ts` — HIGH: shell orchestration, quoting, timeout handling, output capture
- [ ] `read.ts` — HIGH: streaming I/O, readline, binary detection → FileSystem + Stream - [x] `read.ts` — HIGH: streaming I/O, readline, binary detection → FileSystem + Stream
- [ ] `edit.ts` — HIGH: multi-step diff/format/publish pipeline, FileWatcher lock - [ ] `edit.ts` — HIGH: multi-step diff/format/publish pipeline, FileWatcher lock
- [ ] `grep.ts` — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handling - [ ] `grep.ts` — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handling
- [ ] `write.ts` — MEDIUM: permission checks, diagnostics polling, Bus events - [ ] `write.ts` — MEDIUM: permission checks, diagnostics polling, Bus events
+14
View File
@@ -0,0 +1,14 @@
# 2.0
What we would change if we could
## 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
```ts
{ key: "ctrl+w", cmd: string | function, description }
```
_Why_
Currently its keybindings that have an `id` like `message_redo` and then a command can use that or define it's own binding. While some keybindings are just used with `.match` in arbitrary key handlers and there is no info what the key is used for, except the binding id maybe. It also is unknown in which context/scope what binding is active, so a plugin like `which-key` is nearly impossible to get right.
+47 -8
View File
@@ -52,6 +52,11 @@ export type AccountOrgs = {
orgs: readonly Org[] orgs: readonly Org[]
} }
export type ActiveOrg = {
account: Info
org: Org
}
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({ class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
config: Schema.Record(Schema.String, Schema.Json), config: Schema.Record(Schema.String, Schema.Json),
}) {} }) {}
@@ -137,6 +142,7 @@ const mapAccountServiceError =
export namespace Account { export namespace Account {
export interface Interface { export interface Interface {
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError> readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
readonly activeOrg: () => Effect.Effect<Option.Option<ActiveOrg>, AccountError>
readonly list: () => Effect.Effect<Info[], AccountError> readonly list: () => Effect.Effect<Info[], AccountError>
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError> readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError> readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
@@ -279,19 +285,31 @@ export namespace Account {
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))), 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 orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
const accounts = yield* repo.list() const accounts = yield* repo.list()
const [errors, results] = yield* Effect.partition( return yield* Effect.forEach(
accounts, accounts,
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))), (account) =>
orgs(account.id).pipe(
Effect.catch(() => Effect.succeed([] as readonly Org[])),
Effect.map((orgs) => ({ account, orgs })),
),
{ concurrency: 3 }, { 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) { const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
@@ -396,6 +414,7 @@ export namespace Account {
return Service.of({ return Service.of({
active: repo.active, active: repo.active,
activeOrg,
list: repo.list, list: repo.list,
orgsByAccount, orgsByAccount,
remove: repo.remove, remove: repo.remove,
@@ -417,6 +436,26 @@ export namespace Account {
return Option.getOrUndefined(await runPromise((service) => service.active())) 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> { export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
const t = await runPromise((service) => service.token(accountID)) const t = await runPromise((service) => service.token(accountID))
return Option.getOrUndefined(t) return Option.getOrUndefined(t)
+1 -1
View File
@@ -78,7 +78,7 @@ export namespace Agent {
const provider = yield* Provider.Service const provider = yield* Provider.Service
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("Agent.state")(function* (ctx) { Effect.fnUntraced(function* (ctx) {
const cfg = yield* config.get() const cfg = yield* config.get()
const skillDirs = yield* skill.dirs() const skillDirs = yield* skill.dirs()
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))] const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
+1 -1
View File
@@ -47,7 +47,7 @@ export namespace Bus {
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("Bus.state")(function* (ctx) { Effect.fnUntraced(function* (ctx) {
const wildcard = yield* PubSub.unbounded<Payload>() const wildcard = yield* PubSub.unbounded<Payload>()
const typed = new Map<string, PubSub.PubSub<Payload>>() const typed = new Map<string, PubSub.PubSub<Payload>>()
+18
View File
@@ -36,6 +36,7 @@ import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command
import { DialogAgent } from "@tui/component/dialog-agent" import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list" import { DialogSessionList } from "@tui/component/dialog-session-list"
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-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 { KeybindProvider, useKeybind } from "@tui/context/keybind"
import { ThemeProvider, useTheme } from "@tui/context/theme" import { ThemeProvider, useTheme } from "@tui/context/theme"
import { Home } from "@tui/routes/home" import { Home } from "@tui/routes/home"
@@ -629,6 +630,23 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}, },
category: "Provider", 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", title: "View status",
keybind: "status_view", keybind: "status_view",
@@ -0,0 +1,103 @@
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,6 +8,7 @@ import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { DialogVariant } from "./dialog-variant" import { DialogVariant } from "./dialog-variant"
import { useKeybind } from "../context/keybind" import { useKeybind } from "../context/keybind"
import * as fuzzysort from "fuzzysort" import * as fuzzysort from "fuzzysort"
import { consoleManagedProviderLabel } from "@tui/util/provider-origin"
export function useConnected() { export function useConnected() {
const sync = useSync() const sync = useSync()
@@ -46,7 +47,11 @@ export function DialogModel(props: { providerID?: string }) {
key: item, key: item,
value: { providerID: provider.id, modelID: model.id }, value: { providerID: provider.id, modelID: model.id },
title: model.name ?? item.modelID, title: model.name ?? item.modelID,
description: provider.name, description: consoleManagedProviderLabel(
sync.data.console_state.consoleManagedProviders,
provider.id,
provider.name,
),
category, category,
disabled: provider.id === "opencode" && model.id.includes("-nano"), disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
@@ -84,7 +89,9 @@ export function DialogModel(props: { providerID?: string }) {
description: favorites.some((item) => item.providerID === provider.id && item.modelID === model) description: favorites.some((item) => item.providerID === provider.id && item.modelID === model)
? "(Favorite)" ? "(Favorite)"
: undefined, : undefined,
category: connected() ? provider.name : undefined, category: connected()
? consoleManagedProviderLabel(sync.data.console_state.consoleManagedProviders, provider.id, provider.name)
: undefined,
disabled: provider.id === "opencode" && model.includes("-nano"), disabled: provider.id === "opencode" && model.includes("-nano"),
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect() { onSelect() {
@@ -132,7 +139,11 @@ export function DialogModel(props: { providerID?: string }) {
props.providerID ? sync.data.provider.find((x) => x.id === props.providerID) : null, props.providerID ? sync.data.provider.find((x) => x.id === props.providerID) : null,
) )
const title = createMemo(() => provider()?.name ?? "Select model") const title = createMemo(() => {
const value = provider()
if (!value) return "Select model"
return consoleManagedProviderLabel(sync.data.console_state.consoleManagedProviders, value.id, value.name)
})
function onSelect(providerID: string, modelID: string) { function onSelect(providerID: string, modelID: string) {
local.model.set({ providerID, modelID }, { recent: true }) local.model.set({ providerID, modelID }, { recent: true })
@@ -13,6 +13,7 @@ import { DialogModel } from "./dialog-model"
import { useKeyboard } from "@opentui/solid" import { useKeyboard } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard" import { Clipboard } from "@tui/util/clipboard"
import { useToast } from "../ui/toast" import { useToast } from "../ui/toast"
import { CONSOLE_MANAGED_ICON, isConsoleManagedProvider } from "@tui/util/provider-origin"
const PROVIDER_PRIORITY: Record<string, number> = { const PROVIDER_PRIORITY: Record<string, number> = {
opencode: 0, opencode: 0,
@@ -28,87 +29,111 @@ export function createDialogProviderOptions() {
const dialog = useDialog() const dialog = useDialog()
const sdk = useSDK() const sdk = useSDK()
const toast = useToast() const toast = useToast()
const { theme } = useTheme()
const options = createMemo(() => { const options = createMemo(() => {
return pipe( return pipe(
sync.data.provider_next.all, sync.data.provider_next.all,
sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99), sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99),
map((provider) => ({ map((provider) => {
title: provider.name, const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, provider.id)
value: provider.id, const connected = sync.data.provider_next.connected.includes(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)}
/>
),
() => 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({ return {
providerID: provider.id, title: provider.name,
method: index, value: provider.id,
inputs, description: {
}) opencode: "(Recommended)",
if (result.error) { anthropic: "(API key)",
toast.show({ openai: "(ChatGPT Plus/Pro or API key)",
variant: "error", "opencode-go": "Low cost subscription for everyone",
message: JSON.stringify(result.error), }[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),
)
}) })
dialog.clear()
return
} }
if (result.data?.method === "code") { if (index == null) return
dialog.replace(() => ( const method = methods[index]
<CodeMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} /> 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!}
/>
))
}
if (result.data?.method === "auto") {
dialog.replace(() => (
<AutoMethod
providerID={provider.id}
title={method.label}
index={index}
authorization={result.data!}
/>
))
}
} }
if (result.data?.method === "auto") { if (method.type === "api") {
dialog.replace(() => ( return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
<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 return options
@@ -35,6 +35,7 @@ import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv" import { useKV } from "../../context/kv"
import { useTextareaKeybindings } from "../textarea-keybindings" import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill" import { DialogSkill } from "../dialog-skill"
import { CONSOLE_MANAGED_ICON, consoleManagedProviderLabel } from "@tui/util/provider-origin"
export type PromptProps = { export type PromptProps = {
sessionID?: string sessionID?: string
@@ -94,6 +95,15 @@ export function Prompt(props: PromptProps) {
const list = createMemo(() => props.placeholders?.normal ?? []) const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? []) const shell = createMemo(() => props.placeholders?.shell ?? [])
const [auto, setAuto] = createSignal<AutocompleteRef>() 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() { function promptModelWarning() {
toast.show({ toast.show({
@@ -1095,7 +1105,7 @@ export function Prompt(props: PromptProps) {
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}> <text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
{local.model.parsed().model} {local.model.parsed().model}
</text> </text>
<text fg={theme.textMuted}>{local.model.parsed().provider}</text> <text fg={theme.textMuted}>{currentProviderLabel()}</text>
<Show when={showVariant()}> <Show when={showVariant()}>
<text fg={theme.textMuted}>·</text> <text fg={theme.textMuted}>·</text>
<text> <text>
@@ -1105,7 +1115,22 @@ export function Prompt(props: PromptProps) {
</box> </box>
</Show> </Show>
</box> </box>
{props.right} <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>
</box> </box>
</box> </box>
</box> </box>
@@ -29,6 +29,7 @@ import { batch, onMount } from "solid-js"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import type { Path } from "@opencode-ai/sdk" import type { Path } from "@opencode-ai/sdk"
import type { Workspace } from "@opencode-ai/sdk/v2" 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({ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync", name: "Sync",
@@ -38,6 +39,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
provider: Provider[] provider: Provider[]
provider_default: Record<string, string> provider_default: Record<string, string>
provider_next: ProviderListResponse provider_next: ProviderListResponse
console_state: ConsoleStateType
provider_auth: Record<string, ProviderAuthMethod[]> provider_auth: Record<string, ProviderAuthMethod[]>
agent: Agent[] agent: Agent[]
command: Command[] command: Command[]
@@ -81,6 +83,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
default: {}, default: {},
connected: [], connected: [],
}, },
console_state: emptyConsoleState,
provider_auth: {}, provider_auth: {},
config: {}, config: {},
status: "loading", status: "loading",
@@ -365,6 +368,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
// blocking - include session.list when continuing a session // blocking - include session.list when continuing a session
const providersPromise = sdk.client.config.providers({}, { throwOnError: true }) const providersPromise = sdk.client.config.providers({}, { throwOnError: true })
const providerListPromise = sdk.client.provider.list({}, { 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 agentsPromise = sdk.client.app.agents({}, { throwOnError: true })
const configPromise = sdk.client.config.get({}, { throwOnError: true }) const configPromise = sdk.client.config.get({}, { throwOnError: true })
const blockingRequests: Promise<unknown>[] = [ const blockingRequests: Promise<unknown>[] = [
@@ -379,6 +386,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
.then(() => { .then(() => {
const providersResponse = providersPromise.then((x) => x.data!) const providersResponse = providersPromise.then((x) => x.data!)
const providerListResponse = providerListPromise.then((x) => x.data!) const providerListResponse = providerListPromise.then((x) => x.data!)
const consoleStateResponse = consoleStatePromise
const agentsResponse = agentsPromise.then((x) => x.data ?? []) const agentsResponse = agentsPromise.then((x) => x.data ?? [])
const configResponse = configPromise.then((x) => x.data!) const configResponse = configPromise.then((x) => x.data!)
const sessionListResponse = args.continue ? sessionListPromise : undefined const sessionListResponse = args.continue ? sessionListPromise : undefined
@@ -386,20 +394,23 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return Promise.all([ return Promise.all([
providersResponse, providersResponse,
providerListResponse, providerListResponse,
consoleStateResponse,
agentsResponse, agentsResponse,
configResponse, configResponse,
...(sessionListResponse ? [sessionListResponse] : []), ...(sessionListResponse ? [sessionListResponse] : []),
]).then((responses) => { ]).then((responses) => {
const providers = responses[0] const providers = responses[0]
const providerList = responses[1] const providerList = responses[1]
const agents = responses[2] const consoleState = responses[2]
const config = responses[3] const agents = responses[3]
const sessions = responses[4] const config = responses[4]
const sessions = responses[5]
batch(() => { batch(() => {
setStore("provider", reconcile(providers.providers)) setStore("provider", reconcile(providers.providers))
setStore("provider_default", reconcile(providers.default)) setStore("provider_default", reconcile(providers.default))
setStore("provider_next", reconcile(providerList)) setStore("provider_next", reconcile(providerList))
setStore("console_state", reconcile(consoleState))
setStore("agent", reconcile(agents)) setStore("agent", reconcile(agents))
setStore("config", reconcile(config)) setStore("config", reconcile(config))
if (sessions !== undefined) setStore("session", reconcile(sessions)) if (sessions !== undefined) setStore("session", reconcile(sessions))
@@ -411,6 +422,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
// non-blocking // non-blocking
Promise.all([ Promise.all([
...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]), ...(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.command.list().then((x) => setStore("command", reconcile(x.data ?? []))),
sdk.client.lsp.status().then((x) => setStore("lsp", 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!))), sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))),
@@ -38,6 +38,7 @@ export interface DialogSelectOption<T = any> {
description?: string description?: string
footer?: JSX.Element | string footer?: JSX.Element | string
category?: string category?: string
categoryView?: JSX.Element
disabled?: boolean disabled?: boolean
bg?: RGBA bg?: RGBA
gutter?: JSX.Element gutter?: JSX.Element
@@ -291,9 +292,16 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<> <>
<Show when={category}> <Show when={category}>
<box paddingTop={index() > 0 ? 1 : 0} paddingLeft={3}> <box paddingTop={index() > 0 ? 1 : 0} paddingLeft={3}>
<text fg={theme.accent} attributes={TextAttributes.BOLD}> <Show
{category} when={options[0]?.categoryView}
</text> fallback={
<text fg={theme.accent} attributes={TextAttributes.BOLD}>
{category}
</text>
}
>
{options[0]?.categoryView}
</Show>
</box> </box>
</Show> </Show>
<For each={options}> <For each={options}>
@@ -0,0 +1,20 @@
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)}`
+1 -1
View File
@@ -79,7 +79,7 @@ export namespace Command {
const mcp = yield* MCP.Service const mcp = yield* MCP.Service
const skill = yield* Skill.Service const skill = yield* Skill.Service
const init = Effect.fn("Command.state")(function* (ctx) { const init = Effect.fnUntraced(function* (ctx) {
const cfg = yield* config.get() const cfg = yield* config.get()
const commands: Record<string, Info> = {} const commands: Record<string, Info> = {}
+36 -12
View File
@@ -33,6 +33,7 @@ import { Account } from "@/account"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { ConfigPaths } from "./paths" import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
@@ -1050,11 +1051,13 @@ export namespace Config {
config: Info config: Info
directories: string[] directories: string[]
deps: Promise<void>[] deps: Promise<void>[]
consoleState: ConsoleState
} }
export interface Interface { export interface Interface {
readonly get: () => Effect.Effect<Info> readonly get: () => Effect.Effect<Info>
readonly getGlobal: () => Effect.Effect<Info> readonly getGlobal: () => Effect.Effect<Info>
readonly getConsoleState: () => Effect.Effect<ConsoleState>
readonly update: (config: Info) => Effect.Effect<void> readonly update: (config: Info) => Effect.Effect<void>
readonly updateGlobal: (config: Info) => Effect.Effect<Info> readonly updateGlobal: (config: Info) => Effect.Effect<Info>
readonly invalidate: (wait?: boolean) => Effect.Effect<void> readonly invalidate: (wait?: boolean) => Effect.Effect<void>
@@ -1260,6 +1263,8 @@ export namespace Config {
const auth = yield* authSvc.all().pipe(Effect.orDie) const auth = yield* authSvc.all().pipe(Effect.orDie)
let result: Info = {} let result: Info = {}
const consoleManagedProviders = new Set<string>()
let activeOrgName: string | undefined
const scope = (source: string): PluginScope => { const scope = (source: string): PluginScope => {
if (source.startsWith("http://") || source.startsWith("https://")) return "global" if (source.startsWith("http://") || source.startsWith("https://")) return "global"
@@ -1371,26 +1376,31 @@ export namespace Config {
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
} }
const active = Option.getOrUndefined(yield* accountSvc.active().pipe(Effect.orDie)) const activeOrg = Option.getOrUndefined(
if (active?.active_org_id) { yield* accountSvc.activeOrg().pipe(Effect.catch(() => Effect.succeed(Option.none()))),
)
if (activeOrg) {
yield* Effect.gen(function* () { yield* Effect.gen(function* () {
const [configOpt, tokenOpt] = yield* Effect.all( const [configOpt, tokenOpt] = yield* Effect.all(
[accountSvc.config(active.id, active.active_org_id!), accountSvc.token(active.id)], [accountSvc.config(activeOrg.account.id, activeOrg.org.id), accountSvc.token(activeOrg.account.id)],
{ concurrency: 2 }, { concurrency: 2 },
) )
const token = Option.getOrUndefined(tokenOpt) if (Option.isSome(tokenOpt)) {
if (token) { process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value
process.env["OPENCODE_CONSOLE_TOKEN"] = token Env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value)
Env.set("OPENCODE_CONSOLE_TOKEN", token)
} }
const config = Option.getOrUndefined(configOpt) activeOrgName = activeOrg.org.name
if (config) {
const source = `${active.url}/api/config` if (Option.isSome(configOpt)) {
const next = yield* loadConfig(JSON.stringify(config), { const source = `${activeOrg.account.url}/api/config`
const next = yield* loadConfig(JSON.stringify(configOpt.value), {
dir: path.dirname(source), dir: path.dirname(source),
source, source,
}) })
for (const providerID of Object.keys(next.provider ?? {})) {
consoleManagedProviders.add(providerID)
}
merge(source, next, "global") merge(source, next, "global")
} }
}).pipe( }).pipe(
@@ -1456,11 +1466,16 @@ export namespace Config {
config: result, config: result,
directories, directories,
deps, deps,
consoleState: {
consoleManagedProviders: Array.from(consoleManagedProviders),
activeOrgName,
switchableOrgCount: 0,
},
} }
}) })
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("Config.state")(function* (ctx) { Effect.fnUntraced(function* (ctx) {
return yield* loadInstanceState(ctx) return yield* loadInstanceState(ctx)
}), }),
) )
@@ -1473,6 +1488,10 @@ export namespace Config {
return yield* InstanceState.use(state, (s) => s.directories) 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* () { const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
yield* InstanceState.useEffect(state, (s) => Effect.promise(() => Promise.all(s.deps).then(() => undefined))) yield* InstanceState.useEffect(state, (s) => Effect.promise(() => Promise.all(s.deps).then(() => undefined)))
}) })
@@ -1528,6 +1547,7 @@ export namespace Config {
return Service.of({ return Service.of({
get, get,
getGlobal, getGlobal,
getConsoleState,
update, update,
updateGlobal, updateGlobal,
invalidate, invalidate,
@@ -1553,6 +1573,10 @@ export namespace Config {
return runPromise((svc) => svc.getGlobal()) return runPromise((svc) => svc.getGlobal())
} }
export async function getConsoleState() {
return runPromise((svc) => svc.getConsoleState())
}
export async function update(config: Info) { export async function update(config: Info) {
return runPromise((svc) => svc.update(config)) return runPromise((svc) => svc.update(config))
} }
@@ -0,0 +1,15 @@
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,
}
@@ -0,0 +1,26 @@
import { Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { Otlp } from "effect/unstable/observability"
import { Flag } from "@/flag/flag"
import { CHANNEL, VERSION } from "@/installation/meta"
export namespace Observability {
const base = Flag.OPENCODE_OTLP_BASE_URL?.trim() || undefined
export const enabled = !!base
export const layer = !base
? Layer.empty
: Otlp.layerJson({
baseUrl: base,
loggerMergeWithExisting: false,
resource: {
serviceName: "opencode",
serviceVersion: VERSION,
attributes: {
"deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL,
"opencode.client": Flag.OPENCODE_CLIENT,
},
},
}).pipe(Layer.provide(FetchHttpClient.layer))
}
+2 -1
View File
@@ -3,6 +3,7 @@ import * as ServiceMap from "effect/ServiceMap"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { Context } from "@/util/context" import { Context } from "@/util/context"
import { InstanceRef } from "./instance-ref" import { InstanceRef } from "./instance-ref"
import { Observability } from "./observability"
export const memoMap = Layer.makeMemoMapUnsafe() export const memoMap = Layer.makeMemoMapUnsafe()
@@ -18,7 +19,7 @@ function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R>
export function makeRuntime<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) { export function makeRuntime<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
const getRuntime = () => (rt ??= ManagedRuntime.make(layer, { memoMap })) const getRuntime = () => (rt ??= ManagedRuntime.make(Layer.merge(layer, Observability.layer), { memoMap }))
return { return {
runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(attach(service.use(fn))), runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(attach(service.use(fn))),
+4 -4
View File
@@ -346,11 +346,11 @@ export namespace File {
const appFs = yield* AppFileSystem.Service const appFs = yield* AppFileSystem.Service
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("File.state")(() => Effect.fnUntraced(function* () {
Effect.succeed({ return {
cache: { files: [], dirs: [] } as Entry, cache: { files: [], dirs: [] } as Entry,
}), }
), }),
) )
const scan = Effect.fn("File.scan")(function* () { const scan = Effect.fn("File.scan")(function* () {
+4 -4
View File
@@ -54,12 +54,12 @@ export namespace FileTime {
} }
}) })
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("FileTime.state")(() => Effect.fnUntraced(function* () {
Effect.succeed({ return {
reads: new Map<SessionID, Map<string, Stamp>>(), reads: new Map<SessionID, Map<string, Stamp>>(),
locks: new Map<string, Semaphore.Semaphore>(), locks: new Map<string, Semaphore.Semaphore>(),
}), }
), }),
) )
const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) { const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) {
+1 -1
View File
@@ -73,7 +73,7 @@ export namespace FileWatcher {
const config = yield* Config.Service const config = yield* Config.Service
const state = yield* InstanceState.make( const state = yield* InstanceState.make(
Effect.fn("FileWatcher.state")( Effect.fnUntraced(
function* () { function* () {
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return
+12 -2
View File
@@ -188,13 +188,23 @@ export namespace AppFileSystem {
export function normalizePath(p: string): string { export function normalizePath(p: string): string {
if (process.platform !== "win32") return p if (process.platform !== "win32") return p
const resolved = pathResolve(windowsPath(p))
try { try {
return realpathSync.native(p) return realpathSync.native(resolved)
} catch { } catch {
return p return resolved
} }
} }
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 { export function resolve(p: string): string {
const resolved = pathResolve(windowsPath(p)) const resolved = pathResolve(windowsPath(p))
try { try {
+1
View File
@@ -44,6 +44,7 @@ export namespace Flag {
export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"] export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"]
export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"] export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"]
export const OPENCODE_ENABLE_QUESTION_TOOL = truthy("OPENCODE_ENABLE_QUESTION_TOOL") export const OPENCODE_ENABLE_QUESTION_TOOL = truthy("OPENCODE_ENABLE_QUESTION_TOOL")
export const OPENCODE_OTLP_BASE_URL = process.env["OPENCODE_OTLP_BASE_URL"]
// Experimental // Experimental
export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL") export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
+91 -42
View File
@@ -40,7 +40,7 @@ export namespace Format {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const state = yield* InstanceState.make( const state = yield* InstanceState.make(
Effect.fn("Format.state")(function* (_ctx) { Effect.fnUntraced(function* (_ctx) {
const commands: Record<string, string[] | false> = {} const commands: Record<string, string[] | false> = {}
const formatters: Record<string, Formatter.Info> = {} const formatters: Record<string, Formatter.Info> = {}
@@ -84,57 +84,106 @@ export namespace Format {
return cmd !== false return cmd !== false
} }
async function getFormatter(ext: string) { function check(item: Formatter.Info, ext: string) {
const matching = Object.values(formatters).filter((item) => item.extensions.includes(ext)) return Effect.gen(function* () {
const checks = await Promise.all( yield* Effect.annotateCurrentSpan({
matching.map(async (item) => { ext,
log.info("checking", { name: item.name, ext }) formatter: item.name,
const cmd = await getCommand(item) })
if (cmd) { log.info("checking", { name: item.name, ext })
log.info("enabled", { name: item.name, ext }) const cmd = yield* Effect.promise(() => getCommand(item))
} if (cmd) {
return { log.info("enabled", { name: item.name, ext })
item, }
cmd, yield* Effect.annotateCurrentSpan({ enabled: !!cmd })
} return {
}), item,
) cmd,
return checks.filter((x) => x.cmd).map((x) => ({ item: x.item, cmd: x.cmd! })) }
}).pipe(Effect.withSpan("Format.checkFormatter"))
} }
function formatFile(filepath: string) { function resolve(ext: string) {
return Effect.gen(function* () {
const matching = Object.values(formatters).filter((item) => item.extensions.includes(ext))
const checks = yield* Effect.all(matching.map((item) => check(item, ext)))
const enabled = checks.filter((item) => item.cmd).map((item) => ({ item: item.item, cmd: item.cmd! }))
yield* Effect.annotateCurrentSpan({
ext,
matched_formatters: matching.map((item) => item.name).join(",") || "none",
enabled_formatters: enabled.map((item) => item.item.name).join(",") || "none",
})
return {
matching,
enabled,
}
}).pipe(Effect.withSpan("Format.resolveFormatters"))
}
function spawn(item: Formatter.Info, command: string[], filepath: string) {
return Effect.gen(function* () {
const dir = yield* InstanceState.directory
yield* Effect.annotateCurrentSpan({
file: filepath,
formatter: item.name,
command: command.join(" "),
})
return yield* spawner.spawn(
ChildProcess.make(command[0]!, command.slice(1), {
cwd: dir,
env: item.environment,
extendEnv: true,
}),
)
}).pipe(Effect.withSpan("Format.spawnFormatter"))
}
function wait(
handle: ChildProcessSpawner.ChildProcessHandle,
item: Formatter.Info,
command: string[],
filepath: string,
) {
return Effect.gen(function* () {
yield* Effect.annotateCurrentSpan({
file: filepath,
formatter: item.name,
command: command.join(" "),
})
return yield* handle.exitCode
}).pipe(Effect.withSpan("Format.waitFormatter"))
}
function formatFile(filepath: string): Effect.Effect<void, never, never> {
return Effect.gen(function* () { return Effect.gen(function* () {
log.info("formatting", { file: filepath }) log.info("formatting", { file: filepath })
const ext = path.extname(filepath) const ext = path.extname(filepath)
yield* Effect.annotateCurrentSpan({ file: filepath, ext })
const fmt = yield* resolve(ext)
yield* Effect.annotateCurrentSpan({
matched_formatters: fmt.matching.map((item) => item.name).join(",") || "none",
enabled_formatters: fmt.enabled.map((item) => item.item.name).join(",") || "none",
})
for (const { item, cmd } of yield* Effect.promise(() => getFormatter(ext))) { for (const { item, cmd } of fmt.enabled) {
if (cmd === false) continue if (cmd === false) continue
log.info("running", { command: cmd }) log.info("running", { command: cmd })
const replaced = cmd.map((x) => x.replace("$FILE", filepath)) const replaced = cmd.map((x) => x.replace("$FILE", filepath))
const dir = yield* InstanceState.directory const code = yield* spawn(item, replaced, filepath).pipe(
const code = yield* spawner Effect.flatMap((handle) => wait(handle, item, replaced, filepath)),
.spawn( Effect.scoped,
ChildProcess.make(replaced[0]!, replaced.slice(1), { Effect.catch(() =>
cwd: dir, Effect.sync(() => {
env: item.environment, log.error("failed to format file", {
extendEnv: true, error: "spawn failed",
command: replaced,
...item.environment,
file: filepath,
})
return ChildProcessSpawner.ExitCode(1)
}), }),
) ),
.pipe( )
Effect.flatMap((handle) => handle.exitCode),
Effect.scoped,
Effect.catch(() =>
Effect.sync(() => {
log.error("failed to format file", {
error: "spawn failed",
command: cmd,
...item.environment,
file: filepath,
})
return ChildProcessSpawner.ExitCode(1)
}),
),
)
if (code !== 0) { if (code !== 0) {
log.error("failed", { log.error("failed", {
command: cmd, command: cmd,
+1 -1
View File
@@ -164,7 +164,7 @@ export namespace LSP {
const config = yield* Config.Service const config = yield* Config.Service
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("LSP.state")(function* () { Effect.fnUntraced(function* () {
const cfg = yield* config.get() const cfg = yield* config.get()
const servers: Record<string, LSPServer.Info> = {} const servers: Record<string, LSPServer.Info> = {}
+1 -1
View File
@@ -478,7 +478,7 @@ export namespace MCP {
} }
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("MCP.state")(function* () { Effect.fnUntraced(function* () {
const cfg = yield* cfgSvc.get() const cfg = yield* cfgSvc.get()
const config = cfg.mcp ?? {} const config = cfg.mcp ?? {}
const s: State = { const s: State = {
+2
View File
@@ -67,6 +67,7 @@ export namespace Npm {
binLinks: true, binLinks: true,
progress: false, progress: false,
savePrefix: "", savePrefix: "",
ignoreScripts: true,
}) })
const tree = await arborist.loadVirtual().catch(() => {}) const tree = await arborist.loadVirtual().catch(() => {})
if (tree) { if (tree) {
@@ -106,6 +107,7 @@ export namespace Npm {
binLinks: true, binLinks: true,
progress: false, progress: false,
savePrefix: "", savePrefix: "",
ignoreScripts: true,
}) })
await arb.reify().catch(() => {}) await arb.reify().catch(() => {})
} }
+1 -1
View File
@@ -142,7 +142,7 @@ export namespace Permission {
Effect.gen(function* () { Effect.gen(function* () {
const bus = yield* Bus.Service const bus = yield* Bus.Service
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("Permission.state")(function* (ctx) { Effect.fnUntraced(function* (ctx) {
const row = Database.use((db) => const row = Database.use((db) =>
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(), db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(),
) )
+1 -1
View File
@@ -98,7 +98,7 @@ export namespace Plugin {
const config = yield* Config.Service const config = yield* Config.Service
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("Plugin.state")(function* (ctx) { Effect.fnUntraced(function* (ctx) {
const hooks: Hooks[] = [] const hooks: Hooks[] = []
const { Server } = yield* Effect.promise(() => import("../server/server")) const { Server } = yield* Effect.promise(() => import("../server/server"))
+28 -30
View File
@@ -147,39 +147,37 @@ export namespace Vcs {
const bus = yield* Bus.Service const bus = yield* Bus.Service
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("Vcs.state")((ctx) => Effect.fnUntraced(function* (ctx) {
Effect.gen(function* () { if (ctx.project.vcs !== "git") {
if (ctx.project.vcs !== "git") { return { current: undefined, root: undefined }
return { current: undefined, root: undefined } }
}
const get = Effect.fnUntraced(function* () { const get = Effect.fnUntraced(function* () {
return yield* git.branch(ctx.directory) return yield* git.branch(ctx.directory)
}) })
const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], { const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
concurrency: 2, concurrency: 2,
}) })
const value = { current, root } const value = { current, root }
log.info("initialized", { branch: value.current, default_branch: value.root?.name }) log.info("initialized", { branch: value.current, default_branch: value.root?.name })
yield* bus.subscribe(FileWatcher.Event.Updated).pipe( yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")), Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
Stream.runForEach((_evt) => Stream.runForEach((_evt) =>
Effect.gen(function* () { Effect.gen(function* () {
const next = yield* get() const next = yield* get()
if (next !== value.current) { if (next !== value.current) {
log.info("branch changed", { from: value.current, to: next }) log.info("branch changed", { from: value.current, to: next })
value.current = next value.current = next
yield* bus.publish(Event.BranchUpdated, { branch: next }) yield* bus.publish(Event.BranchUpdated, { branch: next })
} }
}), }),
), ),
Effect.forkScoped, Effect.forkScoped,
) )
return value return value
}), }),
),
) )
return Service.of({ return Service.of({
+1 -1
View File
@@ -117,7 +117,7 @@ export namespace ProviderAuth {
const auth = yield* Auth.Service const auth = yield* Auth.Service
const plugin = yield* Plugin.Service const plugin = yield* Plugin.Service
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("ProviderAuth.state")(function* () { Effect.fnUntraced(function* () {
const plugins = yield* plugin.list() const plugins = yield* plugin.list()
return { return {
hooks: Record.fromEntries( hooks: Record.fromEntries(
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -133,7 +133,7 @@ export namespace Pty {
} }
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("Pty.state")(function* (ctx) { Effect.fnUntraced(function* (ctx) {
const state = { const state = {
dir: ctx.directory, dir: ctx.directory,
sessions: new Map<PtyID, Active>(), sessions: new Map<PtyID, Active>(),
+1 -1
View File
@@ -111,7 +111,7 @@ export namespace Question {
Effect.gen(function* () { Effect.gen(function* () {
const bus = yield* Bus.Service const bus = yield* Bus.Service
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("Question.state")(function* () { Effect.fnUntraced(function* () {
const state = { const state = {
pending: new Map<QuestionID, PendingEntry>(), pending: new Map<QuestionID, PendingEntry>(),
} }
@@ -8,13 +8,116 @@ import { Instance } from "../../project/instance"
import { Project } from "../../project/project" import { Project } from "../../project/project"
import { MCP } from "../../mcp" import { MCP } from "../../mcp"
import { Session } from "../../session" 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 { zodToJsonSchema } from "zod-to-json-schema"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
import { WorkspaceRoutes } from "./workspace" 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(() => export const ExperimentalRoutes = lazy(() =>
new Hono() 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( .get(
"/tool/ids", "/tool/ids",
describeRoute({ describeRoute({
+2 -2
View File
@@ -138,7 +138,7 @@ export namespace SessionCompaction {
} }
}) })
const processCompaction = Effect.fn("SessionCompaction.process")(function* (input: { const process = Effect.fn("SessionCompaction.process")(function* (input: {
parentID: MessageID parentID: MessageID
messages: MessageV2.WithParts[] messages: MessageV2.WithParts[]
sessionID: SessionID sessionID: SessionID
@@ -374,7 +374,7 @@ When constructing the summary, try to stick to this template:
return Service.of({ return Service.of({
isOverflow, isOverflow,
prune, prune,
process: processCompaction, process,
create, create,
}) })
}), }),
+1 -1
View File
@@ -278,7 +278,7 @@ export namespace Session {
const tokens = { const tokens = {
total, total,
input: adjustedInputTokens, input: adjustedInputTokens,
output: outputTokens, output: outputTokens - reasoningTokens,
reasoning: reasoningTokens, reasoning: reasoningTokens,
cache: { cache: {
write: cacheWriteInputTokens, write: cacheWriteInputTokens,
+4 -4
View File
@@ -75,12 +75,12 @@ export namespace Instruction {
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient)) const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
const state = yield* InstanceState.make( const state = yield* InstanceState.make(
Effect.fn("Instruction.state")(() => Effect.fnUntraced(function* () {
Effect.succeed({ return {
// Track which instruction files have already been attached for a given assistant message. // Track which instruction files have already been attached for a given assistant message.
claims: new Map<MessageID, Set<string>>(), claims: new Map<MessageID, Set<string>>(),
}), }
), }),
) )
const relative = Effect.fnUntraced(function* (instruction: string) { const relative = Effect.fnUntraced(function* (instruction: string) {
@@ -415,9 +415,20 @@ export namespace SessionProcessor {
const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) { const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) {
log.error("process", { error: e, stack: e instanceof Error ? e.stack : undefined }) log.error("process", { error: e, stack: e instanceof Error ? e.stack : undefined })
yield* Effect.logError("session processor failed", {
agent: ctx.assistantMessage.agent,
modelID: ctx.model.id,
providerID: ctx.model.providerID,
sessionID: ctx.sessionID,
})
const error = parse(e) const error = parse(e)
if (MessageV2.ContextOverflowError.isInstance(error)) { if (MessageV2.ContextOverflowError.isInstance(error)) {
ctx.needsCompaction = true ctx.needsCompaction = true
yield* Effect.logWarning("session processor requested compaction", {
modelID: ctx.model.id,
providerID: ctx.model.providerID,
sessionID: ctx.sessionID,
})
yield* bus.publish(Session.Event.Error, { sessionID: ctx.sessionID, error }) yield* bus.publish(Session.Event.Error, { sessionID: ctx.sessionID, error })
return return
} }
@@ -446,6 +457,18 @@ export namespace SessionProcessor {
log.info("process") log.info("process")
ctx.needsCompaction = false ctx.needsCompaction = false
ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true
yield* Effect.annotateCurrentSpan({
agent: streamInput.agent.name,
modelID: streamInput.model.id,
providerID: streamInput.model.providerID,
sessionID: ctx.sessionID,
})
yield* Effect.logInfo("session processor started", {
agent: streamInput.agent.name,
modelID: streamInput.model.id,
providerID: streamInput.model.providerID,
sessionID: ctx.sessionID,
})
return yield* Effect.gen(function* () { return yield* Effect.gen(function* () {
yield* Effect.gen(function* () { yield* Effect.gen(function* () {
@@ -459,6 +482,7 @@ export namespace SessionProcessor {
Stream.runDrain, Stream.runDrain,
) )
}).pipe( }).pipe(
Effect.withSpan("SessionProcessor.stream"),
Effect.onInterrupt(() => Effect.sync(() => void (aborted = true))), Effect.onInterrupt(() => Effect.sync(() => void (aborted = true))),
Effect.catchCauseIf( Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause), (cause) => !Cause.hasInterruptsOnly(cause),
@@ -483,6 +507,12 @@ export namespace SessionProcessor {
if (aborted && !ctx.assistantMessage.error) { if (aborted && !ctx.assistantMessage.error) {
yield* abort() yield* abort()
} }
yield* Effect.logInfo("session processor finished", {
aborted,
blocked: ctx.blocked,
compact: ctx.needsCompaction,
sessionID: ctx.sessionID,
})
if (ctx.needsCompaction) return "compact" if (ctx.needsCompaction) return "compact"
if (ctx.blocked || ctx.assistantMessage.error || aborted) return "stop" if (ctx.blocked || ctx.assistantMessage.error || aborted) return "stop"
return "continue" return "continue"
+25 -7
View File
@@ -103,7 +103,7 @@ export namespace SessionPrompt {
const instruction = yield* Instruction.Service const instruction = yield* Instruction.Service
const state = yield* InstanceState.make( const state = yield* InstanceState.make(
Effect.fn("SessionPrompt.state")(function* () { Effect.fnUntraced(function* () {
const runners = new Map<string, Runner<MessageV2.WithParts>>() const runners = new Map<string, Runner<MessageV2.WithParts>>()
yield* Effect.addFinalizer( yield* Effect.addFinalizer(
Effect.fnUntraced(function* () { Effect.fnUntraced(function* () {
@@ -1340,12 +1340,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
let structured: unknown | undefined let structured: unknown | undefined
let step = 0 let step = 0
const session = yield* sessions.get(sessionID) const session = yield* sessions.get(sessionID)
yield* Effect.annotateCurrentSpan({ sessionID })
while (true) { while (true) {
yield* status.set(sessionID, { type: "busy" }) yield* status.set(sessionID, { type: "busy" })
log.info("loop", { step, sessionID }) log.info("loop", { step, sessionID })
let msgs = yield* MessageV2.filterCompactedEffect(sessionID) let msgs = yield* MessageV2.filterCompactedEffect(sessionID).pipe(
Effect.withSpan("SessionPrompt.loadMessages"),
)
let lastUser: MessageV2.User | undefined let lastUser: MessageV2.User | undefined
let lastAssistant: MessageV2.Assistant | undefined let lastAssistant: MessageV2.Assistant | undefined
@@ -1398,13 +1401,20 @@ NOTE: At any point in time through this workflow you should feel free to ask the
} }
if (task?.type === "compaction") { if (task?.type === "compaction") {
const result = yield* compaction.process({ yield* Effect.logWarning("session compaction task", {
messages: msgs,
parentID: lastUser.id,
sessionID,
auto: task.auto, auto: task.auto,
overflow: task.overflow, overflow: task.overflow,
sessionID,
}) })
const result = yield* compaction
.process({
messages: msgs,
parentID: lastUser.id,
sessionID,
auto: task.auto,
overflow: task.overflow,
})
.pipe(Effect.withSpan("SessionPrompt.compaction"))
if (result === "stop") break if (result === "stop") break
continue continue
} }
@@ -1414,6 +1424,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
lastFinished.summary !== true && lastFinished.summary !== true &&
(yield* compaction.isOverflow({ tokens: lastFinished.tokens, model })) (yield* compaction.isOverflow({ tokens: lastFinished.tokens, model }))
) { ) {
yield* Effect.logWarning("session overflow detected", { modelID: model.id, sessionID, step })
yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true }) yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true })
continue continue
} }
@@ -1429,6 +1440,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const maxSteps = agent.steps ?? Infinity const maxSteps = agent.steps ?? Infinity
const isLastStep = step >= maxSteps const isLastStep = step >= maxSteps
msgs = yield* insertReminders({ messages: msgs, agent, session }) msgs = yield* insertReminders({ messages: msgs, agent, session })
yield* Effect.logInfo("session turn", {
agent: agent.name,
modelID: model.id,
providerID: model.providerID,
sessionID,
step,
})
const msg: MessageV2.Assistant = { const msg: MessageV2.Assistant = {
id: MessageID.ascending(), id: MessageID.ascending(),
@@ -1503,7 +1521,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
Effect.promise(() => SystemPrompt.environment(model)), Effect.promise(() => SystemPrompt.environment(model)),
instruction.system().pipe(Effect.orDie), instruction.system().pipe(Effect.orDie),
Effect.promise(() => MessageV2.toModelMessages(msgs, model)), Effect.promise(() => MessageV2.toModelMessages(msgs, model)),
]) ]).pipe(Effect.withSpan("SessionPrompt.buildInput"))
const system = [...env, ...(skills ? [skills] : []), ...instructions] const system = [...env, ...(skills ? [skills] : []), ...instructions]
const format = lastUser.format ?? { type: "text" as const } const format = lastUser.format ?? { type: "text" as const }
if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
@@ -82,25 +82,6 @@ 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. 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 # 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. 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.
+3 -1
View File
@@ -58,7 +58,9 @@ export namespace SessionStatus {
const bus = yield* Bus.Service const bus = yield* Bus.Service
const state = yield* InstanceState.make( const state = yield* InstanceState.make(
Effect.fn("SessionStatus.state")(() => Effect.succeed(new Map<SessionID, Info>())), Effect.fnUntraced(function* () {
return new Map<SessionID, Info>()
}),
) )
const get = Effect.fn("SessionStatus.get")(function* (sessionID: SessionID) { const get = Effect.fn("SessionStatus.get")(function* (sessionID: SessionID) {
+2 -2
View File
@@ -99,8 +99,8 @@ export namespace SessionSummary {
if (part.type === "step-finish" && part.snapshot) to = part.snapshot if (part.type === "step-finish" && part.snapshot) to = part.snapshot
} }
} }
if (from && to) return yield* snapshot.diffFull(from, to) if (!from || !to || from === to) return []
return [] return yield* snapshot.diffFull(from, to)
}) })
const summarize = Effect.fn("SessionSummary.summarize")(function* (input: { const summarize = Effect.fn("SessionSummary.summarize")(function* (input: {
+1 -1
View File
@@ -82,7 +82,7 @@ export namespace Todo {
}), }),
) )
const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
const { runPromise } = makeRuntime(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
export async function update(input: { sessionID: SessionID; todos: Info[] }) { export async function update(input: { sessionID: SessionID; todos: Info[] }) {
+1 -1
View File
@@ -144,7 +144,7 @@ export namespace ShareNext {
} }
const state: InstanceState<State> = yield* InstanceState.make<State>( const state: InstanceState<State> = yield* InstanceState.make<State>(
Effect.fn("ShareNext.state")(function* (_ctx) { Effect.fnUntraced(function* (_ctx) {
const cache: State = { queue: new Map(), scope: yield* Scope.make() } const cache: State = { queue: new Map(), scope: yield* Scope.make() }
yield* Effect.addFinalizer(() => yield* Effect.addFinalizer(() =>
+1 -1
View File
@@ -197,7 +197,7 @@ export namespace Skill {
const bus = yield* Bus.Service const bus = yield* Bus.Service
const fsys = yield* AppFileSystem.Service const fsys = yield* AppFileSystem.Service
const state = yield* InstanceState.make( const state = yield* InstanceState.make(
Effect.fn("Skill.state")(function* (ctx) { Effect.fnUntraced(function* (ctx) {
const s: State = { skills: {}, dirs: new Set() } const s: State = { skills: {}, dirs: new Set() }
yield* loadSkills(s, config, discovery, bus, fsys, ctx.directory, ctx.worktree) yield* loadSkills(s, config, discovery, bus, fsys, ctx.directory, ctx.worktree)
return s return s
+200 -60
View File
@@ -1,4 +1,3 @@
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, Layer, Schedule, Semaphore, ServiceMap, Stream } from "effect" import { Cause, Duration, Effect, Layer, Schedule, Semaphore, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import path from "path" import path from "path"
@@ -82,7 +81,7 @@ export namespace Snapshot {
} }
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("Snapshot.state")(function* (ctx) { Effect.fnUntraced(function* (ctx) {
const state = { const state = {
directory: ctx.directory, directory: ctx.directory,
worktree: ctx.worktree, worktree: ctx.worktree,
@@ -150,7 +149,7 @@ export namespace Snapshot {
yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie) yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie)
}) })
const add = Effect.fnUntraced(function* () { const add = Effect.fn("Snapshot.add")(function* () {
yield* sync() yield* sync()
const [diff, other] = yield* Effect.all( const [diff, other] = yield* Effect.all(
[ [
@@ -203,7 +202,7 @@ export namespace Snapshot {
} }
}) })
const cleanup = Effect.fnUntraced(function* () { const cleanup = Effect.fn("Snapshot.cleanup")(function* () {
return yield* locked( return yield* locked(
Effect.gen(function* () { Effect.gen(function* () {
if (!(yield* enabled())) return if (!(yield* enabled())) return
@@ -221,7 +220,7 @@ export namespace Snapshot {
) )
}) })
const track = Effect.fnUntraced(function* () { const track = Effect.fn("Snapshot.track")(function* () {
return yield* locked( return yield* locked(
Effect.gen(function* () { Effect.gen(function* () {
if (!(yield* enabled())) return if (!(yield* enabled())) return
@@ -238,7 +237,9 @@ export namespace Snapshot {
log.info("initialized") log.info("initialized")
} }
yield* add() yield* add()
const result = yield* git(args(["write-tree"]), { cwd: state.directory }) const result = yield* git(args(["write-tree"]), { cwd: state.directory }).pipe(
Effect.withSpan("Snapshot.writeTree"),
)
const hash = result.text.trim() const hash = result.text.trim()
log.info("tracking", { hash, cwd: state.directory, git: state.gitdir }) log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
return hash return hash
@@ -246,7 +247,7 @@ export namespace Snapshot {
) )
}) })
const patch = Effect.fnUntraced(function* (hash: string) { const patch = Effect.fn("Snapshot.patch")(function* (hash: string) {
return yield* locked( return yield* locked(
Effect.gen(function* () { Effect.gen(function* () {
yield* add() yield* add()
@@ -273,7 +274,7 @@ export namespace Snapshot {
) )
}) })
const restore = Effect.fnUntraced(function* (snapshot: string) { const restore = Effect.fn("Snapshot.restore")(function* (snapshot: string) {
return yield* locked( return yield* locked(
Effect.gen(function* () { Effect.gen(function* () {
log.info("restore", { commit: snapshot }) log.info("restore", { commit: snapshot })
@@ -299,7 +300,7 @@ export namespace Snapshot {
) )
}) })
const revert = Effect.fnUntraced(function* (patches: Snapshot.Patch[]) { const revert = Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
return yield* locked( return yield* locked(
Effect.gen(function* () { Effect.gen(function* () {
const ops: { hash: string; file: string; rel: string }[] = [] const ops: { hash: string; file: string; rel: string }[] = []
@@ -414,7 +415,7 @@ export namespace Snapshot {
) )
}) })
const diff = Effect.fnUntraced(function* (hash: string) { const diff = Effect.fn("Snapshot.diff")(function* (hash: string) {
return yield* locked( return yield* locked(
Effect.gen(function* () { Effect.gen(function* () {
yield* add() yield* add()
@@ -434,16 +435,156 @@ export namespace Snapshot {
) )
}) })
const diffFull = Effect.fnUntraced(function* (from: string, to: string) { const diffFull = Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
return yield* locked( return yield* locked(
Effect.gen(function* () { Effect.gen(function* () {
type Row = {
file: string
status: "added" | "deleted" | "modified"
binary: boolean
additions: number
deletions: number
}
type Ref = {
file: string
side: "before" | "after"
ref: string
}
const show = Effect.fn("Snapshot.show")(function* (row: Row) {
if (row.binary) return ["", ""]
if (row.status === "added") {
return [
"",
yield* git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(
Effect.map((item) => item.text),
),
]
}
if (row.status === "deleted") {
return [
yield* git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe(
Effect.map((item) => item.text),
),
"",
]
}
return yield* Effect.all(
[
git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
],
{ concurrency: 2 },
)
})
const load = Effect.fn("Snapshot.load")(
function* (rows: Row[]) {
const refs = rows.flatMap((row) => {
if (row.binary) return []
if (row.status === "added")
return [{ file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref]
if (row.status === "deleted") {
return [{ file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref]
}
return [
{ file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref,
{ file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref,
]
})
if (!refs.length) return new Map<string, { before: string; after: string }>()
const proc = ChildProcess.make("git", [...cfg, ...args(["cat-file", "--batch"])], {
cwd: state.directory,
extendEnv: true,
stdin: Stream.make(new TextEncoder().encode(refs.map((item) => item.ref).join("\n") + "\n")),
})
const handle = yield* spawner.spawn(proc)
const [out, err] = yield* Effect.all(
[Stream.mkUint8Array(handle.stdout), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const code = yield* handle.exitCode
if (code !== 0) {
log.info("git cat-file --batch failed during snapshot diff, falling back to per-file git show", {
stderr: err,
refs: refs.length,
})
return
}
const fail = (msg: string, extra?: Record<string, string>) => {
log.info(msg, { ...extra, refs: refs.length })
return undefined
}
const map = new Map<string, { before: string; after: string }>()
const dec = new TextDecoder()
let i = 0
// Parse the default `git cat-file --batch` stream: one header line,
// then exactly `size` bytes of blob content, then a trailing newline.
for (const ref of refs) {
let end = i
while (end < out.length && out[end] !== 10) end += 1
if (end >= out.length) {
return fail(
"git cat-file --batch returned a truncated header during snapshot diff, falling back to per-file git show",
)
}
const head = dec.decode(out.slice(i, end))
i = end + 1
const hit = map.get(ref.file) ?? { before: "", after: "" }
if (head.endsWith(" missing")) {
map.set(ref.file, hit)
continue
}
const match = head.match(/^[0-9a-f]+ blob (\d+)$/)
if (!match) {
return fail(
"git cat-file --batch returned an unexpected header during snapshot diff, falling back to per-file git show",
{ head },
)
}
const size = Number(match[1])
if (!Number.isInteger(size) || size < 0 || i + size >= out.length || out[i + size] !== 10) {
return fail(
"git cat-file --batch returned truncated content during snapshot diff, falling back to per-file git show",
{ head },
)
}
const text = dec.decode(out.slice(i, i + size))
if (ref.side === "before") hit.before = text
if (ref.side === "after") hit.after = text
map.set(ref.file, hit)
i += size + 1
}
if (i !== out.length) {
return fail(
"git cat-file --batch returned trailing data during snapshot diff, falling back to per-file git show",
)
}
return map
},
Effect.scoped,
Effect.catch(() =>
Effect.succeed<Map<string, { before: string; after: string }> | undefined>(undefined),
),
)
const result: Snapshot.FileDiff[] = [] const result: Snapshot.FileDiff[] = []
const status = new Map<string, "added" | "deleted" | "modified">() const status = new Map<string, "added" | "deleted" | "modified">()
const statuses = yield* git( const statuses = yield* git(
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])], [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
{ cwd: state.directory }, { cwd: state.directory },
) ).pipe(Effect.withSpan("Snapshot.diffStatus"))
for (const line of statuses.text.trim().split("\n")) { for (const line of statuses.text.trim().split("\n")) {
if (!line) continue if (!line) continue
@@ -457,32 +598,47 @@ export namespace Snapshot {
{ {
cwd: state.directory, cwd: state.directory,
}, },
) ).pipe(Effect.withSpan("Snapshot.diffNumstat"))
for (const line of numstat.text.trim().split("\n")) { const rows = numstat.text
if (!line) continue .trim()
const [adds, dels, file] = line.split("\t") .split("\n")
if (!file) continue .filter(Boolean)
const binary = adds === "-" && dels === "-" .flatMap((line) => {
const [before, after] = binary const [adds, dels, file] = line.split("\t")
? ["", ""] if (!file) return []
: yield* Effect.all( const binary = adds === "-" && dels === "-"
[ const additions = binary ? 0 : parseInt(adds)
git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)), const deletions = binary ? 0 : parseInt(dels)
git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)), return [
], {
{ concurrency: 2 }, file,
) status: status.get(file) ?? "modified",
const additions = binary ? 0 : parseInt(adds) binary,
const deletions = binary ? 0 : parseInt(dels) additions: Number.isFinite(additions) ? additions : 0,
result.push({ deletions: Number.isFinite(deletions) ? deletions : 0,
file, } satisfies Row,
before, ]
after,
additions: Number.isFinite(additions) ? additions : 0,
deletions: Number.isFinite(deletions) ? deletions : 0,
status: status.get(file) ?? "modified",
}) })
const step = 100
// Keep batches bounded so a large diff does not buffer every blob at once.
for (let i = 0; i < rows.length; i += step) {
const run = rows.slice(i, i + step)
const text = yield* load(run)
for (const row of run) {
const hit = text?.get(row.file) ?? { before: "", after: "" }
const [before, after] = row.binary ? ["", ""] : text ? [hit.before, hit.after] : yield* show(row)
result.push({
file: row.file,
before,
after,
additions: row.additions,
deletions: row.deletions,
status: row.status,
})
}
} }
return result return result
@@ -505,30 +661,14 @@ export namespace Snapshot {
) )
return Service.of({ return Service.of({
init: Effect.fn("Snapshot.init")(function* () { init: () => InstanceState.get(state).pipe(Effect.asVoid),
yield* InstanceState.get(state) cleanup: () => InstanceState.useEffect(state, (s) => s.cleanup()),
}), track: () => InstanceState.useEffect(state, (s) => s.track()),
cleanup: Effect.fn("Snapshot.cleanup")(function* () { patch: (hash: string) => InstanceState.useEffect(state, (s) => s.patch(hash)),
return yield* InstanceState.useEffect(state, (s) => s.cleanup()) restore: (snapshot: string) => InstanceState.useEffect(state, (s) => s.restore(snapshot)),
}), revert: (patches: Snapshot.Patch[]) => InstanceState.useEffect(state, (s) => s.revert(patches)),
track: Effect.fn("Snapshot.track")(function* () { diff: (hash: string) => InstanceState.useEffect(state, (s) => s.diff(hash)),
return yield* InstanceState.useEffect(state, (s) => s.track()) diffFull: (from: string, to: string) => InstanceState.useEffect(state, (s) => s.diffFull(from, to)),
}),
patch: Effect.fn("Snapshot.patch")(function* (hash: string) {
return yield* InstanceState.useEffect(state, (s) => s.patch(hash))
}),
restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) {
return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot))
}),
revert: Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
return yield* InstanceState.useEffect(state, (s) => s.revert(patches))
}),
diff: Effect.fn("Snapshot.diff")(function* (hash: string) {
return yield* InstanceState.useEffect(state, (s) => s.diff(hash))
}),
diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to))
}),
}) })
}), }),
) )
@@ -1,7 +1,8 @@
import path from "path" import path from "path"
import { Effect } from "effect"
import type { Tool } from "./tool" import type { Tool } from "./tool"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Filesystem } from "@/util/filesystem" import { AppFileSystem } from "../filesystem"
type Kind = "file" | "directory" type Kind = "file" | "directory"
@@ -15,14 +16,14 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string
if (options?.bypass) return if (options?.bypass) return
const full = process.platform === "win32" ? Filesystem.normalizePath(target) : target const full = process.platform === "win32" ? AppFileSystem.normalizePath(target) : target
if (Instance.containsPath(full)) return if (Instance.containsPath(full)) return
const kind = options?.kind ?? "file" const kind = options?.kind ?? "file"
const dir = kind === "directory" ? full : path.dirname(full) const dir = kind === "directory" ? full : path.dirname(full)
const glob = const glob =
process.platform === "win32" process.platform === "win32"
? Filesystem.normalizePathPattern(path.join(dir, "*")) ? AppFileSystem.normalizePathPattern(path.join(dir, "*"))
: path.join(dir, "*").replaceAll("\\", "/") : path.join(dir, "*").replaceAll("\\", "/")
await ctx.ask({ await ctx.ask({
@@ -35,3 +36,11 @@ 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))
})
+228 -192
View File
@@ -1,16 +1,17 @@
import z from "zod" import z from "zod"
import { Effect, Scope } from "effect"
import { createReadStream } from "fs" import { createReadStream } from "fs"
import * as fs from "fs/promises" import { open } from "fs/promises"
import * as path from "path" import * as path from "path"
import { createInterface } from "readline" import { createInterface } from "readline"
import { Tool } from "./tool" import { Tool } from "./tool"
import { AppFileSystem } from "../filesystem"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { FileTime } from "../file/time" import { FileTime } from "../file/time"
import DESCRIPTION from "./read.txt" import DESCRIPTION from "./read.txt"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { assertExternalDirectory } from "./external-directory" import { assertExternalDirectoryEffect } from "./external-directory"
import { Instruction } from "../session/instruction" import { Instruction } from "../session/instruction"
import { Filesystem } from "../util/filesystem"
const DEFAULT_READ_LIMIT = 2000 const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000 const MAX_LINE_LENGTH = 2000
@@ -18,222 +19,257 @@ const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
const MAX_BYTES = 50 * 1024 const MAX_BYTES = 50 * 1024
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB` const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
export const ReadTool = Tool.define("read", { const parameters = z.object({
description: DESCRIPTION, filePath: z.string().describe("The absolute path to the file or directory to read"),
parameters: z.object({ offset: z.coerce.number().describe("The line number to start reading from (1-indexed)").optional(),
filePath: z.string().describe("The absolute path to the file or directory to read"), limit: z.coerce.number().describe("The maximum number of lines to read (defaults to 2000)").optional(),
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)
const stat = Filesystem.stat(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
await assertExternalDirectory(ctx, filepath, { const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) {
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 dir = path.dirname(filepath)
const base = path.basename(filepath) const base = path.basename(filepath)
const items = yield* fs.readDirectory(dir).pipe(
const suggestions = await fs Effect.map((items) =>
.readdir(dir) items
.then((entries) =>
entries
.filter( .filter(
(entry) => (item) =>
entry.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(entry.toLowerCase()), item.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(item.toLowerCase()),
) )
.map((entry) => path.join(dir, entry)) .map((item) => path.join(dir, item))
.slice(0, 3), .slice(0, 3),
) ),
.catch(() => []) Effect.catch(() => Effect.succeed([] as string[])),
)
if (suggestions.length > 0) { if (items.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}\n\nDid you mean one of these?\n${items.join("\n")}`),
)
} }
throw new Error(`File not found: ${filepath}`) return yield* Effect.fail(new Error(`File not found: ${filepath}`))
} })
if (stat.isDirectory()) { const list = Effect.fn("ReadTool.list")(function* (filepath: string) {
const dirents = await fs.readdir(filepath, { withFileTypes: true }) const items = yield* fs.readDirectoryEntries(filepath)
const entries = await Promise.all( return yield* Effect.forEach(
dirents.map(async (dirent) => { items,
if (dirent.isDirectory()) return dirent.name + "/" Effect.fnUntraced(function* (item) {
if (dirent.isSymbolicLink()) { if (item.type === "directory") return item.name + "/"
const target = await fs.stat(path.join(filepath, dirent.name)).catch(() => undefined) if (item.type !== "symlink") return item.name
if (target?.isDirectory()) return dirent.name + "/"
} const target = yield* fs
return dirent.name .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: {},
}), }),
) )
entries.sort((a, b) => a.localeCompare(b))
const limit = params.limit ?? DEFAULT_READ_LIMIT if (!stat) return yield* miss(filepath)
const offset = params.offset ?? 1
const start = offset - 1
const sliced = entries.slice(start, start + limit)
const truncated = start + sliced.length < entries.length
const output = [ if (stat.type === "Directory") {
`<path>${filepath}</path>`, const items = yield* list(filepath)
`<type>directory</type>`, const limit = params.limit ?? DEFAULT_READ_LIMIT
`<entries>`, const offset = params.offset ?? 1
sliced.join("\n"), const start = offset - 1
truncated const sliced = items.slice(start, start + limit)
? `\n(Showing ${sliced.length} of ${entries.length} entries. Use 'offset' parameter to read beyond entry ${offset + sliced.length})` const truncated = start + sliced.length < items.length
: `\n(${entries.length} entries)`,
`</entries>`, return {
].join("\n") 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>"].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>`
}
return { return {
title, title,
output, output,
metadata: { metadata: {
preview: sliced.slice(0, 20).join("\n"), preview: file.raw.slice(0, 20).join("\n"),
truncated, truncated,
loaded: [] as string[], loaded: loaded.map((item) => item.filepath),
}, },
} }
}
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 { return {
title, description: DESCRIPTION,
output, parameters,
metadata: { async execute(params: z.infer<typeof parameters>, ctx) {
preview, return Effect.runPromise(run(params, ctx).pipe(Effect.orDie))
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> { async function isBinaryFile(filepath: string, fileSize: number): Promise<boolean> {
const ext = path.extname(filepath).toLowerCase() const ext = path.extname(filepath).toLowerCase()
@@ -274,7 +310,7 @@ async function isBinaryFile(filepath: string, fileSize: number): Promise<boolean
if (fileSize === 0) return false if (fileSize === 0) return false
const fh = await fs.open(filepath, "r") const fh = await open(filepath, "r")
try { try {
const sampleSize = Math.min(4096, fileSize) const sampleSize = Math.min(4096, fileSize)
const bytes = Buffer.alloc(sampleSize) const bytes = Buffer.alloc(sampleSize)
+23 -2
View File
@@ -34,6 +34,11 @@ import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { Env } from "../env" import { Env } from "../env"
import { Question } from "../question" 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"
export namespace ToolRegistry { export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" }) const log = Log.create({ service: "tool.registry" })
@@ -56,7 +61,18 @@ export namespace ToolRegistry {
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ToolRegistry") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ToolRegistry") {}
export const layer: Layer.Layer<Service, never, Config.Service | Plugin.Service | Question.Service> = Layer.effect( 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, Service,
Effect.gen(function* () { Effect.gen(function* () {
const config = yield* Config.Service const config = yield* Config.Service
@@ -66,7 +82,7 @@ export namespace ToolRegistry {
Effect.isEffect(tool) ? tool : Effect.succeed(tool) Effect.isEffect(tool) ? tool : Effect.succeed(tool)
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) { Effect.fnUntraced(function* (ctx) {
const custom: Tool.Info[] = [] const custom: Tool.Info[] = []
function fromPlugin(id: string, def: ToolDefinition): Tool.Info { function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
@@ -222,6 +238,11 @@ export namespace ToolRegistry {
Layer.provide(Config.defaultLayer), Layer.provide(Config.defaultLayer),
Layer.provide(Plugin.defaultLayer), Layer.provide(Plugin.defaultLayer),
Layer.provide(Question.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),
), ),
), ),
) )
+42 -25
View File
@@ -1,31 +1,48 @@
import z from "zod" import z from "zod"
import { Effect } from "effect"
import { Tool } from "./tool" import { Tool } from "./tool"
import DESCRIPTION_WRITE from "./todowrite.txt" import DESCRIPTION_WRITE from "./todowrite.txt"
import { Todo } from "../session/todo" import { Todo } from "../session/todo"
export const TodoWriteTool = Tool.define("todowrite", { const parameters = z.object({
description: DESCRIPTION_WRITE, todos: z.array(z.object(Todo.Info.shape)).describe("The updated todo list"),
parameters: z.object({
todos: z.array(z.object(Todo.Info.shape)).describe("The updated todo list"),
}),
async execute(params, ctx) {
await ctx.ask({
permission: "todowrite",
patterns: ["*"],
always: ["*"],
metadata: {},
})
await Todo.update({
sessionID: ctx.sessionID,
todos: params.todos,
})
return {
title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
output: JSON.stringify(params.todos, null, 2),
metadata: {
todos: params.todos,
},
}
},
}) })
type Metadata = {
todos: Todo.Info[]
}
export const TodoWriteTool = Tool.defineEffect<typeof parameters, Metadata, Todo.Service>(
"todowrite",
Effect.gen(function* () {
const todo = yield* Todo.Service
return {
description: DESCRIPTION_WRITE,
parameters,
async execute(params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) {
await ctx.ask({
permission: "todowrite",
patterns: ["*"],
always: ["*"],
metadata: {},
})
await todo
.update({
sessionID: ctx.sessionID,
todos: params.todos,
})
.pipe(Effect.runPromise)
return {
title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
output: JSON.stringify(params.todos, null, 2),
metadata: {
todos: params.todos,
},
}
},
} satisfies Tool.Def<typeof parameters, Metadata>
}),
)
@@ -25,6 +25,7 @@ import { Npm } from "../../src/npm"
const emptyAccount = Layer.mock(Account.Service)({ const emptyAccount = Layer.mock(Account.Service)({
active: () => Effect.succeed(Option.none()), active: () => Effect.succeed(Option.none()),
activeOrg: () => Effect.succeed(Option.none()),
}) })
const emptyAuth = Layer.mock(Auth.Service)({ const emptyAuth = Layer.mock(Auth.Service)({
@@ -282,6 +283,21 @@ test("resolves env templates in account config with account token", async () =>
active_org_id: OrgID.make("org-1"), 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: () => config: () =>
Effect.succeed( Effect.succeed(
Option.some({ Option.some({
+1
View File
@@ -33,6 +33,7 @@ process.env["XDG_DATA_HOME"] = path.join(dir, "share")
process.env["XDG_CACHE_HOME"] = path.join(dir, "cache") process.env["XDG_CACHE_HOME"] = path.join(dir, "cache")
process.env["XDG_CONFIG_HOME"] = path.join(dir, "config") process.env["XDG_CONFIG_HOME"] = path.join(dir, "config")
process.env["XDG_STATE_HOME"] = path.join(dir, "state") process.env["XDG_STATE_HOME"] = path.join(dir, "state")
delete process.env["OPENCODE_OTLP_BASE_URL"]
process.env["OPENCODE_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json") process.env["OPENCODE_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json")
// Set test home directory to isolate tests from user's actual home directory // Set test home directory to isolate tests from user's actual home directory
@@ -1,12 +1,22 @@
import { test, expect } from "bun:test" import { test, expect } from "bun:test"
import { mkdir, unlink } from "fs/promises"
import path from "path" import path from "path"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { Global } from "../../src/global"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Plugin } from "../../src/plugin/index"
import { Provider } from "../../src/provider/provider" import { Provider } from "../../src/provider/provider"
import { ProviderID, ModelID } from "../../src/provider/schema" import { ProviderID, ModelID } from "../../src/provider/schema"
import { Filesystem } from "../../src/util/filesystem"
import { Env } from "../../src/env" import { Env } from "../../src/env"
function paid(providers: Awaited<ReturnType<typeof Provider.list>>) {
const item = providers[ProviderID.make("opencode")]
expect(item).toBeDefined()
return Object.values(item.models).filter((model) => model.cost.input > 0).length
}
test("provider loaded from env variable", async () => { test("provider loaded from env variable", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
init: async (dir) => { init: async (dir) => {
@@ -2282,3 +2292,203 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => {
}, },
}) })
}) })
test("plugin config providers persist after instance dispose", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const root = path.join(dir, ".opencode", "plugin")
await mkdir(root, { recursive: true })
await Bun.write(
path.join(root, "demo-provider.ts"),
[
"export default {",
' id: "demo.plugin-provider",',
" server: async () => ({",
" async config(cfg) {",
" cfg.provider ??= {}",
" cfg.provider.demo = {",
' name: "Demo Provider",',
' npm: "@ai-sdk/openai-compatible",',
' api: "https://example.com/v1",',
" models: {",
" chat: {",
' name: "Demo Chat",',
" tool_call: true,",
" limit: { context: 128000, output: 4096 },",
" },",
" },",
" }",
" },",
" }),",
"}",
"",
].join("\n"),
)
},
})
const first = await Instance.provide({
directory: tmp.path,
fn: async () => {
await Plugin.init()
return Provider.list()
},
})
expect(first[ProviderID.make("demo")]).toBeDefined()
expect(first[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined()
await Instance.disposeAll()
const second = await Instance.provide({
directory: tmp.path,
fn: async () => Provider.list(),
})
expect(second[ProviderID.make("demo")]).toBeDefined()
expect(second[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined()
})
test("plugin config enabled and disabled providers are honored", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const root = path.join(dir, ".opencode", "plugin")
await mkdir(root, { recursive: true })
await Bun.write(
path.join(root, "provider-filter.ts"),
[
"export default {",
' id: "demo.provider-filter",',
" server: async () => ({",
" async config(cfg) {",
' cfg.enabled_providers = ["anthropic", "openai"]',
' cfg.disabled_providers = ["openai"]',
" },",
" }),",
"}",
"",
].join("\n"),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("ANTHROPIC_API_KEY", "test-anthropic-key")
Env.set("OPENAI_API_KEY", "test-openai-key")
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.openai]).toBeUndefined()
},
})
})
test("opencode loader keeps paid models when config apiKey is present", async () => {
await using base = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
},
})
const none = await Instance.provide({
directory: base.path,
fn: async () => paid(await Provider.list()),
})
await using keyed = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
opencode: {
options: {
apiKey: "test-key",
},
},
},
}),
)
},
})
const keyedCount = await Instance.provide({
directory: keyed.path,
fn: async () => paid(await Provider.list()),
})
expect(none).toBe(0)
expect(keyedCount).toBeGreaterThan(0)
})
test("opencode loader keeps paid models when auth exists", async () => {
await using base = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
},
})
const none = await Instance.provide({
directory: base.path,
fn: async () => paid(await Provider.list()),
})
await using keyed = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
},
})
const authPath = path.join(Global.Path.data, "auth.json")
let prev: string | undefined
try {
prev = await Filesystem.readText(authPath)
} catch {}
try {
await Filesystem.write(
authPath,
JSON.stringify({
opencode: {
type: "api",
key: "test-key",
},
}),
)
const keyedCount = await Instance.provide({
directory: keyed.path,
fn: async () => paid(await Provider.list()),
})
expect(none).toBe(0)
expect(keyedCount).toBeGreaterThan(0)
} finally {
if (prev !== undefined) {
await Filesystem.write(authPath, prev)
}
if (prev === undefined) {
try {
await unlink(authPath)
} catch {}
}
}
})
@@ -1080,7 +1080,7 @@ describe("session.getUsage", () => {
expect(result.tokens.cache.read).toBe(200) expect(result.tokens.cache.read).toBe(200)
}) })
test("handles reasoning tokens", () => { test("separates reasoning tokens from output tokens", () => {
const model = createModel({ context: 100_000, output: 32_000 }) const model = createModel({ context: 100_000, output: 32_000 })
const result = Session.getUsage({ const result = Session.getUsage({
model, model,
@@ -1092,7 +1092,35 @@ describe("session.getUsage", () => {
}, },
}) })
expect(result.tokens.input).toBe(1000)
expect(result.tokens.output).toBe(400)
expect(result.tokens.reasoning).toBe(100) 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", () => { test("handles undefined optional values gracefully", () => {
@@ -16,6 +16,7 @@ import { Provider as ProviderSvc } from "../../src/provider/provider"
import type { Provider } from "../../src/provider/provider" import type { Provider } from "../../src/provider/provider"
import { ModelID, ProviderID } from "../../src/provider/schema" import { ModelID, ProviderID } from "../../src/provider/schema"
import { Question } from "../../src/question" import { Question } from "../../src/question"
import { Todo } from "../../src/session/todo"
import { Session } from "../../src/session" import { Session } from "../../src/session"
import { LLM } from "../../src/session/llm" import { LLM } from "../../src/session/llm"
import { MessageV2 } from "../../src/session/message-v2" import { MessageV2 } from "../../src/session/message-v2"
@@ -162,7 +163,12 @@ function makeHttp() {
status, status,
).pipe(Layer.provideMerge(infra)) ).pipe(Layer.provideMerge(infra))
const question = Question.layer.pipe(Layer.provideMerge(deps)) const question = Question.layer.pipe(Layer.provideMerge(deps))
const registry = ToolRegistry.layer.pipe(Layer.provideMerge(question), Layer.provideMerge(deps)) const todo = Todo.layer.pipe(Layer.provideMerge(deps))
const registry = ToolRegistry.layer.pipe(
Layer.provideMerge(todo),
Layer.provideMerge(question),
Layer.provideMerge(deps),
)
const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps)) const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps))
const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
@@ -39,6 +39,7 @@ import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin" import { Plugin } from "../../src/plugin"
import { Provider as ProviderSvc } from "../../src/provider/provider" import { Provider as ProviderSvc } from "../../src/provider/provider"
import { Question } from "../../src/question" import { Question } from "../../src/question"
import { Todo } from "../../src/session/todo"
import { SessionCompaction } from "../../src/session/compaction" import { SessionCompaction } from "../../src/session/compaction"
import { Instruction } from "../../src/session/instruction" import { Instruction } from "../../src/session/instruction"
import { SessionProcessor } from "../../src/session/processor" import { SessionProcessor } from "../../src/session/processor"
@@ -126,7 +127,12 @@ function makeHttp() {
status, status,
).pipe(Layer.provideMerge(infra)) ).pipe(Layer.provideMerge(infra))
const question = Question.layer.pipe(Layer.provideMerge(deps)) const question = Question.layer.pipe(Layer.provideMerge(deps))
const registry = ToolRegistry.layer.pipe(Layer.provideMerge(question), Layer.provideMerge(deps)) const todo = Todo.layer.pipe(Layer.provideMerge(deps))
const registry = ToolRegistry.layer.pipe(
Layer.provideMerge(todo),
Layer.provideMerge(question),
Layer.provideMerge(deps),
)
const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps)) const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps))
const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
@@ -982,6 +982,98 @@ test("diffFull with new file additions", async () => {
}) })
}) })
test("diffFull with a large interleaved mixed diff", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ids = Array.from({ length: 60 }, (_, i) => i.toString().padStart(3, "0"))
const mod = ids.map((id) => fwd(tmp.path, "mix", `${id}-mod.txt`))
const del = ids.map((id) => fwd(tmp.path, "mix", `${id}-del.txt`))
const add = ids.map((id) => fwd(tmp.path, "mix", `${id}-add.txt`))
const bin = ids.map((id) => fwd(tmp.path, "mix", `${id}-bin.bin`))
await $`mkdir -p ${tmp.path}/mix`.quiet()
await Promise.all([
...mod.map((file, i) => Filesystem.write(file, `before-${ids[i]}\n🙂\nline`)),
...del.map((file, i) => Filesystem.write(file, `gone-${ids[i]}\n你好`)),
...bin.map((file, i) => Filesystem.write(file, new Uint8Array([0, i, 255, i % 251]))),
])
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Promise.all([
...mod.map((file, i) => Filesystem.write(file, `after-${ids[i]}\n🚀\nline`)),
...add.map((file, i) => Filesystem.write(file, `new-${ids[i]}\nこんにちは`)),
...bin.map((file, i) => Filesystem.write(file, new Uint8Array([9, i, 8, i % 251]))),
...del.map((file) => fs.rm(file)),
])
const after = await Snapshot.track()
expect(after).toBeTruthy()
const diffs = await Snapshot.diffFull(before!, after!)
expect(diffs).toHaveLength(ids.length * 4)
const map = new Map(diffs.map((item) => [item.file, item]))
for (let i = 0; i < ids.length; i++) {
const m = map.get(fwd("mix", `${ids[i]}-mod.txt`))
expect(m).toBeDefined()
expect(m!.before).toBe(`before-${ids[i]}\n🙂\nline`)
expect(m!.after).toBe(`after-${ids[i]}\n🚀\nline`)
expect(m!.status).toBe("modified")
const d = map.get(fwd("mix", `${ids[i]}-del.txt`))
expect(d).toBeDefined()
expect(d!.before).toBe(`gone-${ids[i]}\n你好`)
expect(d!.after).toBe("")
expect(d!.status).toBe("deleted")
const a = map.get(fwd("mix", `${ids[i]}-add.txt`))
expect(a).toBeDefined()
expect(a!.before).toBe("")
expect(a!.after).toBe(`new-${ids[i]}\nこんにちは`)
expect(a!.status).toBe("added")
const b = map.get(fwd("mix", `${ids[i]}-bin.bin`))
expect(b).toBeDefined()
expect(b!.before).toBe("")
expect(b!.after).toBe("")
expect(b!.additions).toBe(0)
expect(b!.deletions).toBe(0)
expect(b!.status).toBe("modified")
}
},
})
})
test("diffFull preserves git diff order across batch boundaries", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ids = Array.from({ length: 140 }, (_, i) => i.toString().padStart(3, "0"))
await $`mkdir -p ${tmp.path}/order`.quiet()
await Promise.all(ids.map((id) => Filesystem.write(`${tmp.path}/order/${id}.txt`, `before-${id}`)))
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Promise.all(ids.map((id) => Filesystem.write(`${tmp.path}/order/${id}.txt`, `after-${id}`)))
const after = await Snapshot.track()
expect(after).toBeTruthy()
const expected = ids.map((id) => `order/${id}.txt`)
const diffs = await Snapshot.diffFull(before!, after!)
expect(diffs.map((item) => item.file)).toEqual(expected)
},
})
})
test("diffFull with file modifications", async () => { test("diffFull with file modifications", async () => {
await using tmp = await bootstrap() await using tmp = await bootstrap()
await Instance.provide({ await Instance.provide({
+389 -467
View File
@@ -1,12 +1,20 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect } from "bun:test"
import { Cause, Effect, Exit, Layer } from "effect"
import path from "path" import path from "path"
import { ReadTool } from "../../src/tool/read"
import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
import { Permission } from "../../src/permission"
import { Agent } from "../../src/agent/agent" 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 { 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 { Filesystem } from "../../src/util/filesystem"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
@@ -25,173 +33,171 @@ const ctx = {
ask: async () => {}, 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 full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p)
const glob = (p: string) => const glob = (p: string) =>
process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/") 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", () => { describe("tool.read external_directory permission", () => {
test("allows reading absolute path inside project directory", async () => { it.live("allows reading absolute path inside project directory", () =>
await using tmp = await tmpdir({ Effect.gen(function* () {
init: async (dir) => { const dir = yield* tmpdirScoped()
await Bun.write(path.join(dir, "test.txt"), "hello world") yield* put(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")
},
})
})
test("allows reading file in subdirectory inside project directory", async () => { const result = yield* exec(dir, { filePath: path.join(dir, "test.txt") })
await using tmp = await tmpdir({ expect(result.output).toContain("hello world")
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")
},
})
})
test("asks for external_directory permission when reading absolute path outside project", async () => { it.live("allows reading file in subdirectory inside project directory", () =>
await using outerTmp = await tmpdir({ Effect.gen(function* () {
init: async (dir) => { const dir = yield* tmpdirScoped()
await Bun.write(path.join(dir, "secret.txt"), "secret data") yield* put(path.join(dir, "subdir", "test.txt"), "nested content")
},
}) const result = yield* exec(dir, { filePath: path.join(dir, "subdir", "test.txt") })
await using tmp = await tmpdir({ git: true }) expect(result.output).toContain("nested content")
await Instance.provide({ }),
directory: tmp.path, )
fn: async () => {
const read = await ReadTool.init() it.live("asks for external_directory permission when reading absolute path outside project", () =>
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] Effect.gen(function* () {
const testCtx = { const outer = yield* tmpdirScoped()
...ctx, const dir = yield* tmpdirScoped({ git: true })
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => { yield* put(path.join(outer, "secret.txt"), "secret data")
requests.push(req)
}, const { items, next } = asks()
}
await read.execute({ filePath: path.join(outerTmp.path, "secret.txt") }, testCtx) yield* exec(dir, { filePath: path.join(outer, "secret.txt") }, next)
const extDirReq = requests.find((r) => r.permission === "external_directory") const ext = items.find((item) => item.permission === "external_directory")
expect(extDirReq).toBeDefined() expect(ext).toBeDefined()
expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp.path, "*"))) expect(ext!.patterns).toContain(glob(path.join(outer, "*")))
}, }),
}) )
})
if (process.platform === "win32") { if (process.platform === "win32") {
test("normalizes read permission paths on Windows", async () => { it.live("normalizes read permission paths on Windows", () =>
await using tmp = await tmpdir({ Effect.gen(function* () {
git: true, const dir = yield* tmpdirScoped({ git: true })
init: async (dir) => { yield* put(path.join(dir, "test.txt"), "hello world")
await Bun.write(path.join(dir, "test.txt"), "hello world")
}, const { items, next } = asks()
}) const target = path.join(dir, "test.txt")
await Instance.provide({ const alt = target
directory: tmp.path, .replace(/^[A-Za-z]:/, "")
fn: async () => { .replaceAll("\\", "/")
const read = await ReadTool.init() .toLowerCase()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = { yield* exec(dir, { filePath: alt }, next)
...ctx, const read = items.find((item) => item.permission === "read")
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => { expect(read).toBeDefined()
requests.push(req) expect(read!.patterns).toEqual([full(target)])
}, }),
} )
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)])
},
})
})
} }
test("asks for directory-scoped external_directory permission when reading external directory", async () => { it.live("asks for directory-scoped external_directory permission when reading external directory", () =>
await using outerTmp = await tmpdir({ Effect.gen(function* () {
init: async (dir) => { const outer = yield* tmpdirScoped()
await Bun.write(path.join(dir, "external", "a.txt"), "a") const dir = yield* tmpdirScoped({ git: true })
}, yield* put(path.join(outer, "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", "*")))
},
})
})
test("asks for external_directory permission when reading relative path outside project", async () => { const { items, next } = asks()
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()
},
})
})
test("does not ask for external_directory permission when reading inside project", async () => { yield* exec(dir, { filePath: path.join(outer, "external") }, next)
await using tmp = await tmpdir({ const ext = items.find((item) => item.permission === "external_directory")
git: true, expect(ext).toBeDefined()
init: async (dir) => { expect(ext!.patterns).toContain(glob(path.join(outer, "external", "*")))
await Bun.write(path.join(dir, "internal.txt"), "internal content") }),
}, )
})
await Instance.provide({ it.live("asks for external_directory permission when reading relative path outside project", () =>
directory: tmp.path, Effect.gen(function* () {
fn: async () => { const dir = yield* tmpdirScoped({ git: true })
const read = await ReadTool.init()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const { items, next } = asks()
const testCtx = {
...ctx, yield* fail(dir, { filePath: "../outside.txt" }, next)
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => { const ext = items.find((item) => item.permission === "external_directory")
requests.push(req) expect(ext).toBeDefined()
}, }),
} )
await read.execute({ filePath: path.join(tmp.path, "internal.txt") }, testCtx)
const extDirReq = requests.find((r) => r.permission === "external_directory") it.live("does not ask for external_directory permission when reading inside project", () =>
expect(extDirReq).toBeUndefined() 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()
}),
)
}) })
describe("tool.read env file permissions", () => { describe("tool.read env file permissions", () => {
@@ -205,261 +211,204 @@ describe("tool.read env file permissions", () => {
["environment.ts", false], ["environment.ts", false],
] ]
describe.each(["build", "plan"])("agent=%s", (agentName) => { for (const agentName of ["build", "plan"] as const) {
test.each(cases)("%s asks=%s", async (filename, shouldAsk) => { describe(`agent=${agentName}`, () => {
await using tmp = await tmpdir({ for (const [filename, shouldAsk] of cases) {
init: (dir) => Bun.write(path.join(dir, filename), "content"), it.live(`${filename} asks=${shouldAsk}`, () =>
}) Effect.gen(function* () {
await Instance.provide({ const dir = yield* tmpdirScoped()
directory: tmp.path, yield* put(path.join(dir, filename), "content")
fn: async () => {
const agent = await Agent.get(agentName) const asked = yield* provideInstance(dir)(
let askedForEnv = false Effect.gen(function* () {
const ctxWithPermissions = { const agent = yield* Agent.Service
...ctx, const info = yield* agent.get(agentName)
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => { let asked = false
for (const pattern of req.patterns) { const next = {
const rule = Permission.evaluate(req.permission, pattern, agent.permission) ...ctx,
if (rule.action === "ask" && req.permission === "read") { ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
askedForEnv = true 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 })
}
}
},
} }
if (rule.action === "deny") {
throw new Permission.DeniedError({ ruleset: agent.permission }) yield* run({ filePath: path.join(dir, filename) }, next)
} return asked
} }),
}, )
}
const read = await ReadTool.init() expect(asked).toBe(shouldAsk)
await read.execute({ filePath: path.join(tmp.path, filename) }, ctxWithPermissions) }),
expect(askedForEnv).toBe(shouldAsk) )
}, }
})
}) })
}) }
}) })
describe("tool.read truncation", () => { describe("tool.read truncation", () => {
test("truncates large file by bytes and sets truncated metadata", async () => { it.live("truncates large file by bytes and sets truncated metadata", () =>
await using tmp = await tmpdir({ Effect.gen(function* () {
init: async (dir) => { const dir = yield* tmpdirScoped()
const base = await Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json")) const base = yield* load(path.join(FIXTURES_DIR, "models-api.json"))
const target = 60 * 1024 const target = 60 * 1024
const content = base.length >= target ? base : base.repeat(Math.ceil(target / base.length)) const content = base.length >= target ? base : base.repeat(Math.ceil(target / base.length))
await Filesystem.write(path.join(dir, "large.json"), content) yield* put(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=")
},
})
})
test("truncates by line count when limit is specified", async () => { const result = yield* exec(dir, { filePath: path.join(dir, "large.json") })
await using tmp = await tmpdir({ expect(result.metadata.truncated).toBe(true)
init: async (dir) => { expect(result.output).toContain("Output capped at")
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") expect(result.output).toContain("Use offset=")
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")
},
})
})
test("does not truncate small file", async () => { it.live("truncates by line count when limit is specified", () =>
await using tmp = await tmpdir({ Effect.gen(function* () {
init: async (dir) => { const dir = yield* tmpdirScoped()
await Bun.write(path.join(dir, "small.txt"), "hello world") const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
}, yield* put(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, "small.txt") }, ctx)
expect(result.metadata.truncated).toBe(false)
expect(result.output).toContain("End of file")
},
})
})
test("respects offset parameter", async () => { const result = yield* exec(dir, { filePath: path.join(dir, "many-lines.txt"), limit: 10 })
await using tmp = await tmpdir({ expect(result.metadata.truncated).toBe(true)
init: async (dir) => { expect(result.output).toContain("Showing lines 1-10 of 100")
const lines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`).join("\n") expect(result.output).toContain("Use offset=11")
await Bun.write(path.join(dir, "offset.txt"), lines) expect(result.output).toContain("line0")
}, expect(result.output).toContain("line9")
}) expect(result.output).not.toContain("line10")
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")
},
})
})
test("throws when offset is beyond end of file", async () => { it.live("does not truncate small file", () =>
await using tmp = await tmpdir({ Effect.gen(function* () {
init: async (dir) => { const dir = yield* tmpdirScoped()
const lines = Array.from({ length: 3 }, (_, i) => `line${i + 1}`).join("\n") yield* put(path.join(dir, "small.txt"), "hello world")
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)")
},
})
})
test("allows reading empty file at default offset", async () => { const result = yield* exec(dir, { filePath: path.join(dir, "small.txt") })
await using tmp = await tmpdir({ expect(result.metadata.truncated).toBe(false)
init: async (dir) => { expect(result.output).toContain("End of file")
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")
},
})
})
test("throws when offset > 1 for empty file", async () => { it.live("respects offset parameter", () =>
await using tmp = await tmpdir({ Effect.gen(function* () {
init: async (dir) => { const dir = yield* tmpdirScoped()
await Bun.write(path.join(dir, "empty.txt"), "") const lines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`).join("\n")
}, yield* put(path.join(dir, "offset.txt"), lines)
})
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)",
)
},
})
})
test("does not mark final directory page as truncated", async () => { const result = yield* exec(dir, { filePath: path.join(dir, "offset.txt"), offset: 10, limit: 5 })
await using tmp = await tmpdir({ expect(result.output).toContain("10: line10")
init: async (dir) => { expect(result.output).toContain("14: line14")
await Promise.all( expect(result.output).not.toContain("9: line10")
Array.from({ length: 10 }, (_, i) => Bun.write(path.join(dir, "dir", `file-${i + 1}.txt`), `line${i}`)), expect(result.output).not.toContain("15: line15")
) expect(result.output).toContain("line10")
}, expect(result.output).toContain("line14")
}) expect(result.output).not.toContain("line0")
await Instance.provide({ expect(result.output).not.toContain("line15")
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")
},
})
})
test("truncates long lines", async () => { it.live("throws when offset is beyond end of file", () =>
await using tmp = await tmpdir({ Effect.gen(function* () {
init: async (dir) => { const dir = yield* tmpdirScoped()
const longLine = "x".repeat(3000) const lines = Array.from({ length: 3 }, (_, i) => `line${i + 1}`).join("\n")
await Bun.write(path.join(dir, "long-line.txt"), longLine) yield* put(path.join(dir, "short.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, "long-line.txt") }, ctx)
expect(result.output).toContain("(line truncated to 2000 chars)")
expect(result.output.length).toBeLessThan(3000)
},
})
})
test("image files set truncated to false", async () => { const err = yield* fail(dir, { filePath: path.join(dir, "short.txt"), offset: 4, limit: 5 })
await using tmp = await tmpdir({ expect(err.message).toContain("Offset 4 is out of range for this file (3 lines)")
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")
},
})
})
test("large image files are properly attached without error", async () => { it.live("allows reading empty file at default offset", () =>
await Instance.provide({ Effect.gen(function* () {
directory: FIXTURES_DIR, const dir = yield* tmpdirScoped()
fn: async () => { yield* put(path.join(dir, "empty.txt"), "")
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")
},
})
})
test(".fbs files (FlatBuffers schema) are read as text, not images", async () => { const result = yield* exec(dir, { filePath: path.join(dir, "empty.txt") })
await using tmp = await tmpdir({ expect(result.metadata.truncated).toBe(false)
init: async (dir) => { expect(result.output).toContain("End of file - total 0 lines")
// FlatBuffers schema content }),
const fbsContent = `namespace MyGame; )
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;
table Monster { table Monster {
pos:Vec3; pos:Vec3;
@@ -468,79 +417,52 @@ table Monster {
} }
root_type Monster;` root_type Monster;`
await Bun.write(path.join(dir, "schema.fbs"), fbsContent) yield* put(path.join(dir, "schema.fbs"), fbs)
},
}) const result = yield* exec(dir, { filePath: path.join(dir, "schema.fbs") })
await Instance.provide({ expect(result.attachments).toBeUndefined()
directory: tmp.path, expect(result.output).toContain("namespace MyGame")
fn: async () => { expect(result.output).toContain("table Monster")
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", () => { describe("tool.read loaded instructions", () => {
test("loads AGENTS.md from parent directory and includes in metadata", async () => { it.live("loads AGENTS.md from parent directory and includes in metadata", () =>
await using tmp = await tmpdir({ Effect.gen(function* () {
init: async (dir) => { const dir = yield* tmpdirScoped()
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Test Instructions\nDo something special.") yield* put(path.join(dir, "subdir", "AGENTS.md"), "# Test Instructions\nDo something special.")
await Bun.write(path.join(dir, "subdir", "nested", "test.txt"), "test content") yield* put(path.join(dir, "subdir", "nested", "test.txt"), "test content")
},
}) const result = yield* exec(dir, { filePath: path.join(dir, "subdir", "nested", "test.txt") })
await Instance.provide({ expect(result.output).toContain("test content")
directory: tmp.path, expect(result.output).toContain("system-reminder")
fn: async () => { expect(result.output).toContain("Test Instructions")
const read = await ReadTool.init() expect(result.metadata.loaded).toBeDefined()
const result = await read.execute({ filePath: path.join(tmp.path, "subdir", "nested", "test.txt") }, ctx) expect(result.metadata.loaded).toContain(path.join(dir, "subdir", "AGENTS.md"))
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", () => { describe("tool.read binary detection", () => {
test("rejects text extension files with null bytes", async () => { it.live("rejects text extension files with null bytes", () =>
await using tmp = await tmpdir({ Effect.gen(function* () {
init: async (dir) => { const dir = yield* tmpdirScoped()
const bytes = Buffer.from([0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x77, 0x6f, 0x72, 0x6c, 0x64]) 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) yield* put(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",
)
},
})
})
test("rejects known binary extensions", async () => { const err = yield* fail(dir, { filePath: path.join(dir, "null-byte.txt") })
await using tmp = await tmpdir({ expect(err.message).toContain("Cannot read binary file")
init: async (dir) => { }),
await Bun.write(path.join(dir, "module.wasm"), "not really wasm") )
},
}) it.live("rejects known binary extensions", () =>
await Instance.provide({ Effect.gen(function* () {
directory: tmp.path, const dir = yield* tmpdirScoped()
fn: async () => { yield* put(path.join(dir, "module.wasm"), "not really wasm")
const read = await ReadTool.init()
await expect(read.execute({ filePath: path.join(tmp.path, "module.wasm") }, ctx)).rejects.toThrow( const err = yield* fail(dir, { filePath: path.join(dir, "module.wasm") })
"Cannot read binary file", expect(err.message).toContain("Cannot read binary file")
) }),
}, )
})
})
}) })
+10 -20
View File
@@ -27,45 +27,37 @@ describe("Tool.define", () => {
await tool.init() await tool.init()
await tool.init() await tool.init()
// The original object's execute should never be overwritten
expect(original.execute).toBe(originalExecute) expect(original.execute).toBe(originalExecute)
}) })
test("object-defined tool does not accumulate wrapper layers across init() calls", async () => { test("object-defined tool does not accumulate wrapper layers across init() calls", async () => {
let executeCalls = 0 let calls = 0
const tool = Tool.define( const tool = Tool.define(
"test-tool", "test-tool",
makeTool("test", () => executeCalls++), makeTool("test", () => calls++),
) )
// Call init() many times to simulate many agentic steps
for (let i = 0; i < 100; i++) { for (let i = 0; i < 100; i++) {
await tool.init() await tool.init()
} }
// Resolve the tool and call execute
const resolved = await tool.init() const resolved = await tool.init()
executeCalls = 0 calls = 0
// Capture the stack trace inside execute to measure wrapper depth let stack = ""
let stackInsideExecute = "" const exec = resolved.execute
const origExec = resolved.execute
resolved.execute = async (args: any, ctx: any) => { resolved.execute = async (args: any, ctx: any) => {
const result = await origExec.call(resolved, args, ctx) const result = await exec.call(resolved, args, ctx)
const err = new Error() stack = new Error().stack || ""
stackInsideExecute = err.stack || ""
return result return result
} }
await resolved.execute(defaultArgs, {} as any) await resolved.execute(defaultArgs, {} as any)
expect(executeCalls).toBe(1) expect(calls).toBe(1)
// Count how many times tool.ts appears in the stack. const frames = stack.split("\n").filter((l) => l.includes("tool.ts")).length
// With the fix: 1 wrapper layer (from the most recent init()). expect(frames).toBeLessThan(5)
// Without the fix: 101 wrapper layers from accumulated closures.
const toolTsFrames = stackInsideExecute.split("\n").filter((l) => l.includes("tool.ts")).length
expect(toolTsFrames).toBeLessThan(5)
}) })
test("function-defined tool returns fresh objects and is unaffected", async () => { test("function-defined tool returns fresh objects and is unaffected", async () => {
@@ -74,7 +66,6 @@ describe("Tool.define", () => {
const first = await tool.init() const first = await tool.init()
const second = await tool.init() const second = await tool.init()
// Function-defined tools return distinct objects each time
expect(first).not.toBe(second) expect(first).not.toBe(second)
}) })
@@ -84,7 +75,6 @@ describe("Tool.define", () => {
const first = await tool.init() const first = await tool.init()
const second = await tool.init() const second = await tool.init()
// Each init() should return a separate object so wrappers don't accumulate
expect(first).not.toBe(second) expect(first).not.toBe(second)
}) })
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.3.13", "version": "1.3.15",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.3.13", "version": "1.3.15",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+130 -21
View File
@@ -24,6 +24,9 @@ import type {
EventTuiPromptAppend, EventTuiPromptAppend,
EventTuiSessionSelect, EventTuiSessionSelect,
EventTuiToastShow, EventTuiToastShow,
ExperimentalConsoleGetResponses,
ExperimentalConsoleListOrgsResponses,
ExperimentalConsoleSwitchOrgResponses,
ExperimentalResourceListResponses, ExperimentalResourceListResponses,
ExperimentalSessionListResponses, ExperimentalSessionListResponses,
ExperimentalWorkspaceCreateErrors, ExperimentalWorkspaceCreateErrors,
@@ -981,13 +984,13 @@ export class Config2 extends HeyApiClient {
} }
} }
export class Tool extends HeyApiClient { export class Console extends HeyApiClient {
/** /**
* List tool IDs * Get active Console provider metadata
* *
* Get a list of all available tool IDs, including both built-in tools and dynamically registered tools. * Get the active Console org name and the set of provider IDs managed by that Console org.
*/ */
public ids<ThrowOnError extends boolean = false>( public get<ThrowOnError extends boolean = false>(
parameters?: { parameters?: {
directory?: string directory?: string
workspace?: string workspace?: string
@@ -1005,24 +1008,22 @@ export class Tool extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<ToolIdsResponses, ToolIdsErrors, ThrowOnError>({ return (options?.client ?? this.client).get<ExperimentalConsoleGetResponses, unknown, ThrowOnError>({
url: "/experimental/tool/ids", url: "/experimental/console",
...options, ...options,
...params, ...params,
}) })
} }
/** /**
* List tools * List switchable Console orgs
* *
* Get a list of available tools with their JSON schema parameters for a specific provider and model combination. * Get the available Console orgs across logged-in accounts, including the current active org.
*/ */
public list<ThrowOnError extends boolean = false>( public listOrgs<ThrowOnError extends boolean = false>(
parameters: { parameters?: {
directory?: string directory?: string
workspace?: string workspace?: string
provider: string
model: string
}, },
options?: Options<never, ThrowOnError>, options?: Options<never, ThrowOnError>,
) { ) {
@@ -1033,18 +1034,55 @@ export class Tool extends HeyApiClient {
args: [ args: [
{ in: "query", key: "directory" }, { in: "query", key: "directory" },
{ in: "query", key: "workspace" }, { in: "query", key: "workspace" },
{ in: "query", key: "provider" },
{ in: "query", key: "model" },
], ],
}, },
], ],
) )
return (options?.client ?? this.client).get<ToolListResponses, ToolListErrors, ThrowOnError>({ return (options?.client ?? this.client).get<ExperimentalConsoleListOrgsResponses, unknown, ThrowOnError>({
url: "/experimental/tool", url: "/experimental/console/orgs",
...options, ...options,
...params, ...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 { export class Workspace extends HeyApiClient {
@@ -1239,6 +1277,11 @@ export class Resource extends HeyApiClient {
} }
export class Experimental extends HeyApiClient { export class Experimental extends HeyApiClient {
private _console?: Console
get console(): Console {
return (this._console ??= new Console({ client: this.client }))
}
private _workspace?: Workspace private _workspace?: Workspace
get workspace(): Workspace { get workspace(): Workspace {
return (this._workspace ??= new Workspace({ client: this.client })) return (this._workspace ??= new Workspace({ client: this.client }))
@@ -1255,6 +1298,72 @@ 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 { export class Worktree extends HeyApiClient {
/** /**
* Remove worktree * Remove worktree
@@ -4017,16 +4126,16 @@ export class OpencodeClient extends HeyApiClient {
return (this._config ??= new Config2({ client: this.client })) return (this._config ??= new Config2({ client: this.client }))
} }
private _tool?: Tool
get tool(): Tool {
return (this._tool ??= new Tool({ client: this.client }))
}
private _experimental?: Experimental private _experimental?: Experimental
get experimental(): Experimental { get experimental(): Experimental {
return (this._experimental ??= new Experimental({ client: this.client })) return (this._experimental ??= new Experimental({ client: this.client }))
} }
private _tool?: Tool
get tool(): Tool {
return (this._tool ??= new Tool({ client: this.client }))
}
private _worktree?: Worktree private _worktree?: Worktree
get worktree(): Worktree { get worktree(): Worktree {
return (this._worktree ??= new Worktree({ client: this.client })) return (this._worktree ??= new Worktree({ client: this.client }))
+75
View File
@@ -2653,6 +2653,81 @@ export type ConfigProvidersResponses = {
export type ConfigProvidersResponse = ConfigProvidersResponses[keyof 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 = { export type ToolIdsData = {
body?: never body?: never
path?: never path?: never
+188
View File
@@ -1220,6 +1220,194 @@
] ]
} }
}, },
"/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": { "/experimental/tool/ids": {
"get": { "get": {
"operationId": "tool.ids", "operationId": "tool.ids",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.3.13", "version": "1.3.15",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.3.13", "version": "1.3.15",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"exports": { "exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.3.13", "version": "1.3.15",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"version": "1.3.13", "version": "1.3.15",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode", "name": "opencode",
"displayName": "opencode", "displayName": "opencode",
"description": "opencode for VS Code", "description": "opencode for VS Code",
"version": "1.3.13", "version": "1.3.15",
"publisher": "sst-dev", "publisher": "sst-dev",
"repository": { "repository": {
"type": "git", "type": "git",
+10
View File
@@ -13,9 +13,19 @@
"outputs": [], "outputs": [],
"passThroughEnv": ["*"] "passThroughEnv": ["*"]
}, },
"opencode#test:ci": {
"dependsOn": ["^build"],
"outputs": [".artifacts/unit/junit.xml"],
"passThroughEnv": ["*"]
},
"@opencode-ai/app#test": { "@opencode-ai/app#test": {
"dependsOn": ["^build"], "dependsOn": ["^build"],
"outputs": [] "outputs": []
},
"@opencode-ai/app#test:ci": {
"dependsOn": ["^build"],
"outputs": [".artifacts/unit/junit.xml"],
"passThroughEnv": ["*"]
} }
} }
} }