Compare commits

..

168 Commits

Author SHA1 Message Date
opencode-agent[bot]
5dbbf94b2b Apply PR #18335: refactor(server): replace Bun serve with Hono node adapters 2026-03-20 05:33:07 +00:00
opencode-agent[bot]
a73f178da2 Apply PR #18327: refactor: replace Bun.serve with Node http.createServer in OAuth handlers 2026-03-20 05:32:22 +00:00
opencode-agent[bot]
5e75f2c92c Apply PR #18308: refactor: replace BunProc with Npm module using @npmcli/arborist 2026-03-20 05:32:21 +00:00
opencode-agent[bot]
6f3ed01727 Apply PR #18266: effectify Installation service, drop Effect suffix from namespaces 2026-03-20 05:32:21 +00:00
opencode-agent[bot]
abd7f62601 Apply PR #18173: feat(bus): migrate Bus to Effect service with PubSub 2026-03-20 05:32:20 +00:00
opencode-agent[bot]
4ff049d8a5 Apply PR #18144: chore: bump Bun to 1.3.11 2026-03-20 05:31:58 +00:00
opencode-agent[bot]
47a55a6c5d Apply PR #18079: Upgrade opentui to 0.1.88 2026-03-20 05:31:57 +00:00
opencode-agent[bot]
223149dce0 Apply PR #15697: tweak(ui): make questions popup collapsible 2026-03-20 05:31:10 +00:00
opencode-agent[bot]
08a8aff750 Apply PR #14471: [DO NOT MERGE]: beta badge for desktop app 2026-03-20 05:30:47 +00:00
opencode-agent[bot]
3e8a0f9930 Apply PR #14307: fix: use parentID matching instead of ID ordering for prompt loop exit and message rendering 2026-03-20 05:30:46 +00:00
opencode-agent[bot]
8e4466d740 Apply PR #12633: feat(tui): add auto-accept mode for permission requests 2026-03-20 05:30:46 +00:00
Luke Parker
0bbf26a1ce deslopity deslopity (#18343) 2026-03-20 05:24:27 +00:00
opencode-agent[bot]
83cdb4de64 chore: update nix node_modules hashes 2026-03-20 05:14:32 +00:00
Brendan Allan
4989632245 patch solid to try fix memo undefined under transition bug (#18338) 2026-03-20 14:58:35 +10:00
Luke Parker
d460614cd7 fix: lots of desktop stability, better e2e error logging (#18300) 2026-03-20 00:12:06 -04:00
Dax
b77c797c0f Merge branch 'dev' into refactor/hono-server 2026-03-20 00:00:24 -04:00
Luke Parker
7866dbcfcc fix: avoid truncate permission import cycle (#18292) 2026-03-19 23:52:04 -04:00
Dax Raad
bc43bf378d sync 2026-03-19 23:38:54 -04:00
Dax Raad
3b669e7f2b fix type 2026-03-19 23:37:00 -04:00
Dax
e48da96886 Update packages/opencode/src/npm/index.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-19 23:32:30 -04:00
Dax
20249bb723 Apply suggestion from @greptile-apps[bot]
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-19 23:29:18 -04:00
Dax Raad
205affa0eb fix: type error in mcp env and add missing @npmcli/arborist dependency 2026-03-19 23:09:27 -04:00
opencode-agent[bot]
e71a21e0a8 chore: update nix node_modules hashes 2026-03-20 02:21:29 +00:00
Dax
1071aca91f fix: miscellaneous small fixes (#18328) 2026-03-19 22:20:29 -04:00
Dax Raad
3eeeec359a chore: extract misc fixes into #18328 2026-03-19 22:13:38 -04:00
Jaaneek
b3d0446d13 feat: switch xai provider to responses API (#18175)
Co-authored-by: Jaaneek <jankiewiczmilosz@gmail.com>
2026-03-19 21:09:49 -05:00
Dax Raad
7409ce8aec fix: remove BUN_BE_BUN workaround, use node for ESLint server
Replace tsx dependency with explicit 'node' invocation for the
compiled ESLint server. Remove all BUN_BE_BUN env var references
which are no longer needed after the Bun-to-Node migration.
2026-03-19 22:05:30 -04:00
Dax Raad
c075a5d694 fix: force reinstall in which() when .bin is missing
When the lockfile exists but .bin is empty or absent, add() would
read the lockfile via loadVirtual() and return early without calling
reify(). Delete the lockfile before calling add() so it proceeds
with a full install.
2026-03-19 21:54:53 -04:00
Dax Raad
34c676ba7a fix: scope Npm.add() lock to per-package key
Use npm-install:${pkg} instead of a global npm-install lock so
concurrent installs of different packages can run in parallel.
2026-03-19 21:49:22 -04:00
Dax Raad
65e786258a chore: extract OAuth changes into #18327 2026-03-19 21:48:23 -04:00
Dax Raad
816a5f793f refactor: replace Bun.serve with Node http.createServer in OAuth handlers
Use Node's createServer for MCP OAuth callback and Codex plugin OAuth
servers instead of Bun.serve, making them work under Node.js.
2026-03-19 21:47:38 -04:00
opencode-agent[bot]
949191ab74 chore: update nix node_modules hashes 2026-03-20 01:36:22 +00:00
Dax
92cd908fb5 feat: add Node.js entry point and build script (#18324) 2026-03-19 21:35:07 -04:00
Dax Raad
cbc40a5981 chore: extract node entry point into #18324 2026-03-19 21:30:35 -04:00
Dax Raad
b9b210a864 Merge branch 'dev' into opencode-2-0 2026-03-19 21:22:28 -04:00
Dax Raad
d473b7e971 chore: extract which/global changes into #18320 2026-03-19 21:22:06 -04:00
Dax
6fcc970def fix: include cache bin directory in which() lookups (#18320) 2026-03-19 21:21:55 -04:00
Dax
52a7a04ad8 refactor: replace Bun shell execution with portable Process utilities (#18318) 2026-03-19 21:17:06 -04:00
Kit Langton
524276604e Merge branch 'dev' into kit/effect-installation 2026-03-19 21:16:18 -04:00
Dax Raad
f5783c4313 chore: extract portable process changes into #18318 2026-03-19 21:15:31 -04:00
Dax Raad
9439a5647e chore: revert drizzle upgrade (extracted to sqlite PR) 2026-03-19 21:10:53 -04:00
Dax Raad
2bfe81ee5c chore: extract SQLite abstraction into separate PR (#refactor/sqlite-abstraction) 2026-03-19 21:02:58 -04:00
Kit Langton
4c51d2b409 refactor(account): expose Account.Info instead of Account.Account 2026-03-19 21:02:22 -04:00
Dax Raad
fcf1bb010c chore: update lockfile and package.json 2026-03-19 20:53:04 -04:00
Dax Raad
08b6d9c6dc sync 2026-03-19 20:48:44 -04:00
Dax Raad
0293a8bb80 chore: revert changes overlapping with #18308 2026-03-19 20:48:23 -04:00
Dax Raad
ca2099e69d refactor: replace BunProc with Npm module using @npmcli/arborist 2026-03-19 20:17:03 -04:00
Dax Raad
850dbb93eb Merge remote-tracking branch 'origin/dev' into opencode-2-0 2026-03-19 19:33:19 -04:00
Kit Langton
f9f580f0ac fix remaining AccountEffect references after merge 2026-03-19 19:24:01 -04:00
Kit Langton
1d41009b9b Merge remote-tracking branch 'origin/dev' into kit/effect-installation
# ------------------------ >8 ------------------------
# Do not modify or remove the line above.
# Everything below it will be ignored.
#
# Conflicts:
#	packages/opencode/test/account/service.test.ts
2026-03-19 19:23:09 -04:00
Dax Raad
48e867ee20 Merge remote-tracking branch 'origin/dev' into opencode-2-0
# Conflicts:
#	.opencode/tool/github-pr-search.ts
#	.opencode/tool/github-triage.ts
2026-03-19 19:03:48 -04:00
Dax Raad
b5ebc541b9 Merge remote-tracking branch 'origin/dev' into opencode-2-0 2026-03-19 18:52:18 -04:00
Dax Raad
bd7a4cec90 sync 2026-03-19 17:53:35 -04:00
Dax Raad
63af295a17 Merge origin/dev into opencode-2-0 2026-03-19 16:07:13 -04:00
Kit Langton
af69bc1184 consolidate Installation back into single index.ts file 2026-03-19 15:10:35 -04:00
Kit Langton
c619537cbe fix circular dependency: use dynamic import for runtime in installation adapters 2026-03-19 15:04:09 -04:00
Kit Langton
8972c881a8 add tests for all Installation version fetch paths
Cover npm registry, bun (via npm registry), brew formulae API,
brew tap CLI JSON parsing, GitHub releases, scoop, and chocolatey.
Tests use mock HttpClient and ChildProcessSpawner layers.
2026-03-19 14:50:55 -04:00
Kit Langton
4da1a917d4 use Schema for Installation version API responses
Replace raw JSON.parse and any casts with Schema.Class definitions
and HttpClientResponse.schemaBodyJson for type-safe parsing of
GitHub, npm, brew, choco, and scoop version responses.
2026-03-19 14:48:51 -04:00
Kit Langton
4710fb4f7a add withTransientReadRetry to Installation HTTP client 2026-03-19 14:31:30 -04:00
Kit Langton
386eb03427 effectify Installation service and drop Effect suffix from service namespaces
Migrate Installation to Effect with HttpClient for version fetching and
ChildProcessSpawner for package manager detection/upgrade. Register in
global ManagedRuntime. Split into effect.ts (service) and index.ts
(legacy adapters) to break the runtime circular dependency.

Rename AccountEffect → Account, AuthEffect → Auth,
TruncateEffect → Truncate to establish consistent naming where both
the effect.ts and index.ts files use the same namespace name.
2026-03-19 14:29:34 -04:00
Kit Langton
8a3aa943dd fix: suppress unhandled interrupt error from forceInvalidate 2026-03-19 12:56:41 -04:00
Kit Langton
9be68a9fa4 fix: link to upstream PubSub shutdown PR 2026-03-19 12:40:07 -04:00
Kit Langton
2a0c9da40b Merge branch 'dev' into kit/effect-bus 2026-03-19 12:22:09 -04:00
Kit Langton
81f71c9b30 fix(bus): GlobalBus bridge for InstanceDisposed + forceInvalidate + Effect tests
Legacy subscribeAll delivers InstanceDisposed via GlobalBus because
the fiber starts asynchronously and may not be running when disposal
happens. This bridge can be removed once upstream PubSub.shutdown
properly wakes suspended subscribers.

Add forceInvalidate in Instances that closes the RcMap entry scope
regardless of refCount. Standard RcMap.invalidate bails when
refCount > 0 — an upstream issue (Effect-TS/effect-smol#1799).

Add PubSub shutdown finalizer to Bus layer so layer teardown
properly cleans up PubSubs.

Add Effect-native tests proving forkScoped + scope closure works
correctly: ensuring fires when the scope closes, streams receive
published events.

Remove stale GlobalBus disposal test (instance.ts responsibility).
2026-03-19 12:17:39 -04:00
Kit Langton
992f4f794a fix(bus): use GlobalBus for InstanceDisposed in legacy subscribeAll
The sync callback API can't wait for async layer acquisition, so
delivering InstanceDisposed through the PubSub stream is a race
condition. Instead, the legacy subscribeAll adapter listens on
GlobalBus for InstanceDisposed matching the current directory.

The Effect service's stream ending IS the disposal signal for
Effect consumers — this is only needed for the legacy callback API.

Also reverts forceInvalidate, fiber tracking, priority-based
disposal, and other workaround attempts. Clean simple solution.
2026-03-19 09:40:39 -04:00
Kit Langton
0c2b5b2c39 fix(bus): use Fiber.interrupt for clean disposal of subscribeAll
Use forkInstance + Fiber.interrupt (which awaits) instead of
runCallbackInstance + interruptUnsafe (fire-and-forget) for
subscribeAll. This ensures the fiber completes before layer
invalidation, allowing the RcMap refCount to drop to 0.

subscribeAll now delivers InstanceDisposed as the last callback
message via Effect.ensuring when the fiber is interrupted during
disposal, but not on manual unsubscribe.

Add priority support to registerDisposer so Bus can interrupt
subscription fibers (priority -1) before layer invalidation
(priority 0).

Add forkInstance helper to effect/runtime that returns a Fiber
instead of an interrupt function.
2026-03-19 08:49:06 -04:00
Kit Langton
009d77c9d8 refactor(format): make formatting explicit instead of bus-driven
Replace the implicit Bus.subscribe(File.Event.Edited) formatter with
an explicit Format.run(filepath) call in write/edit/apply_patch tools.

This ensures formatting completes before FileTime stamps and LSP
diagnostics run, rather than relying on the bus to block on subscribers.

- Add Format.run() to the Effect service interface and legacy adapter
- Call Format.run() in write, edit, and apply_patch tools after writes
- Remove Bus subscription from Format layer
2026-03-18 22:09:14 -04:00
Kit Langton
f3cf519d98 feat(bus): migrate Bus to Effect service with PubSub internals
Add Bus.Service as a ServiceMap.Service backed by Effect PubSub:
- publish() pushes to per-type + wildcard PubSubs and GlobalBus
- subscribe() returns a typed Stream via Stream.fromPubSub
- subscribeAll() returns a wildcard Stream

Legacy adapters wrap the Effect service:
- publish → runPromiseInstance
- subscribe/subscribeAll → runCallbackInstance with Stream.runForEach

Other changes:
- Register Bus.Service in Instances LayerMap
- Add runCallbackInstance helper to effect/runtime
- Remove unused Bus.once (zero callers)
- Skip PubSub creation on publish when no subscribers exist
- Move subscribe/unsubscribe logging into the Effect service layer
2026-03-18 21:36:04 -04:00
Kit Langton
645c15351b test(bus): add comprehensive test suite for Bus service
Covers publish/subscribe, multiple subscribers, unsubscribe, subscribeAll,
once, GlobalBus forwarding, instance isolation, disposal, and async subscribers.
2026-03-18 21:05:36 -04:00
Kit Langton
f63a2a2636 fix(bus): tighten GlobalBus payload and BusEvent.define types
Constrain BusEvent.define to ZodObject instead of ZodType so TS knows
event properties are always a record. Type GlobalBus payload as
{ type: string; properties: Record<string, unknown> } instead of any.

Refactor watcher test to use Bus.subscribe instead of raw GlobalBus
listener, removing hand-rolled event types and unnecessary casts.
2026-03-18 20:57:08 -04:00
LukeParkerDev
79318eb96a Merge origin/dev into chore/bun-1.3.11 2026-03-19 10:05:49 +10:00
LukeParkerDev
eb32c7150e chore: bump Bun to 1.3.11 2026-03-19 07:56:50 +10:00
Sebastian Herrlinger
c7b93a885c adapt to new paste event with raw bytes 2026-03-18 12:24:17 +01:00
Sebastian Herrlinger
df5f873cb3 upgrade package 2026-03-18 11:31:16 +01:00
MakonnenMak
1392d868b1 Merge remote-tracking branch 'upstream/dev' into fix/clock-skew-prompt-loop-exit
# Conflicts:
#	packages/ui/src/components/session-turn.tsx
2026-03-13 18:10:02 -04:00
Dax Raad
04954a9620 Merge remote-tracking branch 'origin/opencode-2-0' into opencode-2-0 2026-03-11 14:32:38 -04:00
Dax Raad
fb63fd79a3 cleanup 2026-03-11 14:29:35 -04:00
Dax Raad
2e04b66eab sync 2026-03-11 14:29:03 -04:00
Dax Raad
f0b7c8c374 refactor(npm): inline pkgPath and lockPath variables 2026-03-11 14:29:03 -04:00
Dax Raad
be6f59035a unbreak 2026-03-11 14:29:03 -04:00
Dax Raad
27ab51f490 sync 2026-03-11 14:29:03 -04:00
Dax Raad
bca723e8fe core: enable running in non-Bun environments by using standard Node.js APIs for OAuth servers and retry logic 2026-03-11 14:29:03 -04:00
Dax Raad
1ac39718d8 sync 2026-03-11 14:29:03 -04:00
Dax Raad
190319fb56 core: cleaner error output and more flexible custom tool directories
- Removed debug console.log when dependency installation fails so users see clean warning messages instead of raw error dumps
- Fixed database connection cleanup to prevent resource leaks between sessions
- Added support for loading custom tools from both .opencode/tool (singular) and .opencode/tools (plural) directories, matching common naming conventions
2026-03-11 14:29:03 -04:00
Dax Raad
3154f0a61c core: return structured server info with stop method from workspace server
- Enables graceful server shutdown for workspace management
- Removes unsupported serverUrl getter that threw errors in plugin context
2026-03-11 14:29:03 -04:00
Dax Raad
0b686b8178 core: remove shell execution and server URL from plugin API
Plugins no longer receive shell access or server URL to prevent unauthorized
execution and limit plugin sandbox surface area.
2026-03-11 14:29:03 -04:00
Dax Raad
4cba56171b sync 2026-03-11 14:29:03 -04:00
Dax Raad
66342acd31 core: bundle database migrations into node build and auto-start server on port 1338 2026-03-11 14:29:03 -04:00
Dax Raad
88dae67549 refactor(server): replace Bun serve with Hono node adapters 2026-03-11 14:29:03 -04:00
Dax Raad
0ec42582f3 core: add Node.js runtime support
Enable running opencode on Node.js by adding platform-specific database adapters and replacing Bun-specific shell execution with cross-platform Process utility.
2026-03-11 14:29:02 -04:00
Luke Parker
4f82248a68 fix: work around Bun/Windows UV_FS_O_FILEMAP incompatibility in tar (#16853) 2026-03-11 14:29:02 -04:00
Dax Raad
5e069aab97 tui: fix Windows plugin loading by using direct paths instead of file URLs 2026-03-11 14:29:02 -04:00
Dax Raad
5325b2ec99 core: fix custom tool loading to properly resolve module paths 2026-03-11 14:29:02 -04:00
Dax Raad
2a98920922 sync 2026-03-11 14:29:02 -04:00
Dax Raad
5ea92ea6cb sync 2026-03-11 14:29:02 -04:00
Dax Raad
a18528a7ee sync 2026-03-11 14:29:02 -04:00
Dax Raad
ced125a974 core: log npm install errors to console for debugging dependency failures 2026-03-11 14:29:02 -04:00
Dax Raad
655fe20beb sync 2026-03-11 14:29:02 -04:00
Dax Raad
dd0c258e23 core: fix CLI tools from npm packages not being accessible after install on Windows 2026-03-11 14:29:02 -04:00
Dax Raad
791e27d289 sync 2026-03-11 14:29:02 -04:00
Dax Raad
fac0aec69f tui: export sessions using consistent Filesystem API instead of Bun.write 2026-03-11 14:29:02 -04:00
Dax Raad
ca26e639f6 core: fix npm dependency installation on Windows CI by disabling bin links when symlink permissions are restricted 2026-03-11 14:29:02 -04:00
Dax Raad
0b5d54f2cb core: enable npm bin links on non-Windows platforms to allow plugin executables to work while keeping them disabled on Windows CI where symlink permissions are restricted 2026-03-11 14:29:02 -04:00
Dax Raad
1b408cf06b core: fix dependency installation failures behind corporate proxies or in CI by disabling Bun cache when network interception is detected 2026-03-11 14:29:02 -04:00
Dax Raad
8e102d19ed core: disable npm bin links to fix package installation in sandboxed environments 2026-03-11 14:29:02 -04:00
Dax Raad
721b2406e9 core: dynamically resolve formatter executable paths at runtime
Formatters now determine their executable location when enabled rather than
using hardcoded paths. This ensures formatters work correctly regardless
of how the tool was installed or where executables are located on the system.
2026-03-11 14:29:01 -04:00
Dax Raad
4a6a18cd79 sync 2026-03-11 14:28:37 -04:00
Dax
c10b5880cc Update packages/opencode/src/util/which.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-11 14:28:37 -04:00
Dax
e6bf83084c Update packages/opencode/src/npm/index.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-11 14:28:37 -04:00
Dax Raad
6722ee22ee sync 2026-03-11 14:28:36 -04:00
Dax Raad
870a5731ac refactor: lsp server and core improvements 2026-03-11 14:28:24 -04:00
Luke Parker
7910ce5d36 fix: guard Npm.which() against infinite loop when .bin is empty (#16961) 2026-03-11 09:34:58 -04:00
Dax
6ad171dba9 Merge branch 'dev' into opencode-2-0 2026-03-10 17:20:19 -04:00
Dax Raad
cb5674edc7 sync 2026-03-10 17:00:15 -04:00
Dax Raad
b99de4118e refactor(npm): inline pkgPath and lockPath variables 2026-03-10 16:59:01 -04:00
Dax Raad
040700dbc4 unbreak 2026-03-10 16:07:25 -04:00
Dax Raad
4d5da9697e sync 2026-03-10 16:02:40 -04:00
Dax Raad
a28648f530 core: enable running in non-Bun environments by using standard Node.js APIs for OAuth servers and retry logic 2026-03-10 16:02:40 -04:00
Dax Raad
4d81e2d4d9 sync 2026-03-10 16:02:40 -04:00
Dax Raad
21e72cbf42 core: cleaner error output and more flexible custom tool directories
- Removed debug console.log when dependency installation fails so users see clean warning messages instead of raw error dumps
- Fixed database connection cleanup to prevent resource leaks between sessions
- Added support for loading custom tools from both .opencode/tool (singular) and .opencode/tools (plural) directories, matching common naming conventions
2026-03-10 16:02:40 -04:00
Dax Raad
5f277d1e62 core: return structured server info with stop method from workspace server
- Enables graceful server shutdown for workspace management
- Removes unsupported serverUrl getter that threw errors in plugin context
2026-03-10 16:02:40 -04:00
Dax Raad
d67e877e28 core: remove shell execution and server URL from plugin API
Plugins no longer receive shell access or server URL to prevent unauthorized
execution and limit plugin sandbox surface area.
2026-03-10 16:02:40 -04:00
Dax Raad
d4e51e04b3 sync 2026-03-10 16:02:40 -04:00
Dax Raad
070c1679e4 core: bundle database migrations into node build and auto-start server on port 1338 2026-03-10 16:02:40 -04:00
Dax Raad
406d216cd2 refactor(server): replace Bun serve with Hono node adapters 2026-03-10 16:02:40 -04:00
Dax Raad
5dc8b4ef29 core: add Node.js runtime support
Enable running opencode on Node.js by adding platform-specific database adapters and replacing Bun-specific shell execution with cross-platform Process utility.
2026-03-10 16:02:39 -04:00
Luke Parker
2f41d89163 fix: work around Bun/Windows UV_FS_O_FILEMAP incompatibility in tar (#16853) 2026-03-10 16:02:39 -04:00
Dax Raad
b2eae867a1 tui: fix Windows plugin loading by using direct paths instead of file URLs 2026-03-10 16:02:39 -04:00
Dax Raad
3c2fda4d91 core: fix custom tool loading to properly resolve module paths 2026-03-10 16:02:39 -04:00
Dax Raad
2678ceb45e sync 2026-03-10 16:02:39 -04:00
Dax Raad
58a4cd00b6 sync 2026-03-10 16:02:39 -04:00
Dax Raad
0faa191b6d sync 2026-03-10 16:02:39 -04:00
Dax Raad
58cf092105 core: log npm install errors to console for debugging dependency failures 2026-03-10 16:02:39 -04:00
Dax Raad
0ff8bfe1d9 sync 2026-03-10 16:02:39 -04:00
Dax Raad
ceb79c786a core: fix CLI tools from npm packages not being accessible after install on Windows 2026-03-10 16:02:39 -04:00
Dax Raad
b1a15d559b sync 2026-03-10 16:02:39 -04:00
Dax Raad
124a8abf9b tui: export sessions using consistent Filesystem API instead of Bun.write 2026-03-10 16:02:39 -04:00
Dax Raad
85c2bb342b core: fix npm dependency installation on Windows CI by disabling bin links when symlink permissions are restricted 2026-03-10 16:02:39 -04:00
Dax Raad
4c57e39466 core: enable npm bin links on non-Windows platforms to allow plugin executables to work while keeping them disabled on Windows CI where symlink permissions are restricted 2026-03-10 16:02:39 -04:00
Dax Raad
0cdd4e4e16 core: fix dependency installation failures behind corporate proxies or in CI by disabling Bun cache when network interception is detected 2026-03-10 16:02:39 -04:00
Dax Raad
a9b01be0c2 core: disable npm bin links to fix package installation in sandboxed environments 2026-03-10 16:02:39 -04:00
Dax Raad
528daf5490 core: dynamically resolve formatter executable paths at runtime
Formatters now determine their executable location when enabled rather than
using hardcoded paths. This ensures formatters work correctly regardless
of how the tool was installed or where executables are located on the system.
2026-03-10 16:02:39 -04:00
Dax Raad
0e176d3ac3 sync 2026-03-10 16:02:39 -04:00
Dax
27f359852e Update packages/opencode/src/util/which.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-10 16:02:39 -04:00
Dax
173128d431 Update packages/opencode/src/npm/index.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-10 16:02:39 -04:00
Dax Raad
e8ee1e239f sync 2026-03-10 16:02:39 -04:00
Dax Raad
656fa191c1 refactor: lsp server and core improvements 2026-03-10 16:02:39 -04:00
MakonnenMak
e993acec31 Merge remote-tracking branch 'upstream/dev' into fix/clock-skew-prompt-loop-exit
# Conflicts:
#	packages/ui/src/components/session-turn.tsx
2026-03-02 13:30:01 -05:00
David Hill
611e616010 tui: add right margin to question progress indicator so it doesn't touch the container edge 2026-03-02 14:27:43 +00:00
David Hill
b286c0ae3f tweak(ui): restore questions progress indicator 2026-03-02 12:08:37 +00:00
David Hill
81a61f8dbd tweak(ui): improve collapse area 2026-03-02 11:14:01 +00:00
David Hill
752e449e38 tweak(ui): improve collpase area 2026-03-02 11:11:52 +00:00
David Hill
5d419a0211 tweak(ui): expand question dock toggle area 2026-02-27 21:30:49 +00:00
David Hill
8b168981aa tweak(ui): active state on type your own answer 2026-02-27 18:50:50 +00:00
David Hill
724dd665ec tweak(ui): collapse questions 2026-02-27 18:47:53 +00:00
MakonnenMak
fc258ea74f fix: remove as any type cast in processor exit logic 2026-02-20 13:20:10 -05:00
Makonnen
abd9e195ac fix: use parentID matching instead of ID ordering for prompt loop exit and message rendering
When the client clock is ahead of the server, user message IDs (generated
client-side) sort after assistant message IDs (generated server-side).
This broke the prompt loop exit check and the UI message pairing logic.

- Extract shouldExitLoop() into a pure function that uses parentID matching
  instead of relying on ID ordering
- Extract findAssistantMessages() with forward+backward scan to handle
  messages sorted out of expected order due to clock skew
- Remove debug console.log statements added during investigation
- Add tests for both extracted functions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:20:10 -05:00
Adam
9d78b69cd3 wip(app): beta badge 2026-02-20 10:59:59 -06:00
Dax
e31f00ad22 Merge branch 'dev' into feat/auto-accept-permissions 2026-02-16 21:50:34 -05:00
Dax
86d7bdc542 Merge branch 'dev' into feat/auto-accept-permissions 2026-02-09 10:55:01 -05:00
Dax
d3ab78bba0 Merge branch 'dev' into feat/auto-accept-permissions 2026-02-09 10:04:40 -05:00
Dax Raad
a531f3f36d core: run command build agent now auto-accepts file edits to reduce workflow interruptions while still requiring confirmation for bash commands 2026-02-07 20:00:09 -05:00
Dax Raad
bb3382311d tui: standardize autoedit indicator text styling to match other status labels 2026-02-07 19:57:45 -05:00
Dax Raad
ad545d0cc9 tui: allow auto-accepting only edit permissions instead of all permissions 2026-02-07 19:52:53 -05:00
Dax Raad
ac244b1458 tui: add searchable 'toggle' keywords to command palette and show current state in toggle titles 2026-02-07 17:03:34 -05:00
Dax Raad
f202536b65 tui: show enable/disable state in permission toggle and make it searchable by 'toggle permissions' 2026-02-07 16:57:48 -05:00
Dax Raad
405cc3f610 tui: streamline permission toggle command naming and add keyboard shortcut support
Rename 'Toggle autoaccept permissions' to 'Toggle permissions' for clarity
and move the command to the Agent category for better discoverability.
Add permission_auto_accept_toggle keybind to enable keyboard shortcut
toggling of auto-accept mode for permission requests.
2026-02-07 16:51:55 -05:00
Dax Raad
878c1b8c2d feat(tui): add auto-accept mode for permission requests
Add a toggleable auto-accept mode that automatically accepts all incoming
permission requests with a 'once' reply. This is useful for users who want
to streamline their workflow when they trust the agent's actions.

Changes:
- Add permission_auto_accept keybind (default: shift+tab) to config
- Remove default for agent_cycle_reverse (was shift+tab)
- Add auto-accept logic in sync.tsx to auto-reply when enabled
- Add command bar action to toggle auto-accept mode (copy: "Toggle autoaccept permissions")
- Add visual indicator showing 'auto-accept' when active
- Store auto-accept state in KV for persistence across sessions
2026-02-07 16:44:39 -05:00
94 changed files with 3921 additions and 2149 deletions

View File

@@ -50,20 +50,17 @@ jobs:
e2e:
name: e2e (${{ matrix.settings.name }})
needs: unit
strategy:
fail-fast: false
matrix:
settings:
- name: linux
host: blacksmith-4vcpu-ubuntu-2404
playwright: bunx playwright install --with-deps
- name: windows
host: blacksmith-4vcpu-windows-2025
playwright: bunx playwright install
runs-on: ${{ matrix.settings.host }}
env:
PLAYWRIGHT_BROWSERS_PATH: 0
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
defaults:
run:
shell: bash
@@ -76,9 +73,28 @@ jobs:
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Install Playwright browsers
- name: Read Playwright version
id: playwright-version
run: |
version=$(node -e 'console.log(require("./packages/app/package.json").devDependencies["@playwright/test"])')
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ${{ github.workspace }}/.playwright-browsers
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
- name: Install Playwright system dependencies
if: runner.os == 'Linux'
working-directory: packages/app
run: ${{ matrix.settings.playwright }}
run: bunx playwright install-deps chromium
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: packages/app
run: bunx playwright install chromium
- name: Run app e2e tests
run: bun --cwd packages/app test:e2e:local

928
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-yfA50QKqylmaioxi+6d++W8Xv4Wix1hl3hEF6Zz7Ue0=",
"aarch64-linux": "sha256-b5sO7V+/zzJClHHKjkSz+9AUBYC8cb7S3m5ab1kpAyk=",
"aarch64-darwin": "sha256-V66nmRX6kAjrc41ARVeuTElWK7KD8qG/DVk9K7Fu+J8=",
"x86_64-darwin": "sha256-cFyh60WESiqZ5XWZi1+g3F/beSDL1+UPG8KhRivhK8w="
"x86_64-linux": "sha256-Gv0pHYCinlj0SQXRQ/a9ozYPxECwdrC99ssTzpeOr1I=",
"aarch64-linux": "sha256-WzVt5goOrxoGe26juzRf73PWPqwnB1URu2TYjxye/Aw=",
"aarch64-darwin": "sha256-18Nn0TR1wK2gRUF/FFP4vFMY/td49XkfjOwFbD5iJNc=",
"x86_64-darwin": "sha256-zk2yaulPzUUiCerCPJaCOCLhklXKMp9mSv7v0N8AMfA="
}
}

View File

@@ -4,7 +4,7 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
"packageManager": "bun@1.3.10",
"packageManager": "bun@1.3.11",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop tauri dev",
@@ -26,7 +26,7 @@
],
"catalog": {
"@effect/platform-node": "4.0.0-beta.35",
"@types/bun": "1.3.9",
"@types/bun": "1.3.11",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"ulid": "3.0.1",
@@ -112,6 +112,8 @@
},
"patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch"
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
}
}

View File

@@ -9,6 +9,7 @@ import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
import {
dropdownMenuTriggerSelector,
dropdownMenuContentSelector,
projectSwitchSelector,
projectMenuTriggerSelector,
projectCloseMenuSelector,
projectWorkspacesToggleSelector,
@@ -23,6 +24,16 @@ import {
workspaceMenuTriggerSelector,
} from "./selectors"
const phase = new WeakMap<Page, "test" | "cleanup">()
export function setHealthPhase(page: Page, value: "test" | "cleanup") {
phase.set(page, value)
}
export function healthPhase(page: Page) {
return phase.get(page) ?? "test"
}
export async function defocus(page: Page) {
await page
.evaluate(() => {
@@ -196,11 +207,51 @@ export async function closeDialog(page: Page, dialog: Locator) {
}
export async function isSidebarClosed(page: Page) {
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await expect(button).toBeVisible()
const button = await waitSidebarButton(page, "isSidebarClosed")
return (await button.getAttribute("aria-expanded")) !== "true"
}
async function errorBoundaryText(page: Page) {
const title = page.getByRole("heading", { name: /something went wrong/i }).first()
if (!(await title.isVisible().catch(() => false))) return
const description = await page
.getByText(/an error occurred while loading the application\./i)
.first()
.textContent()
.catch(() => "")
const detail = await page
.getByRole("textbox", { name: /error details/i })
.first()
.inputValue()
.catch(async () =>
(
(await page
.getByRole("textbox", { name: /error details/i })
.first()
.textContent()
.catch(() => "")) ?? ""
).trim(),
)
return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
}
export async function assertHealthy(page: Page, context: string) {
const text = await errorBoundaryText(page)
if (!text) return
console.log(`[e2e:error-boundary][${context}]\n${text}`)
throw new Error(`Error boundary during ${context}\n${text}`)
}
async function waitSidebarButton(page: Page, context: string) {
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
const boundary = page.getByRole("heading", { name: /something went wrong/i }).first()
await button.or(boundary).first().waitFor({ state: "visible", timeout: 10_000 })
await assertHealthy(page, context)
return button
}
export async function toggleSidebar(page: Page) {
await defocus(page)
await page.keyboard.press(`${modKey}+B`)
@@ -209,7 +260,7 @@ export async function toggleSidebar(page: Page) {
export async function openSidebar(page: Page) {
if (!(await isSidebarClosed(page))) return
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
const button = await waitSidebarButton(page, "openSidebar")
await button.click()
const opened = await expect(button)
@@ -226,7 +277,7 @@ export async function openSidebar(page: Page) {
export async function closeSidebar(page: Page) {
if (await isSidebarClosed(page)) return
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
const button = await waitSidebarButton(page, "closeSidebar")
await button.click()
const closed = await expect(button)
@@ -241,6 +292,7 @@ export async function closeSidebar(page: Page) {
}
export async function openSettings(page: Page) {
await assertHealthy(page, "openSettings")
await defocus(page)
const dialog = page.getByRole("dialog")
@@ -253,6 +305,8 @@ export async function openSettings(page: Page) {
if (opened) return dialog
await assertHealthy(page, "openSettings")
await page.getByRole("button", { name: "Settings" }).first().click()
await expect(dialog).toBeVisible()
return dialog
@@ -314,10 +368,12 @@ export async function seedProjects(page: Page, input: { directory: string; extra
export async function createTestProject() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
const id = `e2e-${path.basename(root)}`
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
await fs.writeFile(path.join(root, "README.md"), `# e2e\n\n${id}\n`)
execSync("git init", { cwd: root, stdio: "ignore" })
await fs.writeFile(path.join(root, ".git", "opencode"), id)
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
execSync("git add -A", { cwd: root, stdio: "ignore" })
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
@@ -339,12 +395,24 @@ export function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
}
async function probeSession(page: Page) {
return page
.evaluate(() => {
const win = window as E2EWindow
const current = win.__opencode_e2e?.model?.current
if (!current) return null
return { dir: current.dir, sessionID: current.sessionID }
})
.catch(() => null as { dir?: string; sessionID?: string } | null)
}
export async function waitSlug(page: Page, skip: string[] = []) {
let prev = ""
let next = ""
await expect
.poll(
() => {
async () => {
await assertHealthy(page, "waitSlug")
const slug = slugFromUrl(page.url())
if (!slug) return ""
if (skip.includes(slug)) return ""
@@ -374,6 +442,7 @@ export async function waitDir(page: Page, directory: string) {
await expect
.poll(
async () => {
await assertHealthy(page, "waitDir")
const slug = slugFromUrl(page.url())
if (!slug) return ""
return resolveSlug(slug)
@@ -386,6 +455,69 @@ export async function waitDir(page: Page, directory: string) {
return { directory: target, slug: base64Encode(target) }
}
export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
const target = await resolveDirectory(input.directory)
await expect
.poll(
async () => {
await assertHealthy(page, "waitSession")
const slug = slugFromUrl(page.url())
if (!slug) return false
const resolved = await resolveSlug(slug).catch(() => undefined)
if (!resolved || resolved.directory !== target) return false
if (input.sessionID && sessionIDFromUrl(page.url()) !== input.sessionID) return false
const state = await probeSession(page)
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
if (state?.dir) {
const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
if (dir !== target) return false
}
return page
.locator(promptSelector)
.first()
.isVisible()
.catch(() => false)
},
{ timeout: 45_000 },
)
.toBe(true)
return { directory: target, slug: base64Encode(target) }
}
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
const sdk = createSdk(directory)
const target = await resolveDirectory(directory)
await expect
.poll(
async () => {
const data = await sdk.session
.get({ sessionID })
.then((x) => x.data)
.catch(() => undefined)
if (!data?.directory) return ""
return resolveDirectory(data.directory).catch(() => data.directory)
},
{ timeout },
)
.toBe(target)
await expect
.poll(
async () => {
const items = await sdk.session
.messages({ sessionID, limit: 20 })
.then((x) => x.data ?? [])
.catch(() => [])
return items.some((item) => item.info.role === "user")
},
{ timeout },
)
.toBe(true)
}
export function sessionIDFromUrl(url: string) {
const match = /\/session\/([^/?#]+)/.exec(url)
return match?.[1]
@@ -797,8 +929,14 @@ export async function openStatusPopover(page: Page) {
}
export async function openProjectMenu(page: Page, projectSlug: string) {
await openSidebar(page)
const item = page.locator(projectSwitchSelector(projectSlug)).first()
await expect(item).toBeVisible()
await item.hover()
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
await expect(trigger).toHaveCount(1)
await expect(trigger).toBeVisible()
const menu = page
.locator(dropdownMenuContentSelector)
@@ -807,7 +945,7 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
const clicked = await trigger
.click({ timeout: 1500 })
.click({ force: true, timeout: 1500 })
.then(() => true)
.catch(() => false)

View File

@@ -1,7 +1,16 @@
import { test as base, expect, type Page } from "@playwright/test"
import type { E2EWindow } from "../src/testing/terminal"
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
import { promptSelector } from "./selectors"
import {
healthPhase,
cleanupSession,
cleanupTestProject,
createTestProject,
setHealthPhase,
seedProjects,
sessionIDFromUrl,
waitSlug,
waitSession,
} from "./actions"
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
export const settingsKey = "settings.v3"
@@ -27,6 +36,29 @@ type WorkerFixtures = {
}
export const test = base.extend<TestFixtures, WorkerFixtures>({
page: async ({ page }, use) => {
let boundary: string | undefined
setHealthPhase(page, "test")
const consoleHandler = (msg: { text(): string }) => {
const text = msg.text()
if (!text.includes("[e2e:error-boundary]")) return
if (healthPhase(page) === "cleanup") {
console.warn(`[e2e:error-boundary][cleanup-warning]\n${text}`)
return
}
boundary ||= text
console.log(text)
}
const pageErrorHandler = (err: Error) => {
console.log(`[e2e:pageerror] ${err.stack || err.message}`)
}
page.on("console", consoleHandler)
page.on("pageerror", pageErrorHandler)
await use(page)
page.off("console", consoleHandler)
page.off("pageerror", pageErrorHandler)
if (boundary) throw new Error(boundary)
},
directory: [
async ({}, use) => {
const directory = await getWorktree()
@@ -48,21 +80,20 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(directory, sessionID))
await expect(page.locator(promptSelector)).toBeVisible()
await waitSession(page, { directory, sessionID })
}
await use(gotoSession)
},
withProject: async ({ page }, use) => {
await use(async (callback, options) => {
const root = await createTestProject()
const slug = dirSlug(root)
const sessions = new Map<string, string>()
const dirs = new Set<string>()
await seedStorage(page, { directory: root, extra: options?.extra })
const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(root, sessionID))
await expect(page.locator(promptSelector)).toBeVisible()
await waitSession(page, { directory: root, sessionID })
const current = sessionIDFromUrl(page.url())
if (current) trackSession(current)
}
@@ -77,13 +108,16 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
try {
await gotoSession()
const slug = await waitSlug(page)
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
} finally {
setHealthPhase(page, "cleanup")
await Promise.allSettled(
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
)
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
await cleanupTestProject(root)
setHealthPhase(page, "test")
}
})
},

View File

@@ -1,5 +1,4 @@
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import {
defocus,
@@ -7,43 +6,14 @@ import {
cleanupTestProject,
openSidebar,
sessionIDFromUrl,
waitDir,
setWorkspacesEnabled,
waitSession,
waitSessionSaved,
waitSlug,
} from "../actions"
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { dirSlug, resolveDirectory } from "../utils"
async function workspaces(page: Page, directory: string, enabled: boolean) {
await page.evaluate(
({ directory, enabled }: { directory: string; enabled: boolean }) => {
const key = "opencode.global.dat:layout"
const raw = localStorage.getItem(key)
const data = raw ? JSON.parse(raw) : {}
const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {}
const current =
sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces)
? sidebar.workspaces
: {}
const next = { ...current }
if (enabled) next[directory] = true
if (!enabled) delete next[directory]
localStorage.setItem(
key,
JSON.stringify({
...data,
sidebar: {
...sidebar,
workspaces: next,
},
}),
)
},
{ directory, enabled },
)
}
test("can switch between projects from sidebar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
@@ -84,9 +54,7 @@ test("switching back to a project opens the latest workspace session", async ({
await withProject(
async ({ directory, slug, trackSession, trackDirectory }) => {
await defocus(page)
await workspaces(page, directory, true)
await page.reload()
await expect(page.locator(promptSelector)).toBeVisible()
await setWorkspacesEnabled(page, slug, true)
await openSidebar(page)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
@@ -108,8 +76,7 @@ test("switching back to a project opens the latest workspace session", async ({
await expect(btn).toBeVisible()
await btn.click({ force: true })
await waitSlug(page)
await waitDir(page, space)
await waitSession(page, { directory: space })
// Create a session by sending a prompt
const prompt = page.locator(promptSelector)
@@ -123,6 +90,7 @@ test("switching back to a project opens the latest workspace session", async ({
const created = sessionIDFromUrl(page.url())
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
trackSession(created, space)
await waitSessionSaved(space, created)
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
@@ -130,15 +98,14 @@ test("switching back to a project opens the latest workspace session", async ({
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.click()
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
await otherButton.click({ force: true })
await waitSession(page, { directory: other })
const rootButton = page.locator(projectSwitchSelector(slug)).first()
await expect(rootButton).toBeVisible()
await rootButton.click()
await rootButton.click({ force: true })
await waitDir(page, space)
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
await waitSession(page, { directory: space, sessionID: created })
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
},
{ extra: [other] },

View File

@@ -1,6 +1,15 @@
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitDir, waitSlug } from "../actions"
import {
openSidebar,
resolveSlug,
sessionIDFromUrl,
setWorkspacesEnabled,
waitDir,
waitSession,
waitSessionSaved,
waitSlug,
} from "../actions"
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk } from "../utils"
@@ -14,20 +23,7 @@ function button(space: { slug: string; raw: string }) {
async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) {
await openSidebar(page)
await expect
.poll(
async () => {
const row = page.locator(item(space)).first()
try {
await row.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
await expect(page.locator(item(space)).first()).toBeVisible({ timeout: 60_000 })
}
async function createWorkspace(page: Page, root: string, seen: string[]) {
@@ -49,7 +45,8 @@ async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: s
await expect(next).toBeVisible()
await next.click({ force: true })
return waitDir(page, space.directory)
await waitSession(page, { directory: space.directory })
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe("")
}
async function createSessionFromWorkspace(
@@ -57,39 +54,28 @@ async function createSessionFromWorkspace(
space: { slug: string; raw: string; directory: string },
text: string,
) {
const next = await openWorkspaceNewSession(page, space)
await openWorkspaceNewSession(page, space)
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await expect(prompt).toBeEditable()
await prompt.click()
await expect(prompt).toBeFocused()
await prompt.fill(text)
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
await prompt.press("Enter")
await waitDir(page, next.directory)
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
await page.keyboard.press("Enter")
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
const sessionID = sessionIDFromUrl(page.url())
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
return { sessionID, slug: next.slug }
}
async function sessionDirectory(directory: string, sessionID: string) {
const info = await createSdk(directory)
.session.get({ sessionID })
.then((x) => x.data)
await waitSessionSaved(space.directory, sessionID)
await createSdk(space.directory)
.session.abort({ sessionID })
.catch(() => undefined)
if (!info) return ""
return info.directory
return sessionID
}
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => {
await withProject(async ({ slug: root, trackDirectory, trackSession }) => {
await openSidebar(page)
await setWorkspacesEnabled(page, root, true)
@@ -101,17 +87,8 @@ test("new sessions from sidebar workspace actions stay in selected workspace", a
trackDirectory(second.directory)
await waitWorkspaceReady(page, second)
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
trackSession(firstSession.sessionID, first.directory)
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
trackSession(secondSession.sessionID, second.directory)
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
trackSession(thirdSession.sessionID, first.directory)
await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory)
trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory)
trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory)
})
})

View File

@@ -1,6 +1,14 @@
import type { Locator, Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions"
import {
openSidebar,
resolveSlug,
sessionIDFromUrl,
setWorkspacesEnabled,
waitSession,
waitSessionIdle,
waitSlug,
} from "../actions"
import {
promptAgentSelector,
promptModelSelector,
@@ -29,8 +37,6 @@ const text = async (locator: Locator) => ((await locator.textContent()) ?? "").t
const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null)
const dirKey = (state: Probe | null) => state?.dir ?? ""
async function probe(page: Page): Promise<Probe | null> {
return page.evaluate(() => {
const win = window as Window & {
@@ -44,21 +50,6 @@ async function probe(page: Page): Promise<Probe | null> {
})
}
async function currentDir(page: Page) {
let hit = ""
await expect
.poll(
async () => {
const next = dirKey(await probe(page))
if (next) hit = next
return next
},
{ timeout: 30_000 },
)
.not.toBe("")
return hit
}
async function read(page: Page): Promise<Footer> {
return {
agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
@@ -187,8 +178,7 @@ async function chooseOtherModel(page: Page): Promise<Footer> {
async function goto(page: Page, directory: string, sessionID?: string) {
await page.goto(sessionPath(directory, sessionID))
await expect(page.locator(promptSelector)).toBeVisible()
await expect.poll(async () => dirKey(await probe(page)), { timeout: 30_000 }).toBe(directory)
await waitSession(page, { directory, sessionID })
}
async function submit(page: Page, value: string) {
@@ -224,7 +214,7 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
await page.getByRole("button", { name: "New workspace" }).first().click()
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`))
await waitSession(page, { directory: next.directory })
return next
}
@@ -256,9 +246,7 @@ async function newWorkspaceSession(page: Page, slug: string) {
await button.click({ force: true })
const next = await resolveSlug(await waitSlug(page))
await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
return currentDir(page)
return waitSession(page, { directory: next.directory }).then((item) => item.directory)
}
test("session model and variant restore per session without leaking into new sessions", async ({
@@ -277,7 +265,7 @@ test("session model and variant restore per session without leaking into new ses
await waitUser(directory, first)
await page.reload()
await expect(page.locator(promptSelector)).toBeVisible()
await waitSession(page, { directory, sessionID: first })
await waitFooter(page, firstState)
await gotoSession()

View File

@@ -0,0 +1,81 @@
import { describe, expect, test } from "bun:test"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { findAssistantMessages } from "@opencode-ai/ui/find-assistant-messages"
function user(id: string): Message {
return {
id,
role: "user",
sessionID: "session-1",
time: { created: 1 },
} as unknown as Message
}
function assistant(id: string, parentID: string): Message {
return {
id,
role: "assistant",
sessionID: "session-1",
parentID,
time: { created: 1 },
} as unknown as Message
}
describe("findAssistantMessages", () => {
test("normal ordering: assistant after user in array → found via forward scan", () => {
const messages = [user("u1"), assistant("a1", "u1")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("clock skew: assistant before user in array → found via backward scan", () => {
// When client clock is ahead, user ID sorts after assistant ID,
// so assistant appears earlier in the ID-sorted message array
const messages = [assistant("a1", "u1"), user("u1")]
const result = findAssistantMessages(messages, 1, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("no assistant messages → returns empty array", () => {
const messages = [user("u1"), user("u2")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(0)
})
test("multiple assistant messages with matching parentID → all found", () => {
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "u1")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(2)
expect(result[0].id).toBe("a1")
expect(result[1].id).toBe("a2")
})
test("does not return assistant messages with different parentID", () => {
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "other")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("stops forward scan at next user message", () => {
const messages = [user("u1"), assistant("a1", "u1"), user("u2"), assistant("a2", "u1")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("stops backward scan at previous user message", () => {
const messages = [assistant("a0", "u1"), user("u0"), assistant("a1", "u1"), user("u1")]
const result = findAssistantMessages(messages, 3, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("invalid index returns empty array", () => {
const messages = [user("u1")]
expect(findAssistantMessages(messages, -1, "u1")).toHaveLength(0)
expect(findAssistantMessages(messages, 5, "u1")).toHaveLength(0)
})
})

View File

@@ -287,6 +287,9 @@ export function Titlebar() {
</div>
</div>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
<div class="bg-icon-interactive-base text-background-base font-medium px-2 rounded-sm uppercase font-mono">
BETA
</div>
</div>
<div class="min-w-0 flex items-center justify-center pointer-events-none">

View File

@@ -378,6 +378,7 @@ function createGlobalSync() {
return globalStore.error
},
child: children.child,
peek: children.peek,
bootstrap,
updateConfig,
project: projectApi,

View File

@@ -226,6 +226,15 @@ export function createChildStoreManager(input: {
return childStore
}
function peek(directory: string, options: ChildOptions = {}) {
const childStore = ensureChild(directory)
const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory)
}
return childStore
}
function projectMeta(directory: string, patch: ProjectMeta) {
const [store, setStore] = ensureChild(directory)
const cached = metaCache.get(directory)
@@ -256,6 +265,7 @@ export function createChildStoreManager(input: {
children,
ensureChild,
child,
peek,
projectMeta,
projectIcon,
mark,

View File

@@ -1,11 +1,12 @@
import { TextField } from "@opencode-ai/ui/text-field"
import { Logo } from "@opencode-ai/ui/logo"
import { Button } from "@opencode-ai/ui/button"
import { Component, Show } from "solid-js"
import { Component, Show, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { Icon } from "@opencode-ai/ui/icon"
import type { E2EWindow } from "@/testing/terminal"
export type InitError = {
name: string
@@ -226,6 +227,13 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
actionError: undefined as string | undefined,
})
onMount(() => {
const win = window as E2EWindow
if (!win.__opencode_e2e) return
const detail = formatError(props.error, language.t)
console.error(`[e2e:error-boundary] ${window.location.pathname}\n${detail}`)
})
async function checkForUpdates() {
if (!platform.checkUpdate) return
setStore("checking", true)

View File

@@ -129,6 +129,16 @@ export default function Layout(props: ParentProps) {
const theme = useTheme()
const language = useLanguage()
const initialDirectory = decode64(params.dir)
const route = createMemo(() => {
const slug = params.dir
if (!slug) return { slug, dir: "" }
const dir = decode64(slug)
if (!dir) return { slug, dir: "" }
return {
slug,
dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
}
})
const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
@@ -137,7 +147,7 @@ export default function Layout(props: ParentProps) {
dark: "theme.scheme.dark",
}
const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
const currentDir = createMemo(() => decode64(params.dir) ?? "")
const currentDir = createMemo(() => route().dir)
const [state, setState] = createStore({
autoselect: !initialDirectory,
@@ -484,8 +494,8 @@ export default function Layout(props: ParentProps) {
}
const currentSession = params.id
if (directory === currentDir() && props.sessionID === currentSession) return
if (directory === currentDir() && session?.parentID === currentSession) return
if (workspaceKey(directory) === workspaceKey(currentDir()) && props.sessionID === currentSession) return
if (workspaceKey(directory) === workspaceKey(currentDir()) && session?.parentID === currentSession) return
dismissSessionAlert(sessionKey)
@@ -620,7 +630,7 @@ export default function Layout(props: ParentProps) {
const activeDir = currentDir()
return workspaceIds(project).filter((directory) => {
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
const active = directory === activeDir
const active = workspaceKey(directory) === workspaceKey(activeDir)
return expanded || active
})
})
@@ -687,7 +697,7 @@ export default function Layout(props: ParentProps) {
seen: lru,
keep: sessionID,
limit: PREFETCH_MAX_SESSIONS_PER_DIR,
preserve: directory === params.dir && params.id ? [params.id] : undefined,
preserve: params.id && workspaceKey(directory) === workspaceKey(currentDir()) ? [params.id] : undefined,
})
}
@@ -700,7 +710,7 @@ export default function Layout(props: ParentProps) {
})
createEffect(() => {
params.dir
route()
globalSDK.url
prefetchToken.value += 1
@@ -1692,13 +1702,10 @@ export default function Layout(props: ParentProps) {
createEffect(
on(
() => {
const dir = params.dir
const directory = dir ? decode64(dir) : undefined
const resolved = directory ? globalSync.child(directory, { bootstrap: false })[0].path.directory : ""
return [pageReady(), dir, params.id, currentProject()?.worktree, directory, resolved] as const
return [pageReady(), route().slug, params.id, currentProject()?.worktree, currentDir()] as const
},
([ready, dir, id, root, directory, resolved]) => {
if (!ready || !dir || !directory) {
([ready, slug, id, root, dir]) => {
if (!ready || !slug || !dir) {
activeRoute.session = ""
activeRoute.sessionProject = ""
activeRoute.directory = ""
@@ -1712,29 +1719,28 @@ export default function Layout(props: ParentProps) {
return
}
const next = resolved || directory
const session = `${dir}/${id}`
const session = `${slug}/${id}`
if (!root) {
activeRoute.session = session
activeRoute.directory = next
activeRoute.directory = dir
activeRoute.sessionProject = ""
return
}
if (server.projects.last() !== root) server.projects.touch(root)
const changed = session !== activeRoute.session || next !== activeRoute.directory
const changed = session !== activeRoute.session || dir !== activeRoute.directory
if (changed) {
activeRoute.session = session
activeRoute.directory = next
activeRoute.sessionProject = syncSessionRoute(next, id, root)
activeRoute.directory = dir
activeRoute.sessionProject = syncSessionRoute(dir, id, root)
return
}
if (root === activeRoute.sessionProject) return
activeRoute.directory = next
activeRoute.sessionProject = rememberSessionRoute(next, id, root)
activeRoute.directory = dir
activeRoute.sessionProject = rememberSessionRoute(dir, id, root)
},
),
)
@@ -1927,6 +1933,7 @@ export default function Layout(props: ParentProps) {
const projectSidebarCtx: ProjectSidebarContext = {
currentDir,
currentProject,
sidebarOpened: () => layout.sidebar.opened(),
sidebarHovering,
hoverProject: () => state.hoverProject,

View File

@@ -40,10 +40,10 @@ export const latestRootSession = (stores: SessionStore[], now: number) =>
stores.flatMap(roots).sort(sortSessions(now))[0]
export function hasProjectPermissions<T>(
request: Record<string, T[] | undefined>,
request: Record<string, T[] | undefined> | undefined,
include: (item: T) => boolean = () => true,
) {
return Object.values(request).some((list) => list?.some(include))
return Object.values(request ?? {}).some((list) => list?.some(include))
}
export const childMapByParent = (sessions: Session[] | undefined) => {

View File

@@ -15,6 +15,7 @@ import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
export type ProjectSidebarContext = {
currentDir: Accessor<string>
currentProject: Accessor<LocalProject | undefined>
sidebarOpened: Accessor<boolean>
sidebarHovering: Accessor<boolean>
hoverProject: Accessor<string | undefined>
@@ -278,11 +279,7 @@ export const SortableProject = (props: {
const globalSync = useGlobalSync()
const language = useLanguage()
const sortable = createSortable(props.project.worktree)
const selected = createMemo(
() =>
props.project.worktree === props.ctx.currentDir() ||
props.project.sandboxes?.includes(props.ctx.currentDir()) === true,
)
const selected = createMemo(() => props.ctx.currentProject()?.worktree === props.project.worktree)
const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
const dirs = createMemo(() => props.ctx.workspaceIds(props.project))

View File

@@ -17,7 +17,7 @@ import { type LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
import { childMapByParent, sortedRootSessions } from "./helpers"
import { childMapByParent, sortedRootSessions, workspaceKey } from "./helpers"
type InlineEditorComponent = (props: {
id: string
@@ -323,7 +323,7 @@ export const SortableWorkspace = (props: {
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
const children = createMemo(() => childMapByParent(workspaceStore.session))
const local = createMemo(() => props.directory === props.project.worktree)
const active = createMemo(() => props.ctx.currentDir() === props.directory)
const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory))
const workspaceValue = createMemo(() => {
const branch = workspaceStore.vcs?.branch
const name = branch ?? getFilename(props.directory)

View File

@@ -3,6 +3,7 @@ import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { showToast } from "@opencode-ai/ui/toast"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useLanguage } from "@/context/language"
@@ -25,6 +26,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
customOn: cached?.customOn ?? ([] as boolean[]),
editing: false,
sending: false,
collapsed: false,
})
let root: HTMLDivElement | undefined
@@ -35,6 +37,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const input = createMemo(() => store.custom[store.tab] ?? "")
const on = createMemo(() => store.customOn[store.tab] === true)
const multi = createMemo(() => question()?.multiple === true)
const picked = createMemo(() => store.answers[store.tab]?.length ?? 0)
const summary = createMemo(() => {
const n = Math.min(store.tab + 1, total())
@@ -43,6 +46,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const last = createMemo(() => store.tab >= total() - 1)
const fold = () => setStore("collapsed", (value) => !value)
const customUpdate = (value: string, selected: boolean = on()) => {
const prev = input().trim()
const next = value.trim()
@@ -257,9 +262,21 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
kind="question"
ref={(el) => (root = el)}
header={
<>
<div
data-action="session-question-toggle"
class="flex flex-1 min-w-0 items-center gap-2 cursor-default select-none"
role="button"
tabIndex={0}
style={{ margin: "0 -10px", padding: "0 0 0 10px" }}
onClick={fold}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
fold()
}}
>
<div data-slot="question-header-title">{summary()}</div>
<div data-slot="question-progress">
<div data-slot="question-progress" class="ml-auto mr-1">
<For each={questions()}>
{(_, i) => (
<button
@@ -271,13 +288,38 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
}
disabled={store.sending}
onClick={() => jump(i())}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
jump(i())
}}
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
/>
)}
</For>
</div>
</>
<div>
<IconButton
data-action="session-question-toggle-button"
icon="chevron-down"
size="normal"
variant="ghost"
classList={{ "rotate-180": store.collapsed }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
fold()
}}
aria-label={store.collapsed ? language.t("session.todo.expand") : language.t("session.todo.collapse")}
/>
</div>
</div>
}
footer={
<>
@@ -297,56 +339,121 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</>
}
>
<div data-slot="question-text">{question()?.question}</div>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
<div
data-slot="question-text"
class="cursor-default"
classList={{
"mb-6": store.collapsed && picked() === 0,
}}
role={store.collapsed ? "button" : undefined}
tabIndex={store.collapsed ? 0 : undefined}
onClick={fold}
onKeyDown={(event) => {
if (!store.collapsed) return
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
fold()
}}
>
{question()?.question}
</div>
<Show when={store.collapsed && picked() > 0}>
<div data-slot="question-hint" class="cursor-default mb-6">
{picked()} answer{picked() === 1 ? "" : "s"} selected
</div>
</Show>
<div data-slot="question-options">
<For each={options()}>
{(opt, i) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<div data-slot="question-answers" hidden={store.collapsed} aria-hidden={store.collapsed}>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
</Show>
<div data-slot="question-options">
<For each={options()}>
{(opt, i) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<button
data-slot="question-option"
data-picked={picked()}
role={multi() ? "checkbox" : "radio"}
aria-checked={picked()}
disabled={store.sending}
onClick={() => selectOption(i())}
>
<span data-slot="question-option-check" aria-hidden="true">
<span
data-slot="question-option-box"
data-type={multi() ? "checkbox" : "radio"}
data-picked={picked()}
>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
</span>
</button>
)
}}
</For>
<Show
when={store.editing}
fallback={
<button
data-slot="question-option"
data-picked={picked()}
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={picked()}
aria-checked={on()}
disabled={store.sending}
onClick={() => selectOption(i())}
onClick={customOpen}
>
<span data-slot="question-option-check" aria-hidden="true">
<span
data-slot="question-option-box"
data-type={multi() ? "checkbox" : "radio"}
data-picked={picked()}
>
<span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
</span>
</button>
)
}}
</For>
<Show
when={store.editing}
fallback={
<button
}
>
<form
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
disabled={store.sending}
onClick={customOpen}
onMouseDown={(e) => {
if (store.sending) {
e.preventDefault()
return
}
if (e.target instanceof HTMLTextAreaElement) return
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
if (input instanceof HTMLTextAreaElement) input.focus()
}}
onSubmit={(e) => {
e.preventDefault()
commitCustom()
}}
>
<span
data-slot="question-option-check"
@@ -365,80 +472,39 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
</span>
</button>
}
>
<form
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
onMouseDown={(e) => {
if (store.sending) {
e.preventDefault()
return
}
if (e.target instanceof HTMLTextAreaElement) return
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
if (input instanceof HTMLTextAreaElement) input.focus()
}}
onSubmit={(e) => {
e.preventDefault()
commitCustom()
}}
>
<span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<textarea
ref={(el) =>
setTimeout(() => {
el.focus()
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}, 0)
}
data-slot="question-custom-input"
placeholder={language.t("ui.question.custom.placeholder")}
value={input()}
rows={1}
disabled={store.sending}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
setStore("editing", false)
return
<textarea
ref={(el) =>
setTimeout(() => {
el.focus()
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}, 0)
}
if (e.key !== "Enter" || e.shiftKey) return
e.preventDefault()
commitCustom()
}}
onInput={(e) => {
customUpdate(e.currentTarget.value)
e.currentTarget.style.height = "0px"
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
}}
/>
</span>
</form>
</Show>
data-slot="question-custom-input"
placeholder={language.t("ui.question.custom.placeholder")}
value={input()}
rows={1}
disabled={store.sending}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
setStore("editing", false)
return
}
if (e.key !== "Enter" || e.shiftKey) return
e.preventDefault()
commitCustom()
}}
onInput={(e) => {
customUpdate(e.currentTarget.value)
e.currentTarget.style.height = "0px"
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
}}
/>
</span>
</form>
</Show>
</div>
</div>
</DockPrompt>
)

View File

@@ -42,7 +42,7 @@
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.0",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"drizzle-kit": "catalog:",
"mysql2": "3.14.4",

View File

@@ -4,7 +4,7 @@ FROM ${REGISTRY}/build/base:24.04
SHELL ["/bin/bash", "-lc"]
ARG NODE_VERSION=24.4.0
ARG BUN_VERSION=1.3.5
ARG BUN_VERSION=1.3.11
ENV BUN_INSTALL=/opt/bun
ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

View File

@@ -52,6 +52,7 @@
"@types/bun": "catalog:",
"@types/cross-spawn": "6.0.6",
"@types/mime-types": "3.0.1",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "^7.5.8",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
@@ -89,11 +90,15 @@
"@ai-sdk/xai": "2.0.51",
"@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/node-server": "1.19.11",
"@hono/node-ws": "1.3.0",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
"@npmcli/arborist": "9.4.0",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
@@ -102,9 +107,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.87",
"@opentui/solid": "0.1.87",
"@effect/platform-node": "catalog:",
"@opentui/core": "0.1.88",
"@opentui/solid": "0.1.88",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bun
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const dir = path.resolve(__dirname, "..")
process.chdir(dir)
// Load migrations from migration directories
const migrationDirs = (
await fs.promises.readdir(path.join(dir, "migration"), {
withFileTypes: true,
})
)
.filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name))
.map((entry) => entry.name)
.sort()
const migrations = await Promise.all(
migrationDirs.map(async (name) => {
const file = path.join(dir, "migration", name, "migration.sql")
const sql = await Bun.file(file).text()
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(name)
const timestamp = match
? Date.UTC(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3]),
Number(match[4]),
Number(match[5]),
Number(match[6]),
)
: 0
return { sql, timestamp, name }
}),
)
console.log(`Loaded ${migrations.length} migrations`)
await Bun.build({
target: "node",
entrypoints: ["./src/node.ts"],
outdir: "./dist",
format: "esm",
external: ["jsonc-parser"],
define: {
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
},
})
console.log("Build complete")

View File

@@ -127,7 +127,7 @@ Done now:
Still open and likely worth migrating:
- [ ] `Plugin`
- [x] `ToolRegistry`
- [ ] `ToolRegistry`
- [ ] `Pty`
- [ ] `Worktree`
- [ ] `Installation`

View File

@@ -6,9 +6,9 @@ import { AccountRepo, type AccountRow } from "./repo"
import {
type AccountError,
AccessToken,
Account,
AccountID,
DeviceCode,
Info,
RefreshToken,
AccountServiceError,
Login,
@@ -24,10 +24,30 @@ import {
UserCode,
} from "./schema"
export * from "./schema"
export {
AccountID,
type AccountError,
AccountRepoError,
AccountServiceError,
AccessToken,
RefreshToken,
DeviceCode,
UserCode,
Info,
Org,
OrgID,
Login,
PollSuccess,
PollPending,
PollSlow,
PollExpired,
PollDenied,
PollError,
PollResult,
} from "./schema"
export type AccountOrgs = {
account: Account
account: Info
orgs: readonly Org[]
}
@@ -108,10 +128,10 @@ const mapAccountServiceError =
),
)
export namespace AccountEffect {
export namespace Account {
export interface Interface {
readonly active: () => Effect.Effect<Option.Option<Account>, AccountError>
readonly list: () => Effect.Effect<Account[], AccountError>
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
readonly list: () => Effect.Effect<Info[], AccountError>
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>

View File

@@ -1,31 +1,24 @@
import { Effect, Option } from "effect"
import {
Account as AccountSchema,
type AccountError,
type AccessToken,
AccountID,
AccountEffect,
OrgID,
} from "./effect"
import { Account as S, type AccountError, type AccessToken, AccountID, Info as Model, OrgID } from "./effect"
export { AccessToken, AccountID, OrgID } from "./effect"
import { runtime } from "@/effect/runtime"
function runSync<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
return runtime.runSync(AccountEffect.Service.use(f))
function runSync<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
return runtime.runSync(S.Service.use(f))
}
function runPromise<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
return runtime.runPromise(AccountEffect.Service.use(f))
function runPromise<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
return runtime.runPromise(S.Service.use(f))
}
export namespace Account {
export const Account = AccountSchema
export type Account = AccountSchema
export const Info = Model
export type Info = Model
export function active(): Account | undefined {
export function active(): Info | undefined {
return Option.getOrUndefined(runSync((service) => service.active()))
}

View File

@@ -3,7 +3,7 @@ import { Effect, Layer, Option, Schema, ServiceMap } from "effect"
import { Database } from "@/storage/db"
import { AccountStateTable, AccountTable } from "./account.sql"
import { AccessToken, Account, AccountID, AccountRepoError, OrgID, RefreshToken } from "./schema"
import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } from "./schema"
export type AccountRow = (typeof AccountTable)["$inferSelect"]
@@ -13,8 +13,8 @@ const ACCOUNT_STATE_ID = 1
export namespace AccountRepo {
export interface Service {
readonly active: () => Effect.Effect<Option.Option<Account>, AccountRepoError>
readonly list: () => Effect.Effect<Account[], AccountRepoError>
readonly active: () => Effect.Effect<Option.Option<Info>, AccountRepoError>
readonly list: () => Effect.Effect<Info[], AccountRepoError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError>
readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
@@ -40,7 +40,7 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
static readonly layer: Layer.Layer<AccountRepo> = Layer.effect(
AccountRepo,
Effect.gen(function* () {
const decode = Schema.decodeUnknownSync(Account)
const decode = Schema.decodeUnknownSync(Info)
const query = <A>(f: (db: DbClient) => A) =>
Effect.try({

View File

@@ -38,7 +38,7 @@ export const UserCode = Schema.String.pipe(
)
export type UserCode = Schema.Schema.Type<typeof UserCode>
export class Account extends Schema.Class<Account>("Account")({
export class Info extends Schema.Class<Info>("Account")({
id: AccountID,
email: Schema.String,
url: Schema.String,

View File

@@ -64,6 +64,7 @@ export namespace Agent {
question: "deny",
plan_enter: "deny",
plan_exit: "deny",
edit: "ask",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",

View File

@@ -37,7 +37,7 @@ const file = path.join(Global.Path.data, "auth.json")
const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
export namespace AuthEffect {
export namespace Auth {
export interface Interface {
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>

View File

@@ -5,8 +5,8 @@ import * as S from "./effect"
export { OAUTH_DUMMY_KEY } from "./effect"
function runPromise<A>(f: (service: S.AuthEffect.Interface) => Effect.Effect<A, S.AuthError>) {
return runtime.runPromise(S.AuthEffect.Service.use(f))
function runPromise<A>(f: (service: S.Auth.Interface) => Effect.Effect<A, S.AuthError>) {
return runtime.runPromise(S.Auth.Service.use(f))
}
export namespace Auth {

View File

@@ -1,127 +0,0 @@
import z from "zod"
import { Global } from "../global"
import { Log } from "../util/log"
import path from "path"
import { Filesystem } from "../util/filesystem"
import { NamedError } from "@opencode-ai/util/error"
import { Lock } from "../util/lock"
import { PackageRegistry } from "./registry"
import { proxied } from "@/util/proxied"
import { Process } from "../util/process"
export namespace BunProc {
const log = Log.create({ service: "bun" })
export async function run(cmd: string[], options?: Process.RunOptions) {
const full = [which(), ...cmd]
log.info("running", {
cmd: full,
...options,
})
const result = await Process.run(full, {
cwd: options?.cwd,
abort: options?.abort,
kill: options?.kill,
timeout: options?.timeout,
nothrow: options?.nothrow,
env: {
...process.env,
...options?.env,
BUN_BE_BUN: "1",
},
})
log.info("done", {
code: result.code,
stdout: result.stdout.toString(),
stderr: result.stderr.toString(),
})
return result
}
export function which() {
return process.execPath
}
export const InstallFailedError = NamedError.create(
"BunInstallFailedError",
z.object({
pkg: z.string(),
version: z.string(),
}),
)
export async function install(pkg: string, version = "latest") {
// Use lock to ensure only one install at a time
using _ = await Lock.write("bun-install")
const mod = path.join(Global.Path.cache, "node_modules", pkg)
const pkgjsonPath = path.join(Global.Path.cache, "package.json")
const parsed = await Filesystem.readJson<{ dependencies: Record<string, string> }>(pkgjsonPath).catch(async () => {
const result = { dependencies: {} as Record<string, string> }
await Filesystem.writeJson(pkgjsonPath, result)
return result
})
if (!parsed.dependencies) parsed.dependencies = {} as Record<string, string>
const dependencies = parsed.dependencies
const modExists = await Filesystem.exists(mod)
const cachedVersion = dependencies[pkg]
if (!modExists || !cachedVersion) {
// continue to install
} else if (version !== "latest" && cachedVersion === version) {
return mod
} else if (version === "latest") {
const isOutdated = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
if (!isOutdated) return mod
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
}
// Build command arguments
const args = [
"add",
"--force",
"--exact",
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied() || process.env.CI ? ["--no-cache"] : []),
"--cwd",
Global.Path.cache,
pkg + "@" + version,
]
// Let Bun handle registry resolution:
// - If .npmrc files exist, Bun will use them automatically
// - If no .npmrc files exist, Bun will default to https://registry.npmjs.org
// - No need to pass --registry flag
log.info("installing package using Bun's default registry resolution", {
pkg,
version,
})
await BunProc.run(args, {
cwd: Global.Path.cache,
}).catch((e) => {
throw new InstallFailedError(
{ pkg, version },
{
cause: e,
},
)
})
// Resolve actual version from installed package when using "latest"
// This ensures subsequent starts use the cached version until explicitly updated
let resolvedVersion = version
if (version === "latest") {
const installedPkg = await Filesystem.readJson<{ version?: string }>(path.join(mod, "package.json")).catch(
() => null,
)
if (installedPkg?.version) {
resolvedVersion = installedPkg.version
}
}
parsed.dependencies[pkg] = resolvedVersion
await Filesystem.writeJson(pkgjsonPath, parsed)
return mod
}
}

View File

@@ -1,44 +0,0 @@
import semver from "semver"
import { Log } from "../util/log"
import { Process } from "../util/process"
export namespace PackageRegistry {
const log = Log.create({ service: "bun" })
function which() {
return process.execPath
}
export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
const { code, stdout, stderr } = await Process.run([which(), "info", pkg, field], {
cwd,
env: {
...process.env,
BUN_BE_BUN: "1",
},
nothrow: true,
})
if (code !== 0) {
log.warn("bun info failed", { pkg, field, code, stderr: stderr.toString() })
return null
}
const value = stdout.toString().trim()
if (!value) return null
return value
}
export async function isOutdated(pkg: string, cachedVersion: string, cwd?: string): Promise<boolean> {
const latestVersion = await info(pkg, "version", cwd)
if (!latestVersion) {
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
return false
}
const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
return semver.lt(cachedVersion, latestVersion)
}
}

View File

@@ -1,12 +1,13 @@
import z from "zod"
import { Effect, Layer, PubSub, ServiceMap, Stream } from "effect"
import { Log } from "../util/log"
import { Instance } from "../project/instance"
import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global"
import { runCallbackInstance, runPromiseInstance } from "../effect/runtime"
export namespace Bus {
const log = Log.create({ service: "bus" })
type Subscription = (event: any) => void
export const InstanceDisposed = BusEvent.define(
"server.instance.disposed",
@@ -15,91 +16,130 @@ export namespace Bus {
}),
)
const state = Instance.state(
() => {
const subscriptions = new Map<any, Subscription[]>()
// ---------------------------------------------------------------------------
// Service definition
// ---------------------------------------------------------------------------
return {
subscriptions,
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
type: D["type"]
properties: z.infer<D["properties"]>
}
export interface Interface {
readonly publish: <D extends BusEvent.Definition>(
def: D,
properties: z.output<D["properties"]>,
) => Effect.Effect<void>
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
readonly subscribeAll: () => Stream.Stream<Payload>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Bus") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const pubsubs = new Map<string, PubSub.PubSub<Payload>>()
const wildcardPubSub = yield* PubSub.unbounded<Payload>()
const getOrCreate = Effect.fnUntraced(function* (type: string) {
let ps = pubsubs.get(type)
if (!ps) {
ps = yield* PubSub.unbounded<Payload>()
pubsubs.set(type, ps)
}
return ps
})
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
return Effect.gen(function* () {
const payload: Payload = { type: def.type, properties }
log.info("publishing", { type: def.type })
const ps = pubsubs.get(def.type)
if (ps) yield* PubSub.publish(ps, payload)
yield* PubSub.publish(wildcardPubSub, payload)
GlobalBus.emit("event", {
directory: Instance.directory,
payload,
})
})
}
},
async (entry) => {
const wildcard = entry.subscriptions.get("*")
if (!wildcard) return
const event = {
type: InstanceDisposed.type,
properties: {
directory: Instance.directory,
},
function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
log.info("subscribing", { type: def.type })
return Stream.unwrap(
Effect.gen(function* () {
const ps = yield* getOrCreate(def.type)
return Stream.fromPubSub(ps) as Stream.Stream<Payload<D>>
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
}
for (const sub of [...wildcard]) {
sub(event)
function subscribeAll(): Stream.Stream<Payload> {
log.info("subscribing", { type: "*" })
return Stream.fromPubSub(wildcardPubSub).pipe(
Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))),
)
}
},
// Shut down all PubSubs when the layer is torn down.
// This causes Stream.fromPubSub consumers to end, triggering
// their ensuring/finalizers.
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
log.info("shutting down PubSubs")
yield* PubSub.shutdown(wildcardPubSub)
for (const ps of pubsubs.values()) {
yield* PubSub.shutdown(ps)
}
}),
)
return Service.of({ publish, subscribe, subscribeAll })
}),
)
export async function publish<Definition extends BusEvent.Definition>(
def: Definition,
properties: z.output<Definition["properties"]>,
) {
const payload = {
type: def.type,
properties,
}
log.info("publishing", {
type: def.type,
})
const pending = []
for (const key of [def.type, "*"]) {
const match = [...(state().subscriptions.get(key) ?? [])]
for (const sub of match) {
pending.push(sub(payload))
}
}
GlobalBus.emit("event", {
directory: Instance.directory,
payload,
})
return Promise.all(pending)
// ---------------------------------------------------------------------------
// Legacy adapters — plain function API wrapping the Effect service
// ---------------------------------------------------------------------------
function runStream(stream: (svc: Interface) => Stream.Stream<Payload>, callback: (event: any) => void) {
return runCallbackInstance(
Service.use((svc) => stream(svc).pipe(Stream.runForEach((msg) => Effect.sync(() => callback(msg))))),
)
}
export function subscribe<Definition extends BusEvent.Definition>(
def: Definition,
callback: (event: { type: Definition["type"]; properties: z.infer<Definition["properties"]> }) => void,
) {
return raw(def.type, callback)
export function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
return runPromiseInstance(Service.use((svc) => svc.publish(def, properties)))
}
export function once<Definition extends BusEvent.Definition>(
def: Definition,
callback: (event: {
type: Definition["type"]
properties: z.infer<Definition["properties"]>
}) => "done" | undefined,
) {
const unsub = subscribe(def, (event) => {
if (callback(event)) unsub()
})
export function subscribe<D extends BusEvent.Definition>(def: D, callback: (event: Payload<D>) => void) {
return runStream((svc) => svc.subscribe(def), callback)
}
export function subscribeAll(callback: (event: any) => void) {
return raw("*", callback)
}
const directory = Instance.directory
function raw(type: string, callback: (event: any) => void) {
log.info("subscribing", { type })
const subscriptions = state().subscriptions
let match = subscriptions.get(type) ?? []
match.push(callback)
subscriptions.set(type, match)
// InstanceDisposed is delivered via GlobalBus because the legacy
// adapter's fiber starts asynchronously and may not be running when
// disposal happens. In the Effect-native path, forkScoped + scope
// closure handles this correctly. This bridge can be removed once
// upstream PubSub.shutdown properly wakes suspended subscribers:
// https://github.com/Effect-TS/effect-smol/pull/1800
const onDispose = (evt: { directory?: string; payload: any }) => {
if (evt.payload.type !== InstanceDisposed.type) return
if (evt.directory !== directory) return
callback(evt.payload)
GlobalBus.off("event", onDispose)
}
GlobalBus.on("event", onDispose)
const interrupt = runStream((svc) => svc.subscribeAll(), callback)
return () => {
log.info("unsubscribing", { type })
const match = subscriptions.get(type)
if (!match) return
const index = match.indexOf(callback)
if (index === -1) return
match.splice(index, 1)
GlobalBus.off("event", onDispose)
interrupt()
}
}
}

View File

@@ -2,7 +2,7 @@ import { cmd } from "./cmd"
import { Duration, Effect, Match, Option } from "effect"
import { UI } from "../ui"
import { runtime } from "@/effect/runtime"
import { AccountID, AccountEffect, OrgID, PollExpired, type PollResult } from "@/account/effect"
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account/effect"
import { type AccountError } from "@/account/schema"
import * as Prompt from "../effect/prompt"
import open from "open"
@@ -17,7 +17,7 @@ const isActiveOrgChoice = (
) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID
const loginEffect = Effect.fn("login")(function* (url: string) {
const service = yield* AccountEffect.Service
const service = yield* Account.Service
yield* Prompt.intro("Log in")
const login = yield* service.login(url)
@@ -58,7 +58,7 @@ const loginEffect = Effect.fn("login")(function* (url: string) {
})
const logoutEffect = Effect.fn("logout")(function* (email?: string) {
const service = yield* AccountEffect.Service
const service = yield* Account.Service
const accounts = yield* service.list()
if (accounts.length === 0) return yield* println("Not logged in")
@@ -98,7 +98,7 @@ interface OrgChoice {
}
const switchEffect = Effect.fn("switch")(function* () {
const service = yield* AccountEffect.Service
const service = yield* Account.Service
const groups = yield* service.orgsByAccount()
if (groups.length === 0) return yield* println("Not logged in")
@@ -129,7 +129,7 @@ const switchEffect = Effect.fn("switch")(function* () {
})
const orgsEffect = Effect.fn("orgs")(function* () {
const service = yield* AccountEffect.Service
const service = yield* Account.Service
const groups = yield* service.orgsByAccount()
if (groups.length === 0) return yield* println("No accounts found")

View File

@@ -23,7 +23,7 @@ export const AcpCommand = cmd({
process.env.OPENCODE_CLIENT = "acp"
await bootstrap(process.cwd(), async () => {
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
const server = await Server.listen(opts)
const sdk = createOpencodeClient({
baseUrl: `http://${server.hostname}:${server.port}`,

View File

@@ -370,6 +370,11 @@ export const RunCommand = cmd({
action: "deny",
pattern: "*",
},
{
permission: "edit",
action: "allow",
pattern: "*",
},
]
function title() {

View File

@@ -15,7 +15,7 @@ export const ServeCommand = cmd({
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
const server = await Server.listen(opts)
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
await new Promise(() => {})

View File

@@ -480,6 +480,7 @@ function App() {
{
title: "Toggle MCPs",
value: "mcp.list",
search: "toggle mcps",
category: "Agent",
slash: {
name: "mcps",
@@ -555,8 +556,9 @@ function App() {
category: "System",
},
{
title: "Toggle appearance",
title: mode() === "dark" ? "Light mode" : "Dark mode",
value: "theme.switch_mode",
search: "toggle appearance",
onSelect: (dialog) => {
setMode(mode() === "dark" ? "light" : "dark")
dialog.clear()
@@ -595,6 +597,7 @@ function App() {
},
{
title: "Toggle debug panel",
search: "toggle debug",
category: "System",
value: "app.debug",
onSelect: (dialog) => {
@@ -604,6 +607,7 @@ function App() {
},
{
title: "Toggle console",
search: "toggle console",
category: "System",
value: "app.console",
onSelect: (dialog) => {
@@ -644,6 +648,7 @@ function App() {
{
title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
value: "terminal.title.toggle",
search: "toggle terminal title",
keybind: "terminal_title_toggle",
category: "System",
onSelect: (dialog) => {
@@ -659,6 +664,7 @@ function App() {
{
title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
value: "app.toggle.animations",
search: "toggle animations",
category: "System",
onSelect: (dialog) => {
kv.set("animations_enabled", !kv.get("animations_enabled", true))
@@ -668,6 +674,7 @@ function App() {
{
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
value: "app.toggle.diffwrap",
search: "toggle diff wrapping",
category: "System",
onSelect: (dialog) => {
const current = kv.get("diff_wrap_mode", "word")

View File

@@ -1,4 +1,4 @@
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg } from "@opentui/core"
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes, t, dim, fg } from "@opentui/core"
import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
import "opentui-spinner/solid"
import path from "path"
@@ -79,6 +79,7 @@ export function Prompt(props: PromptProps) {
const renderer = useRenderer()
const { theme, syntax } = useTheme()
const kv = useKV()
const [autoaccept, setAutoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
function promptModelWarning() {
toast.show({
@@ -172,6 +173,17 @@ export function Prompt(props: PromptProps) {
command.register(() => {
return [
{
title: autoaccept() === "none" ? "Enable autoedit" : "Disable autoedit",
value: "permission.auto_accept.toggle",
search: "toggle permissions",
keybind: "permission_auto_accept_toggle",
category: "Agent",
onSelect: (dialog) => {
setAutoaccept(() => (autoaccept() === "none" ? "edit" : "none"))
dialog.clear()
},
},
{
title: "Clear prompt",
value: "prompt.clear",
@@ -934,7 +946,7 @@ export function Prompt(props: PromptProps) {
// Normalize line endings at the boundary
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
// Replace CRLF first, then any remaining CR
const normalizedText = event.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim()
if (!pastedContent) {
command.trigger("prompt.paste")
@@ -1010,23 +1022,30 @@ export function Prompt(props: PromptProps) {
cursorColor={theme.text}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
<text fg={highlight()}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
{local.model.parsed().model}
</text>
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
<Show when={showVariant()}>
<text fg={theme.textMuted}>·</text>
<text>
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
<box flexDirection="row" gap={1}>
<text fg={highlight()}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
{local.model.parsed().model}
</text>
</Show>
</box>
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
<Show when={showVariant()}>
<text fg={theme.textMuted}>·</text>
<text>
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
</text>
</Show>
</box>
</Show>
</box>
<Show when={autoaccept() === "edit"}>
<text>
<span style={{ fg: theme.warning }}>autoedit</span>
</text>
</Show>
</box>
</box>

View File

@@ -25,6 +25,7 @@ import { createSimpleContext } from "./helper"
import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit"
import { useArgs } from "./args"
import { useKV } from "./kv"
import { batch, onMount } from "solid-js"
import { Log } from "@/util/log"
import type { Path } from "@opencode-ai/sdk"
@@ -106,6 +107,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})
const sdk = useSDK()
const kv = useKV()
const [autoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
async function syncWorkspaces() {
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
@@ -136,6 +139,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
case "permission.asked": {
const request = event.properties
if (autoaccept() === "edit" && request.permission === "edit") {
sdk.client.permission.reply({
reply: "once",
requestID: request.id,
})
break
}
const requests = store.permission[request.sessionID]
if (!requests) {
setStore("permission", request.sessionID, [request])
@@ -451,6 +461,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
get ready() {
return store.status !== "loading"
},
session: {
get(sessionID: string) {
const match = Binary.search(store.session, sessionID, (s) => s.id)

View File

@@ -47,6 +47,7 @@ export function Home() {
{
title: tipsHidden() ? "Show tips" : "Hide tips",
value: "tips.toggle",
search: "toggle tips",
keybind: "tips_toggle",
category: "System",
onSelect: (dialog) => {

View File

@@ -568,6 +568,7 @@ export function Session() {
{
title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
value: "session.sidebar.toggle",
search: "toggle sidebar",
keybind: "sidebar_toggle",
category: "Session",
onSelect: (dialog) => {
@@ -582,6 +583,7 @@ export function Session() {
{
title: conceal() ? "Disable code concealment" : "Enable code concealment",
value: "session.toggle.conceal",
search: "toggle code concealment",
keybind: "messages_toggle_conceal" as any,
category: "Session",
onSelect: (dialog) => {
@@ -592,6 +594,7 @@ export function Session() {
{
title: showTimestamps() ? "Hide timestamps" : "Show timestamps",
value: "session.toggle.timestamps",
search: "toggle timestamps",
category: "Session",
slash: {
name: "timestamps",
@@ -605,6 +608,7 @@ export function Session() {
{
title: showThinking() ? "Hide thinking" : "Show thinking",
value: "session.toggle.thinking",
search: "toggle thinking",
keybind: "display_thinking",
category: "Session",
slash: {
@@ -619,6 +623,7 @@ export function Session() {
{
title: showDetails() ? "Hide tool details" : "Show tool details",
value: "session.toggle.actions",
search: "toggle tool details",
keybind: "tool_details",
category: "Session",
onSelect: (dialog) => {
@@ -627,8 +632,9 @@ export function Session() {
},
},
{
title: "Toggle session scrollbar",
title: showScrollbar() ? "Hide session scrollbar" : "Show session scrollbar",
value: "session.toggle.scrollbar",
search: "toggle session scrollbar",
keybind: "scrollbar_toggle",
category: "Session",
onSelect: (dialog) => {

View File

@@ -34,6 +34,7 @@ export interface DialogSelectOption<T = any> {
title: string
value: T
description?: string
search?: string
footer?: JSX.Element | string
category?: string
disabled?: boolean
@@ -85,8 +86,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
// users typically search by the item name, and not its category.
const result = fuzzysort
.go(needle, options, {
keys: ["title", "category"],
scoreFn: (r) => r[0].score * 2 + r[1].score,
keys: ["title", "category", "search"],
scoreFn: (r) => r[0].score * 2 + r[1].score + r[2].score,
})
.map((x) => x.obj)

View File

@@ -58,10 +58,10 @@ export const UpgradeCommand = {
spinner.stop("Upgrade failed", 1)
if (err instanceof Installation.UpgradeFailedError) {
// necessary because choco only allows install/upgrade in elevated terminals
if (method === "choco" && err.data.stderr.includes("not running from an elevated command shell")) {
if (method === "choco" && err.stderr.includes("not running from an elevated command shell")) {
prompts.log.error("Please run the terminal as Administrator and try again")
} else {
prompts.log.error(err.data.stderr)
prompts.log.error(err.stderr)
}
} else if (err instanceof Error) prompts.log.error(err.message)
prompts.outro("Done")

View File

@@ -37,7 +37,7 @@ export const WebCommand = cmd({
UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
const server = await Server.listen(opts)
UI.empty()
UI.println(UI.logo(" "))
UI.empty()

View File

@@ -1,6 +1,6 @@
import { Log } from "../util/log"
import path from "path"
import { pathToFileURL, fileURLToPath } from "url"
import { pathToFileURL } from "url"
import { createRequire } from "module"
import os from "os"
import z from "zod"
@@ -22,7 +22,6 @@ import {
} from "jsonc-parser"
import { Instance } from "../project/instance"
import { LSPServer } from "../lsp/server"
import { BunProc } from "@/bun"
import { Installation } from "@/installation"
import { ConfigMarkdown } from "./markdown"
import { constants, existsSync } from "fs"
@@ -30,14 +29,11 @@ import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
import { Glob } from "../util/glob"
import { PackageRegistry } from "@/bun/registry"
import { proxied } from "@/util/proxied"
import { iife } from "@/util/iife"
import { Account } from "@/account"
import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { Lock } from "@/util/lock"
import { Npm } from "@/npm"
export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
@@ -154,8 +150,7 @@ export namespace Config {
deps.push(
iife(async () => {
const shouldInstall = await needsInstall(dir)
if (shouldInstall) await installDependencies(dir)
await installDependencies(dir)
}),
)
@@ -271,6 +266,10 @@ export namespace Config {
}
export async function installDependencies(dir: string) {
if (!(await isWritable(dir))) {
log.info("config dir is not writable, skipping dependency install", { dir })
return
}
const pkg = path.join(dir, "package.json")
const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION
@@ -284,43 +283,15 @@ export namespace Config {
await Filesystem.writeJson(pkg, json)
const gitignore = path.join(dir, ".gitignore")
const hasGitIgnore = await Filesystem.exists(gitignore)
if (!hasGitIgnore)
await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
if (!(await Filesystem.exists(gitignore)))
await Filesystem.write(
gitignore,
["node_modules", "plans", "package.json", "bun.lock", ".gitignore", "package-lock.json"].join("\n"),
)
// Install any additional dependencies defined in the package.json
// This allows local plugins and custom tools to use external packages
using _ = await Lock.write("bun-install")
await BunProc.run(
[
"install",
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied() || process.env.CI ? ["--no-cache"] : []),
],
{ cwd: dir },
).catch((err) => {
if (err instanceof Process.RunFailedError) {
const detail = {
dir,
cmd: err.cmd,
code: err.code,
stdout: err.stdout.toString(),
stderr: err.stderr.toString(),
}
if (Flag.OPENCODE_STRICT_CONFIG_DEPS) {
log.error("failed to install dependencies", detail)
throw err
}
log.warn("failed to install dependencies", detail)
return
}
if (Flag.OPENCODE_STRICT_CONFIG_DEPS) {
log.error("failed to install dependencies", { dir, error: err })
throw err
}
log.warn("failed to install dependencies", { dir, error: err })
})
await Npm.install(dir)
}
async function isWritable(dir: string) {
@@ -332,41 +303,6 @@ export namespace Config {
}
}
export async function needsInstall(dir: string) {
// Some config dirs may be read-only.
// Installing deps there will fail; skip installation in that case.
const writable = await isWritable(dir)
if (!writable) {
log.debug("config dir is not writable, skipping dependency install", { dir })
return false
}
const nodeModules = path.join(dir, "node_modules")
if (!existsSync(nodeModules)) return true
const pkg = path.join(dir, "package.json")
const pkgExists = await Filesystem.exists(pkg)
if (!pkgExists) return true
const parsed = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => null)
const dependencies = parsed?.dependencies ?? {}
const depVersion = dependencies["@opencode-ai/plugin"]
if (!depVersion) return true
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
if (targetVersion === "latest") {
const isOutdated = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
if (!isOutdated) return false
log.info("Cached version is outdated, proceeding with install", {
pkg: "@opencode-ai/plugin",
cachedVersion: depVersion,
})
return true
}
if (depVersion === targetVersion) return false
return true
}
function rel(item: string, patterns: string[]) {
const normalizedItem = item.replaceAll("\\", "/")
for (const pattern of patterns) {
@@ -858,7 +794,12 @@ export namespace Config {
command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
agent_list: z.string().optional().default("<leader>a").describe("List agents"),
agent_cycle: z.string().optional().default("tab").describe("Next agent"),
agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
agent_cycle_reverse: z.string().optional().default("none").describe("Previous agent"),
permission_auto_accept_toggle: z
.string()
.optional()
.default("shift+tab")
.describe("Toggle auto-accept mode for permissions"),
variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"),
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),

View File

@@ -1,3 +1,4 @@
import { createAdaptorServer } from "@hono/node-server"
import { Hono } from "hono"
import { Instance } from "../../project/instance"
import { InstanceBootstrap } from "../../project/bootstrap"
@@ -56,10 +57,24 @@ export namespace WorkspaceServer {
}
export function Listen(opts: { hostname: string; port: number }) {
return Bun.serve({
hostname: opts.hostname,
port: opts.port,
const server = createAdaptorServer({
fetch: App().fetch,
})
server.listen(opts.port, opts.hostname)
return {
hostname: opts.hostname,
port: opts.port,
stop() {
return new Promise<void>((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err)
return
}
resolve()
})
})
},
}
}
}

View File

@@ -1,4 +1,5 @@
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
import { Effect, Exit, Fiber, Layer, LayerMap, MutableHashMap, Scope, ServiceMap } from "effect"
import { Bus } from "@/bus"
import { File } from "@/file"
import { FileTime } from "@/file/time"
import { FileWatcher } from "@/file/watcher"
@@ -10,13 +11,13 @@ import { ProviderAuth } from "@/provider/auth"
import { Question } from "@/question"
import { Skill } from "@/skill/skill"
import { Snapshot } from "@/snapshot"
import { ToolRegistry } from "@/tool/registry"
import { InstanceContext } from "./instance-context"
import { registerDisposer } from "./instance-registry"
export { InstanceContext } from "./instance-context"
export type InstanceServices =
| Bus.Service
| Question.Service
| PermissionNext.Service
| ProviderAuth.Service
@@ -27,7 +28,6 @@ export type InstanceServices =
| File.Service
| Skill.Service
| Snapshot.Service
| ToolRegistry.Service
// NOTE: LayerMap only passes the key (directory string) to lookup, but we need
// the full instance context (directory, worktree, project). We read from the
@@ -38,6 +38,7 @@ export type InstanceServices =
function lookup(_key: string) {
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
return Layer.mergeAll(
Layer.fresh(Bus.layer),
Layer.fresh(Question.layer),
Layer.fresh(PermissionNext.layer),
Layer.fresh(ProviderAuth.defaultLayer),
@@ -48,7 +49,6 @@ function lookup(_key: string) {
Layer.fresh(File.layer),
Layer.fresh(Skill.defaultLayer),
Layer.fresh(Snapshot.defaultLayer),
Layer.fresh(ToolRegistry.layer),
).pipe(Layer.provide(ctx))
}
@@ -59,7 +59,23 @@ export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<s
Instances,
Effect.gen(function* () {
const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity })
const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory)))
// Force-invalidate closes the RcMap entry scope even when refCount > 0.
// Standard RcMap.invalidate bails in that case, leaving long-running
// consumer fibers orphaned. This is an upstream issue:
// https://github.com/Effect-TS/effect-smol/pull/1799
const forceInvalidate = (directory: string) =>
Effect.gen(function* () {
const rcMap = layerMap.rcMap
if (rcMap.state._tag === "Closed") return
const entry = MutableHashMap.get(rcMap.state.map, directory)
if (entry._tag === "None") return
MutableHashMap.remove(rcMap.state.map, directory)
if (entry.value.fiber) yield* Fiber.interrupt(entry.value.fiber)
yield* Scope.close(entry.value.scope, Exit.void)
}).pipe(Effect.uninterruptible, Effect.ignore)
const unregister = registerDisposer((directory) => Effect.runPromise(forceInvalidate(directory)))
yield* Effect.addFinalizer(() => Effect.sync(unregister))
return Instances.of(layerMap)
}),

View File

@@ -1,23 +1,31 @@
import { Effect, Layer, ManagedRuntime } from "effect"
import { AccountEffect } from "@/account/effect"
import { AuthEffect } from "@/auth/effect"
import { Account } from "@/account/effect"
import { Auth } from "@/auth/effect"
import { Instances } from "@/effect/instances"
import type { InstanceServices } from "@/effect/instances"
import { TruncateEffect } from "@/tool/truncate-effect"
import { Installation } from "@/installation"
import { Truncate } from "@/tool/truncate-effect"
import { Instance } from "@/project/instance"
export const runtime = ManagedRuntime.make(
Layer.mergeAll(
AccountEffect.defaultLayer, //
TruncateEffect.defaultLayer,
Account.defaultLayer, //
Installation.defaultLayer,
Truncate.defaultLayer,
Instances.layer,
).pipe(Layer.provideMerge(AuthEffect.layer)),
).pipe(Layer.provideMerge(Auth.layer)),
)
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
}
export function runCallbackInstance<A, E>(
effect: Effect.Effect<A, E, InstanceServices>,
): (interruptor?: number) => void {
return runtime.runCallback(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
}
export function disposeRuntime() {
return runtime.dispose()
}

View File

@@ -1,43 +1,40 @@
import { text } from "node:stream/consumers"
import { BunProc } from "../bun"
import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
import { Process } from "../util/process"
import { which } from "../util/which"
import { Flag } from "@/flag/flag"
import { Npm } from "@/npm"
export interface Info {
name: string
command: string[]
environment?: Record<string, string>
extensions: string[]
enabled(): Promise<boolean>
enabled(): Promise<string[] | false>
}
export const gofmt: Info = {
name: "gofmt",
command: ["gofmt", "-w", "$FILE"],
extensions: [".go"],
async enabled() {
return which("gofmt") !== null
const p = which("gofmt")
if (p === null) return false
return [p, "-w", "$FILE"]
},
}
export const mix: Info = {
name: "mix",
command: ["mix", "format", "$FILE"],
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
async enabled() {
return which("mix") !== null
const p = which("mix")
if (p === null) return false
return [p, "format", "$FILE"]
},
}
export const prettier: Info = {
name: "prettier",
command: [BunProc.which(), "x", "prettier", "--write", "$FILE"],
environment: {
BUN_BE_BUN: "1",
},
extensions: [
".js",
".jsx",
@@ -73,8 +70,11 @@ export const prettier: Info = {
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
}>(item)
if (json.dependencies?.prettier) return true
if (json.devDependencies?.prettier) return true
if (json.dependencies?.prettier || json.devDependencies?.prettier) {
const bin = await Npm.which("prettier").catch(() => null)
if (!bin) return false
return [bin, "--write", "$FILE"]
}
}
return false
},
@@ -82,10 +82,6 @@ export const prettier: Info = {
export const oxfmt: Info = {
name: "oxfmt",
command: [BunProc.which(), "x", "oxfmt", "$FILE"],
environment: {
BUN_BE_BUN: "1",
},
extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"],
async enabled() {
if (!Flag.OPENCODE_EXPERIMENTAL_OXFMT) return false
@@ -95,8 +91,9 @@ export const oxfmt: Info = {
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
}>(item)
if (json.dependencies?.oxfmt) return true
if (json.devDependencies?.oxfmt) return true
if (json.dependencies?.oxfmt || json.devDependencies?.oxfmt) {
return [await Npm.which("oxfmt"), "$FILE"]
}
}
return false
},
@@ -104,10 +101,6 @@ export const oxfmt: Info = {
export const biome: Info = {
name: "biome",
command: [BunProc.which(), "x", "@biomejs/biome", "check", "--write", "$FILE"],
environment: {
BUN_BE_BUN: "1",
},
extensions: [
".js",
".jsx",
@@ -141,7 +134,7 @@ export const biome: Info = {
for (const config of configs) {
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
if (found.length > 0) {
return true
return [await Npm.which("@biomejs/biome"), "check", "--write", "$FILE"]
}
}
return false
@@ -150,47 +143,49 @@ export const biome: Info = {
export const zig: Info = {
name: "zig",
command: ["zig", "fmt", "$FILE"],
extensions: [".zig", ".zon"],
async enabled() {
return which("zig") !== null
const p = which("zig")
if (p === null) return false
return [p, "fmt", "$FILE"]
},
}
export const clang: Info = {
name: "clang-format",
command: ["clang-format", "-i", "$FILE"],
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
async enabled() {
const items = await Filesystem.findUp(".clang-format", Instance.directory, Instance.worktree)
return items.length > 0
if (items.length === 0) return false
return ["clang-format", "-i", "$FILE"]
},
}
export const ktlint: Info = {
name: "ktlint",
command: ["ktlint", "-F", "$FILE"],
extensions: [".kt", ".kts"],
async enabled() {
return which("ktlint") !== null
const p = which("ktlint")
if (p === null) return false
return [p, "-F", "$FILE"]
},
}
export const ruff: Info = {
name: "ruff",
command: ["ruff", "format", "$FILE"],
extensions: [".py", ".pyi"],
async enabled() {
if (!which("ruff")) return false
const p = which("ruff")
if (p === null) return false
const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"]
for (const config of configs) {
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
if (found.length > 0) {
if (config === "pyproject.toml") {
const content = await Filesystem.readText(found[0])
if (content.includes("[tool.ruff]")) return true
if (content.includes("[tool.ruff]")) return [p, "format", "$FILE"]
} else {
return true
return [p, "format", "$FILE"]
}
}
}
@@ -199,7 +194,7 @@ export const ruff: Info = {
const found = await Filesystem.findUp(dep, Instance.directory, Instance.worktree)
if (found.length > 0) {
const content = await Filesystem.readText(found[0])
if (content.includes("ruff")) return true
if (content.includes("ruff")) return [p, "format", "$FILE"]
}
}
return false
@@ -208,14 +203,13 @@ export const ruff: Info = {
export const rlang: Info = {
name: "air",
command: ["air", "format", "$FILE"],
extensions: [".R"],
async enabled() {
const airPath = which("air")
if (airPath == null) return false
try {
const proc = Process.spawn(["air", "--help"], {
const proc = Process.spawn([airPath, "--help"], {
stdout: "pipe",
stderr: "pipe",
})
@@ -227,7 +221,10 @@ export const rlang: Info = {
const firstLine = output.split("\n")[0]
const hasR = firstLine.includes("R language")
const hasFormatter = firstLine.includes("formatter")
return hasR && hasFormatter
if (hasR && hasFormatter) {
return [airPath, "format", "$FILE"]
}
return false
} catch (error) {
return false
}
@@ -236,14 +233,14 @@ export const rlang: Info = {
export const uvformat: Info = {
name: "uv",
command: ["uv", "format", "--", "$FILE"],
extensions: [".py", ".pyi"],
async enabled() {
if (await ruff.enabled()) return false
if (which("uv") !== null) {
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
const uvPath = which("uv")
if (uvPath !== null) {
const proc = Process.spawn([uvPath, "format", "--help"], { stderr: "pipe", stdout: "pipe" })
const code = await proc.exited
return code === 0
if (code === 0) return [uvPath, "format", "--", "$FILE"]
}
return false
},
@@ -251,108 +248,118 @@ export const uvformat: Info = {
export const rubocop: Info = {
name: "rubocop",
command: ["rubocop", "--autocorrect", "$FILE"],
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async enabled() {
return which("rubocop") !== null
const path = which("rubocop")
if (path === null) return false
return [path, "--autocorrect", "$FILE"]
},
}
export const standardrb: Info = {
name: "standardrb",
command: ["standardrb", "--fix", "$FILE"],
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async enabled() {
return which("standardrb") !== null
const path = which("standardrb")
if (path === null) return false
return [path, "--fix", "$FILE"]
},
}
export const htmlbeautifier: Info = {
name: "htmlbeautifier",
command: ["htmlbeautifier", "$FILE"],
extensions: [".erb", ".html.erb"],
async enabled() {
return which("htmlbeautifier") !== null
const path = which("htmlbeautifier")
if (path === null) return false
return [path, "$FILE"]
},
}
export const dart: Info = {
name: "dart",
command: ["dart", "format", "$FILE"],
extensions: [".dart"],
async enabled() {
return which("dart") !== null
const path = which("dart")
if (path === null) return false
return [path, "format", "$FILE"]
},
}
export const ocamlformat: Info = {
name: "ocamlformat",
command: ["ocamlformat", "-i", "$FILE"],
extensions: [".ml", ".mli"],
async enabled() {
if (!which("ocamlformat")) return false
const path = which("ocamlformat")
if (!path) return false
const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree)
return items.length > 0
if (items.length === 0) return false
return [path, "-i", "$FILE"]
},
}
export const terraform: Info = {
name: "terraform",
command: ["terraform", "fmt", "$FILE"],
extensions: [".tf", ".tfvars"],
async enabled() {
return which("terraform") !== null
const path = which("terraform")
if (path === null) return false
return [path, "fmt", "$FILE"]
},
}
export const latexindent: Info = {
name: "latexindent",
command: ["latexindent", "-w", "-s", "$FILE"],
extensions: [".tex"],
async enabled() {
return which("latexindent") !== null
const path = which("latexindent")
if (path === null) return false
return [path, "-w", "-s", "$FILE"]
},
}
export const gleam: Info = {
name: "gleam",
command: ["gleam", "format", "$FILE"],
extensions: [".gleam"],
async enabled() {
return which("gleam") !== null
const path = which("gleam")
if (path === null) return false
return [path, "format", "$FILE"]
},
}
export const shfmt: Info = {
name: "shfmt",
command: ["shfmt", "-w", "$FILE"],
extensions: [".sh", ".bash"],
async enabled() {
return which("shfmt") !== null
const path = which("shfmt")
if (path === null) return false
return [path, "-w", "$FILE"]
},
}
export const nixfmt: Info = {
name: "nixfmt",
command: ["nixfmt", "$FILE"],
extensions: [".nix"],
async enabled() {
return which("nixfmt") !== null
const path = which("nixfmt")
if (path === null) return false
return [path, "$FILE"]
},
}
export const rustfmt: Info = {
name: "rustfmt",
command: ["rustfmt", "$FILE"],
extensions: [".rs"],
async enabled() {
return which("rustfmt") !== null
const path = which("rustfmt")
if (path === null) return false
return [path, "$FILE"]
},
}
export const pint: Info = {
name: "pint",
command: ["./vendor/bin/pint", "$FILE"],
extensions: [".php"],
async enabled() {
const items = await Filesystem.findUp("composer.json", Instance.directory, Instance.worktree)
@@ -361,8 +368,9 @@ export const pint: Info = {
require?: Record<string, string>
"require-dev"?: Record<string, string>
}>(item)
if (json.require?.["laravel/pint"]) return true
if (json["require-dev"]?.["laravel/pint"]) return true
if (json.require?.["laravel/pint"] || json["require-dev"]?.["laravel/pint"]) {
return ["./vendor/bin/pint", "$FILE"]
}
}
return false
},
@@ -370,27 +378,30 @@ export const pint: Info = {
export const ormolu: Info = {
name: "ormolu",
command: ["ormolu", "-i", "$FILE"],
extensions: [".hs"],
async enabled() {
return which("ormolu") !== null
const path = which("ormolu")
if (path === null) return false
return [path, "-i", "$FILE"]
},
}
export const cljfmt: Info = {
name: "cljfmt",
command: ["cljfmt", "fix", "--quiet", "$FILE"],
extensions: [".clj", ".cljs", ".cljc", ".edn"],
async enabled() {
return which("cljfmt") !== null
const path = which("cljfmt")
if (path === null) return false
return [path, "fix", "--quiet", "$FILE"]
},
}
export const dfmt: Info = {
name: "dfmt",
command: ["dfmt", "-i", "$FILE"],
extensions: [".d"],
async enabled() {
return which("dfmt") !== null
const path = which("dfmt")
if (path === null) return false
return [path, "-i", "$FILE"]
},
}

View File

@@ -4,9 +4,7 @@ import { InstanceContext } from "@/effect/instance-context"
import path from "path"
import { mergeDeep } from "remeda"
import z from "zod"
import { Bus } from "../bus"
import { Config } from "../config/config"
import { File } from "../file"
import { Instance } from "../project/instance"
import { Process } from "../util/process"
import { Log } from "../util/log"
@@ -27,6 +25,7 @@ export namespace Format {
export type Status = z.infer<typeof Status>
export interface Interface {
readonly run: (filepath: string) => Effect.Effect<void>
readonly status: () => Effect.Effect<Status[]>
}
@@ -37,7 +36,7 @@ export namespace Format {
Effect.gen(function* () {
const instance = yield* InstanceContext
const enabled: Record<string, boolean> = {}
const enabled: Record<string, string[] | false> = {}
const formatters: Record<string, Formatter.Info> = {}
const cfg = yield* Effect.promise(() => Config.get())
@@ -62,7 +61,7 @@ export namespace Format {
formatters[name] = {
...info,
name,
enabled: async () => true,
enabled: async () => info.command,
}
}
} else {
@@ -79,59 +78,64 @@ export namespace Format {
}
async function getFormatter(ext: string) {
const result = []
const result: Array<{
name: string
command: string[]
environment?: Record<string, string>
}> = []
for (const item of Object.values(formatters)) {
log.info("checking", { name: item.name, ext })
if (!item.extensions.includes(ext)) continue
if (!(await isEnabled(item))) continue
const cmd = await isEnabled(item)
if (!cmd) continue
log.info("enabled", { name: item.name, ext })
result.push(item)
result.push({
name: item.name,
command: cmd,
environment: item.environment,
})
}
return result
}
yield* Effect.acquireRelease(
Effect.sync(() =>
Bus.subscribe(
File.Event.Edited,
Instance.bind(async (payload) => {
const file = payload.properties.file
log.info("formatting", { file })
const ext = path.extname(file)
const run = Effect.fn("Format.run")(function* (filepath: string) {
log.info("formatting", { file: filepath })
const ext = path.extname(filepath)
for (const item of await getFormatter(ext)) {
log.info("running", { command: item.command })
try {
const proc = Process.spawn(
item.command.map((x) => x.replace("$FILE", file)),
{
cwd: instance.directory,
env: { ...process.env, ...item.environment },
stdout: "ignore",
stderr: "ignore",
},
)
const exit = await proc.exited
if (exit !== 0) {
log.error("failed", {
command: item.command,
...item.environment,
})
}
} catch (error) {
log.error("failed to format file", {
error,
command: item.command,
...item.environment,
file,
})
}
for (const item of yield* Effect.promise(() => getFormatter(ext))) {
log.info("running", { command: item.command })
yield* Effect.tryPromise({
try: async () => {
const proc = Process.spawn(
item.command.map((x) => x.replace("$FILE", filepath)),
{
cwd: instance.directory,
env: { ...process.env, ...item.environment },
stdout: "ignore",
stderr: "ignore",
},
)
const exit = await proc.exited
if (exit !== 0) {
log.error("failed", {
command: item.command,
...item.environment,
})
}
}),
),
),
(unsubscribe) => Effect.sync(unsubscribe),
)
},
catch: (error) => {
log.error("failed to format file", {
error,
command: item.command,
...item.environment,
file: filepath,
})
return error
},
}).pipe(Effect.ignore)
}
})
log.info("init")
const status = Effect.fn("Format.status")(function* () {
@@ -141,16 +145,20 @@ export namespace Format {
result.push({
name: formatter.name,
extensions: formatter.extensions,
enabled: isOn,
enabled: !!isOn,
})
}
return result
})
return Service.of({ status })
return Service.of({ run, status })
}),
)
export async function run(filepath: string) {
return runPromiseInstance(Service.use((s) => s.run(filepath)))
}
export async function status() {
return runPromiseInstance(Service.use((s) => s.status()))
}

View File

@@ -18,7 +18,7 @@ export namespace Global {
return process.env.OPENCODE_TEST_HOME || os.homedir()
},
data,
bin: path.join(data, "bin"),
bin: path.join(cache, "bin"),
log: path.join(data, "log"),
cache,
config,

View File

@@ -1,12 +1,13 @@
import { BusEvent } from "@/bus/bus-event"
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import path from "path"
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { Log } from "../util/log"
import { iife } from "@/util/iife"
import { BusEvent } from "@/bus/bus-event"
import { Flag } from "../flag/flag"
import { Process } from "@/util/process"
import { buffer } from "node:stream/consumers"
import { Log } from "../util/log"
declare global {
const OPENCODE_VERSION: string
@@ -16,39 +17,7 @@ declare global {
export namespace Installation {
const log = Log.create({ service: "installation" })
async function text(cmd: string[], opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) {
return Process.text(cmd, {
cwd: opts.cwd,
env: opts.env,
nothrow: true,
}).then((x) => x.text)
}
async function upgradeCurl(target: string) {
const body = await fetch("https://opencode.ai/install").then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.text()
})
const proc = Process.spawn(["bash"], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
VERSION: target,
},
})
if (!proc.stdin || !proc.stdout || !proc.stderr) throw new Error("Process output not available")
proc.stdin.end(body)
const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
return {
code,
stdout,
stderr,
}
}
export type Method = Awaited<ReturnType<typeof method>>
export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown"
export const Event = {
Updated: BusEvent.define(
@@ -75,12 +44,9 @@ export namespace Installation {
})
export type Info = z.infer<typeof Info>
export async function info() {
return {
version: VERSION,
latest: await latest(),
}
}
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}`
export function isPreview() {
return CHANNEL !== "latest"
@@ -90,214 +56,306 @@ export namespace Installation {
return CHANNEL === "local"
}
export async function method() {
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl"
if (process.execPath.includes(path.join(".local", "bin"))) return "curl"
const exec = process.execPath.toLowerCase()
export class UpgradeFailedError extends Schema.TaggedErrorClass<UpgradeFailedError>()("UpgradeFailedError", {
stderr: Schema.String,
}) {}
const checks = [
{
name: "npm" as const,
command: () => text(["npm", "list", "-g", "--depth=0"]),
},
{
name: "yarn" as const,
command: () => text(["yarn", "global", "list"]),
},
{
name: "pnpm" as const,
command: () => text(["pnpm", "list", "-g", "--depth=0"]),
},
{
name: "bun" as const,
command: () => text(["bun", "pm", "ls", "-g"]),
},
{
name: "brew" as const,
command: () => text(["brew", "list", "--formula", "opencode"]),
},
{
name: "scoop" as const,
command: () => text(["scoop", "list", "opencode"]),
},
{
name: "choco" as const,
command: () => text(["choco", "list", "--limit-output", "opencode"]),
},
]
// Response schemas for external version APIs
const GitHubRelease = Schema.Struct({ tag_name: Schema.String })
const NpmPackage = Schema.Struct({ version: Schema.String })
const BrewFormula = Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })
const BrewInfoV2 = Schema.Struct({
formulae: Schema.Array(Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })),
})
const ChocoPackage = Schema.Struct({
d: Schema.Struct({ results: Schema.Array(Schema.Struct({ Version: Schema.String })) }),
})
const ScoopManifest = NpmPackage
checks.sort((a, b) => {
const aMatches = exec.includes(a.name)
const bMatches = exec.includes(b.name)
if (aMatches && !bMatches) return -1
if (!aMatches && bMatches) return 1
return 0
})
for (const check of checks) {
const output = await check.command()
const installedName =
check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai"
if (output.includes(installedName)) {
return check.name
}
}
return "unknown"
export interface Interface {
readonly info: () => Effect.Effect<Info>
readonly method: () => Effect.Effect<Method>
readonly latest: (method?: Method) => Effect.Effect<string>
readonly upgrade: (method: Method, target: string) => Effect.Effect<void, UpgradeFailedError>
}
export const UpgradeFailedError = NamedError.create(
"UpgradeFailedError",
z.object({
stderr: z.string(),
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Installation") {}
export const layer: Layer.Layer<
Service,
never,
HttpClient.HttpClient | ChildProcessSpawner.ChildProcessSpawner
> = Layer.effect(
Service,
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const httpOk = HttpClient.filterStatusOk(withTransientReadRetry(http))
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const text = Effect.fnUntraced(
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
const proc = ChildProcess.make(cmd[0], cmd.slice(1), {
cwd: opts?.cwd,
env: opts?.env,
extendEnv: true,
})
const handle = yield* spawner.spawn(proc)
const out = yield* Stream.mkString(Stream.decodeText(handle.stdout))
yield* handle.exitCode
return out
},
Effect.scoped,
Effect.catch(() => Effect.succeed("")),
)
const run = Effect.fnUntraced(
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
const proc = ChildProcess.make(cmd[0], cmd.slice(1), {
cwd: opts?.cwd,
env: opts?.env,
extendEnv: true,
})
const handle = yield* spawner.spawn(proc)
const [stdout, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const code = yield* handle.exitCode
return { code, stdout, stderr }
},
Effect.scoped,
Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })),
)
const getBrewFormula = Effect.fnUntraced(function* () {
const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
const coreFormula = yield* text(["brew", "list", "--formula", "opencode"])
if (coreFormula.includes("opencode")) return "opencode"
return "opencode"
})
const upgradeCurl = Effect.fnUntraced(
function* (target: string) {
const response = yield* httpOk.execute(HttpClientRequest.get("https://opencode.ai/install"))
const body = yield* response.text
const bodyBytes = new TextEncoder().encode(body)
const proc = ChildProcess.make("bash", [], {
stdin: Stream.make(bodyBytes),
env: { VERSION: target },
extendEnv: true,
})
const handle = yield* spawner.spawn(proc)
const [stdout, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const code = yield* handle.exitCode
return { code, stdout, stderr }
},
Effect.scoped,
Effect.orDie,
)
const methodImpl = Effect.fn("Installation.method")(function* () {
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl" as Method
if (process.execPath.includes(path.join(".local", "bin"))) return "curl" as Method
const exec = process.execPath.toLowerCase()
const checks: Array<{ name: Method; command: () => Effect.Effect<string> }> = [
{ name: "npm", command: () => text(["npm", "list", "-g", "--depth=0"]) },
{ name: "yarn", command: () => text(["yarn", "global", "list"]) },
{ name: "pnpm", command: () => text(["pnpm", "list", "-g", "--depth=0"]) },
{ name: "bun", command: () => text(["bun", "pm", "ls", "-g"]) },
{ name: "brew", command: () => text(["brew", "list", "--formula", "opencode"]) },
{ name: "scoop", command: () => text(["scoop", "list", "opencode"]) },
{ name: "choco", command: () => text(["choco", "list", "--limit-output", "opencode"]) },
]
checks.sort((a, b) => {
const aMatches = exec.includes(a.name)
const bMatches = exec.includes(b.name)
if (aMatches && !bMatches) return -1
if (!aMatches && bMatches) return 1
return 0
})
for (const check of checks) {
const output = yield* check.command()
const installedName =
check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai"
if (output.includes(installedName)) {
return check.name
}
}
return "unknown" as Method
})
const latestImpl = Effect.fn("Installation.latest")(
function* (installMethod?: Method) {
const detectedMethod = installMethod || (yield* methodImpl())
if (detectedMethod === "brew") {
const formula = yield* getBrewFormula()
if (formula.includes("/")) {
const infoJson = yield* text(["brew", "info", "--json=v2", formula])
const info = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(BrewInfoV2))(infoJson)
return info.formulae[0].versions.stable
}
const response = yield* httpOk.execute(
HttpClientRequest.get("https://formulae.brew.sh/api/formula/opencode.json").pipe(
HttpClientRequest.acceptJson,
),
)
const data = yield* HttpClientResponse.schemaBodyJson(BrewFormula)(response)
return data.versions.stable
}
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
const r = (yield* text(["npm", "config", "get", "registry"])).trim()
const reg = r || "https://registry.npmjs.org"
const registry = reg.endsWith("/") ? reg.slice(0, -1) : reg
const channel = CHANNEL
const response = yield* httpOk.execute(
HttpClientRequest.get(`${registry}/opencode-ai/${channel}`).pipe(HttpClientRequest.acceptJson),
)
const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response)
return data.version
}
if (detectedMethod === "choco") {
const response = yield* httpOk.execute(
HttpClientRequest.get(
"https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json;odata=verbose" })),
)
const data = yield* HttpClientResponse.schemaBodyJson(ChocoPackage)(response)
return data.d.results[0].Version
}
if (detectedMethod === "scoop") {
const response = yield* httpOk.execute(
HttpClientRequest.get(
"https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json",
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json" })),
)
const data = yield* HttpClientResponse.schemaBodyJson(ScoopManifest)(response)
return data.version
}
const response = yield* httpOk.execute(
HttpClientRequest.get("https://api.github.com/repos/anomalyco/opencode/releases/latest").pipe(
HttpClientRequest.acceptJson,
),
)
const data = yield* HttpClientResponse.schemaBodyJson(GitHubRelease)(response)
return data.tag_name.replace(/^v/, "")
},
Effect.orDie,
)
const upgradeImpl = Effect.fn("Installation.upgrade")(function* (m: Method, target: string) {
let result: { code: ChildProcessSpawner.ExitCode; stdout: string; stderr: string } | undefined
switch (m) {
case "curl":
result = yield* upgradeCurl(target)
break
case "npm":
result = yield* run(["npm", "install", "-g", `opencode-ai@${target}`])
break
case "pnpm":
result = yield* run(["pnpm", "install", "-g", `opencode-ai@${target}`])
break
case "bun":
result = yield* run(["bun", "install", "-g", `opencode-ai@${target}`])
break
case "brew": {
const formula = yield* getBrewFormula()
const env = { HOMEBREW_NO_AUTO_UPDATE: "1" }
if (formula.includes("/")) {
const tap = yield* run(["brew", "tap", "anomalyco/tap"], { env })
if (tap.code !== 0) {
result = tap
break
}
const repo = yield* text(["brew", "--repo", "anomalyco/tap"])
const dir = repo.trim()
if (dir) {
const pull = yield* run(["git", "pull", "--ff-only"], { cwd: dir, env })
if (pull.code !== 0) {
result = pull
break
}
}
}
result = yield* run(["brew", "upgrade", formula], { env })
break
}
case "choco":
result = yield* run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"])
break
case "scoop":
result = yield* run(["scoop", "install", `opencode@${target}`])
break
default:
throw new Error(`Unknown method: ${m}`)
}
if (!result || result.code !== 0) {
const stderr = m === "choco" ? "not running from an elevated command shell" : result?.stderr || ""
return yield* new UpgradeFailedError({ stderr })
}
log.info("upgraded", {
method: m,
target,
stdout: result.stdout,
stderr: result.stderr,
})
yield* text([process.execPath, "--version"])
})
return Service.of({
info: Effect.fn("Installation.info")(function* () {
return {
version: VERSION,
latest: yield* latestImpl(),
}
}),
method: methodImpl,
latest: latestImpl,
upgrade: upgradeImpl,
})
}),
)
async function getBrewFormula() {
const tapFormula = await text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
const coreFormula = await text(["brew", "list", "--formula", "opencode"])
if (coreFormula.includes("opencode")) return "opencode"
return "opencode"
export const defaultLayer = layer.pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(NodeChildProcessSpawner.layer),
Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer),
)
// Legacy adapters — dynamic import avoids circular dependency since
// foundational modules (db.ts, provider/models.ts) import Installation
// at load time, and runtime transitively loads those same modules.
async function runPromise<A>(f: (service: Interface) => Effect.Effect<A, any>) {
const { runtime } = await import("@/effect/runtime")
return runtime.runPromise(Service.use(f))
}
export async function upgrade(method: Method, target: string) {
let result: Awaited<ReturnType<typeof upgradeCurl>> | undefined
switch (method) {
case "curl":
result = await upgradeCurl(target)
break
case "npm":
result = await Process.run(["npm", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
break
case "pnpm":
result = await Process.run(["pnpm", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
break
case "bun":
result = await Process.run(["bun", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
break
case "brew": {
const formula = await getBrewFormula()
const env = {
HOMEBREW_NO_AUTO_UPDATE: "1",
...process.env,
}
if (formula.includes("/")) {
const tap = await Process.run(["brew", "tap", "anomalyco/tap"], { env, nothrow: true })
if (tap.code !== 0) {
result = tap
break
}
const repo = await Process.text(["brew", "--repo", "anomalyco/tap"], { env, nothrow: true })
if (repo.code !== 0) {
result = repo
break
}
const dir = repo.text.trim()
if (dir) {
const pull = await Process.run(["git", "pull", "--ff-only"], { cwd: dir, env, nothrow: true })
if (pull.code !== 0) {
result = pull
break
}
}
}
result = await Process.run(["brew", "upgrade", formula], { env, nothrow: true })
break
}
case "choco":
result = await Process.run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"], { nothrow: true })
break
case "scoop":
result = await Process.run(["scoop", "install", `opencode@${target}`], { nothrow: true })
break
default:
throw new Error(`Unknown method: ${method}`)
}
if (!result || result.code !== 0) {
const stderr =
method === "choco" ? "not running from an elevated command shell" : result?.stderr.toString("utf8") || ""
throw new UpgradeFailedError({
stderr: stderr,
})
}
log.info("upgraded", {
method,
target,
stdout: result.stdout.toString(),
stderr: result.stderr.toString(),
})
await Process.text([process.execPath, "--version"], { nothrow: true })
export function info(): Promise<Info> {
return runPromise((svc) => svc.info())
}
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}`
export function method(): Promise<Method> {
return runPromise((svc) => svc.method())
}
export async function latest(installMethod?: Method) {
const detectedMethod = installMethod || (await method())
export function latest(installMethod?: Method): Promise<string> {
return runPromise((svc) => svc.latest(installMethod))
}
if (detectedMethod === "brew") {
const formula = await getBrewFormula()
if (formula.includes("/")) {
const infoJson = await text(["brew", "info", "--json=v2", formula])
const info = JSON.parse(infoJson)
const version = info.formulae?.[0]?.versions?.stable
if (!version) throw new Error(`Could not detect version for tap formula: ${formula}`)
return version
}
return fetch("https://formulae.brew.sh/api/formula/opencode.json")
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.versions.stable)
}
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
const registry = await iife(async () => {
const r = (await text(["npm", "config", "get", "registry"])).trim()
const reg = r || "https://registry.npmjs.org"
return reg.endsWith("/") ? reg.slice(0, -1) : reg
})
const channel = CHANNEL
return fetch(`${registry}/opencode-ai/${channel}`)
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.version)
}
if (detectedMethod === "choco") {
return fetch(
"https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
{ headers: { Accept: "application/json;odata=verbose" } },
)
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.d.results[0].Version)
}
if (detectedMethod === "scoop") {
return fetch("https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json", {
headers: { Accept: "application/json" },
})
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.version)
}
return fetch("https://api.github.com/repos/anomalyco/opencode/releases/latest")
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.tag_name.replace(/^v/, ""))
export function upgrade(m: Method, target: string): Promise<void> {
return runPromise((svc) => svc.upgrade(m, target))
}
}

View File

@@ -3,7 +3,6 @@ import path from "path"
import os from "os"
import { Global } from "../global"
import { Log } from "../util/log"
import { BunProc } from "../bun"
import { text } from "node:stream/consumers"
import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
@@ -14,6 +13,7 @@ import { Process } from "../util/process"
import { which } from "../util/which"
import { Module } from "@opencode-ai/util/module"
import { spawn } from "./launch"
import { Npm } from "@/npm"
export namespace LSPServer {
const log = Log.create({ service: "lsp.server" })
@@ -103,11 +103,10 @@ export namespace LSPServer {
const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
log.info("typescript server", { tsserver })
if (!tsserver) return
const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
const proc = spawn(await Npm.which("typescript-language-server"), ["--stdio"], {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
return {
@@ -129,36 +128,14 @@ export namespace LSPServer {
let binary = which("vue-language-server")
const args: string[] = []
if (!binary) {
const js = path.join(
Global.Path.bin,
"node_modules",
"@vue",
"language-server",
"bin",
"vue-language-server.js",
)
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "@vue/language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("@vue/language-server")
}
args.push("--stdio")
const proc = spawn(binary, args, {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
return {
@@ -214,11 +191,10 @@ export namespace LSPServer {
log.info("installed VS Code ESLint server", { serverPath })
}
const proc = spawn(BunProc.which(), [serverPath, "--stdio"], {
const proc = spawn("node", [serverPath, "--stdio"], {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
@@ -345,15 +321,14 @@ export namespace LSPServer {
if (!bin) {
const resolved = Module.resolve("biome", root)
if (!resolved) return
bin = BunProc.which()
args = ["x", "biome", "lsp-proxy", "--stdio"]
bin = await Npm.which("biome")
args = ["lsp-proxy", "--stdio"]
}
const proc = spawn(bin, args, {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
@@ -372,9 +347,7 @@ export namespace LSPServer {
},
extensions: [".go"],
async spawn(root) {
let bin = which("gopls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("gopls")
if (!bin) {
if (!which("go")) return
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -409,9 +382,7 @@ export namespace LSPServer {
root: NearestRoot(["Gemfile"]),
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async spawn(root) {
let bin = which("rubocop", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("rubocop")
if (!bin) {
const ruby = which("ruby")
const gem = which("gem")
@@ -516,19 +487,8 @@ export namespace LSPServer {
let binary = which("pyright-langserver")
const args = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "pyright"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
}).exited
}
binary = BunProc.which()
args.push(...["run", js])
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("pyright")
}
args.push("--stdio")
@@ -552,7 +512,6 @@ export namespace LSPServer {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
return {
@@ -630,9 +589,7 @@ export namespace LSPServer {
extensions: [".zig", ".zon"],
root: NearestRoot(["build.zig"]),
async spawn(root) {
let bin = which("zls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("zls")
if (!bin) {
const zig = which("zig")
@@ -742,9 +699,7 @@ export namespace LSPServer {
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
extensions: [".cs"],
async spawn(root) {
let bin = which("csharp-ls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("csharp-ls")
if (!bin) {
if (!which("dotnet")) {
log.error(".NET SDK is required to install csharp-ls")
@@ -781,9 +736,7 @@ export namespace LSPServer {
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
async spawn(root) {
let bin = which("fsautocomplete", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("fsautocomplete")
if (!bin) {
if (!which("dotnet")) {
log.error(".NET SDK is required to install fsautocomplete")
@@ -1049,29 +1002,14 @@ export namespace LSPServer {
let binary = which("svelteserver")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "svelte-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("svelte-language-server")
}
args.push("--stdio")
const proc = spawn(binary, args, {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
return {
@@ -1096,29 +1034,14 @@ export namespace LSPServer {
let binary = which("astro-ls")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "@astrojs/language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("@astrojs/language-server")
}
args.push("--stdio")
const proc = spawn(binary, args, {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
return {
@@ -1360,38 +1283,14 @@ export namespace LSPServer {
let binary = which("yaml-language-server")
const args: string[] = []
if (!binary) {
const js = path.join(
Global.Path.bin,
"node_modules",
"yaml-language-server",
"out",
"server",
"src",
"server.js",
)
const exists = await Filesystem.exists(js)
if (!exists) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "yaml-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("yaml-language-server")
}
args.push("--stdio")
const proc = spawn(binary, args, {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
return {
@@ -1413,9 +1312,7 @@ export namespace LSPServer {
]),
extensions: [".lua"],
async spawn(root) {
let bin = which("lua-language-server", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("lua-language-server")
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1551,29 +1448,14 @@ export namespace LSPServer {
let binary = which("intelephense")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "intelephense"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("intelephense")
}
args.push("--stdio")
const proc = spawn(binary, args, {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
return {
@@ -1648,29 +1530,14 @@ export namespace LSPServer {
let binary = which("bash-language-server")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "bash-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("bash-language-server")
}
args.push("start")
const proc = spawn(binary, args, {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
return {
@@ -1684,9 +1551,7 @@ export namespace LSPServer {
extensions: [".tf", ".tfvars"],
root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
async spawn(root) {
let bin = which("terraform-ls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("terraform-ls")
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1767,9 +1632,7 @@ export namespace LSPServer {
extensions: [".tex", ".bib"],
root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
async spawn(root) {
let bin = which("texlab", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("texlab")
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1860,29 +1723,14 @@ export namespace LSPServer {
let binary = which("docker-langserver")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("dockerfile-language-server-nodejs")
}
args.push("--stdio")
const proc = spawn(binary, args, {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
return {
@@ -1966,9 +1814,7 @@ export namespace LSPServer {
extensions: [".typ", ".typc"],
root: NearestRoot(["typst.toml"]),
async spawn(root) {
let bin = which("tinymist", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("tinymist")
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return

View File

@@ -11,6 +11,7 @@ import {
} from "@modelcontextprotocol/sdk/types.js"
import { Config } from "../config/config"
import { Log } from "../util/log"
import { Process } from "../util/process"
import { NamedError } from "@opencode-ai/util/error"
import z from "zod/v4"
import { Instance } from "../project/instance"
@@ -166,14 +167,10 @@ export namespace MCP {
const queue = [pid]
while (queue.length > 0) {
const current = queue.shift()!
const proc = Bun.spawn(["pgrep", "-P", String(current)], { stdout: "pipe", stderr: "pipe" })
const [code, out] = await Promise.all([proc.exited, new Response(proc.stdout).text()]).catch(
() => [-1, ""] as const,
)
if (code !== 0) continue
for (const tok of out.trim().split(/\s+/)) {
const lines = await Process.lines(["pgrep", "-P", String(current)], { nothrow: true })
for (const tok of lines) {
const cpid = parseInt(tok, 10)
if (!isNaN(cpid) && pids.indexOf(cpid) === -1) {
if (!isNaN(cpid) && !pids.includes(cpid)) {
pids.push(cpid)
queue.push(cpid)
}
@@ -458,9 +455,8 @@ export namespace MCP {
cwd,
env: {
...process.env,
...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}),
...mcp.environment,
},
} as any,
})
transport.stderr?.on("data", (chunk: Buffer) => {
log.info(`mcp stderr: ${chunk.toString()}`, { key })

View File

@@ -1,4 +1,5 @@
import { createConnection } from "net"
import { createServer } from "http"
import { Log } from "../util/log"
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
@@ -52,11 +53,74 @@ interface PendingAuth {
}
export namespace McpOAuthCallback {
let server: ReturnType<typeof Bun.serve> | undefined
let server: ReturnType<typeof createServer> | undefined
const pendingAuths = new Map<string, PendingAuth>()
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
const url = new URL(req.url || "/", `http://localhost:${OAUTH_CALLBACK_PORT}`)
if (url.pathname !== OAUTH_CALLBACK_PATH) {
res.writeHead(404)
res.end("Not found")
return
}
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
log.info("received oauth callback", { hasCode: !!code, state, error })
// Enforce state parameter presence
if (!state) {
const errorMsg = "Missing required state parameter - potential CSRF attack"
log.error("oauth callback missing state parameter", { url: url.toString() })
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
if (error) {
const errorMsg = errorDescription || error
if (pendingAuths.has(state)) {
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
pending.reject(new Error(errorMsg))
}
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
if (!code) {
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR("No authorization code provided"))
return
}
// Validate state parameter
if (!pendingAuths.has(state)) {
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
pending.resolve(code)
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_SUCCESS)
}
export async function ensureRunning(): Promise<void> {
if (server) return
@@ -66,75 +130,14 @@ export namespace McpOAuthCallback {
return
}
server = Bun.serve({
port: OAUTH_CALLBACK_PORT,
fetch(req) {
const url = new URL(req.url)
if (url.pathname !== OAUTH_CALLBACK_PATH) {
return new Response("Not found", { status: 404 })
}
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
log.info("received oauth callback", { hasCode: !!code, state, error })
// Enforce state parameter presence
if (!state) {
const errorMsg = "Missing required state parameter - potential CSRF attack"
log.error("oauth callback missing state parameter", { url: url.toString() })
return new Response(HTML_ERROR(errorMsg), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
if (error) {
const errorMsg = errorDescription || error
if (pendingAuths.has(state)) {
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
pending.reject(new Error(errorMsg))
}
return new Response(HTML_ERROR(errorMsg), {
headers: { "Content-Type": "text/html" },
})
}
if (!code) {
return new Response(HTML_ERROR("No authorization code provided"), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
// Validate state parameter
if (!pendingAuths.has(state)) {
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
return new Response(HTML_ERROR(errorMsg), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
pending.resolve(code)
return new Response(HTML_SUCCESS, {
headers: { "Content-Type": "text/html" },
})
},
server = createServer(handleRequest)
await new Promise<void>((resolve, reject) => {
server!.listen(OAUTH_CALLBACK_PORT, () => {
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
resolve()
})
server!.on("error", reject)
})
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
}
export function waitForCallback(oauthState: string): Promise<string> {
@@ -174,7 +177,7 @@ export namespace McpOAuthCallback {
export async function stop(): Promise<void> {
if (server) {
server.stop()
await new Promise<void>((resolve) => server!.close(() => resolve()))
server = undefined
log.info("oauth callback server stopped")
}

View File

@@ -0,0 +1 @@
export { Server } from "./server/server"

View File

@@ -0,0 +1,181 @@
// Workaround: Bun on Windows does not support the UV_FS_O_FILEMAP flag that
// the `tar` package uses for files < 512KB (fs.open returns EINVAL).
// tar silently swallows the error and skips writing files, leaving only empty
// directories. Setting __FAKE_PLATFORM__ makes tar fall back to the plain 'w'
// flag. See tar's get-write-flag.js.
// Must be set before @npmcli/arborist is imported since tar caches the flag
// at module evaluation time — so we use a dynamic import() below.
if (process.platform === "win32") {
process.env.__FAKE_PLATFORM__ = "linux"
}
import semver from "semver"
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { Global } from "../global"
import { Lock } from "../util/lock"
import { Log } from "../util/log"
import path from "path"
import { readdir, rm } from "fs/promises"
import { Filesystem } from "@/util/filesystem"
const { Arborist } = await import("@npmcli/arborist")
export namespace Npm {
const log = Log.create({ service: "npm" })
export const InstallFailedError = NamedError.create(
"NpmInstallFailedError",
z.object({
pkg: z.string(),
}),
)
function directory(pkg: string) {
return path.join(Global.Path.cache, "packages", pkg)
}
export async function outdated(pkg: string, cachedVersion: string): Promise<boolean> {
const response = await fetch(`https://registry.npmjs.org/${pkg}`)
if (!response.ok) {
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
return false
}
const data = (await response.json()) as { "dist-tags"?: { latest?: string } }
const latestVersion = data?.["dist-tags"]?.latest
if (!latestVersion) {
log.warn("No latest version found, using cached", { pkg, cachedVersion })
return false
}
const range = /[\s^~*xX<>|=]/.test(cachedVersion)
if (range) return !semver.satisfies(latestVersion, cachedVersion)
return semver.lt(cachedVersion, latestVersion)
}
export async function add(pkg: string) {
using _ = await Lock.write(`npm-install:${pkg}`)
log.info("installing package", {
pkg,
})
const dir = directory(pkg)
const arborist = new Arborist({
path: dir,
binLinks: true,
progress: false,
savePrefix: "",
})
const tree = await arborist.loadVirtual().catch(() => {})
if (tree) {
const first = tree.edgesOut.values().next().value?.to
if (first) {
log.info("package already installed", { pkg })
return first.path
}
}
const result = await arborist
.reify({
add: [pkg],
save: true,
saveType: "prod",
})
.catch((cause) => {
throw new InstallFailedError(
{ pkg },
{
cause,
},
)
})
const first = result.edgesOut.values().next().value?.to
if (!first) throw new InstallFailedError({ pkg })
return first.path
}
export async function install(dir: string) {
using _ = await Lock.write(`npm-install:${dir}`)
log.info("checking dependencies", { dir })
const reify = async () => {
const arb = new Arborist({
path: dir,
binLinks: true,
progress: false,
savePrefix: "",
})
await arb.reify().catch(() => {})
}
if (!(await Filesystem.exists(path.join(dir, "node_modules")))) {
log.info("node_modules missing, reifying")
await reify()
return
}
const pkg = await Filesystem.readJson(path.join(dir, "package.json")).catch(() => ({}))
const lock = await Filesystem.readJson(path.join(dir, "package-lock.json")).catch(() => ({}))
const declared = new Set([
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.devDependencies || {}),
...Object.keys(pkg.peerDependencies || {}),
...Object.keys(pkg.optionalDependencies || {}),
])
const root = lock.packages?.[""] || {}
const locked = new Set([
...Object.keys(root.dependencies || {}),
...Object.keys(root.devDependencies || {}),
...Object.keys(root.peerDependencies || {}),
...Object.keys(root.optionalDependencies || {}),
])
for (const name of declared) {
if (!locked.has(name)) {
log.info("dependency not in lock file, reifying", { name })
await reify()
return
}
}
log.info("dependencies in sync")
}
export async function which(pkg: string) {
const dir = directory(pkg)
const binDir = path.join(dir, "node_modules", ".bin")
const pick = async () => {
const files = await readdir(binDir).catch(() => [])
if (files.length === 0) return undefined
if (files.length === 1) return files[0]
// Multiple binaries — resolve from package.json bin field like npx does
const pkgJson = await Filesystem.readJson<{ bin?: string | Record<string, string> }>(
path.join(dir, "node_modules", pkg, "package.json"),
).catch(() => undefined)
if (pkgJson?.bin) {
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
const bin = pkgJson.bin
if (typeof bin === "string") return unscoped
const keys = Object.keys(bin)
if (keys.length === 1) return keys[0]
return bin[unscoped] ? unscoped : keys[0]
}
return files[0]
}
const bin = await pick()
if (bin) return path.join(binDir, bin)
await rm(path.join(dir, "package-lock.json"), { force: true })
await add(pkg)
const resolved = await pick()
if (!resolved) throw new Error(`No binary found for package "${pkg}" after install`)
return path.join(binDir, resolved)
}
}

View File

@@ -0,0 +1,15 @@
import { Wildcard } from "@/util/wildcard"
type Rule = {
permission: string
pattern: string
action: "allow" | "deny" | "ask"
}
export function evaluate(permission: string, pattern: string, ...rulesets: Rule[][]): Rule {
const rules = rulesets.flat()
const match = rules.findLast(
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
)
return match ?? { action: "ask", permission, pattern: "*" }
}

View File

@@ -13,6 +13,7 @@ import { Wildcard } from "@/util/wildcard"
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import os from "os"
import z from "zod"
import { evaluate as evalRule } from "./evaluate"
import { PermissionID } from "./schema"
export namespace PermissionNext {
@@ -125,12 +126,8 @@ export namespace PermissionNext {
}
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
const rules = rulesets.flat()
log.info("evaluate", { permission, pattern, ruleset: rules })
const match = rules.findLast(
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
)
return match ?? { action: "ask", permission, pattern: "*" }
log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
return evalRule(permission, pattern, ...rulesets)
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/PermissionNext") {}

View File

@@ -6,6 +6,7 @@ import os from "os"
import { ProviderTransform } from "@/provider/transform"
import { ModelID, ProviderID } from "@/provider/schema"
import { setTimeout as sleep } from "node:timers/promises"
import { createServer } from "http"
const log = Log.create({ service: "plugin.codex" })
@@ -241,7 +242,7 @@ interface PendingOAuth {
reject: (error: Error) => void
}
let oauthServer: ReturnType<typeof Bun.serve> | undefined
let oauthServer: ReturnType<typeof createServer> | undefined
let pendingOAuth: PendingOAuth | undefined
async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
@@ -249,77 +250,83 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
}
oauthServer = Bun.serve({
port: OAUTH_PORT,
fetch(req) {
const url = new URL(req.url)
oauthServer = createServer((req, res) => {
const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`)
if (url.pathname === "/auth/callback") {
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
if (url.pathname === "/auth/callback") {
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
if (error) {
const errorMsg = errorDescription || error
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
return new Response(HTML_ERROR(errorMsg), {
headers: { "Content-Type": "text/html" },
})
}
if (!code) {
const errorMsg = "Missing authorization code"
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
return new Response(HTML_ERROR(errorMsg), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
if (!pendingOAuth || state !== pendingOAuth.state) {
const errorMsg = "Invalid state - potential CSRF attack"
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
return new Response(HTML_ERROR(errorMsg), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
const current = pendingOAuth
if (error) {
const errorMsg = errorDescription || error
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
.then((tokens) => current.resolve(tokens))
.catch((err) => current.reject(err))
return new Response(HTML_SUCCESS, {
headers: { "Content-Type": "text/html" },
})
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
if (url.pathname === "/cancel") {
pendingOAuth?.reject(new Error("Login cancelled"))
if (!code) {
const errorMsg = "Missing authorization code"
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
return new Response("Login cancelled", { status: 200 })
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
return new Response("Not found", { status: 404 })
},
if (!pendingOAuth || state !== pendingOAuth.state) {
const errorMsg = "Invalid state - potential CSRF attack"
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
const current = pendingOAuth
pendingOAuth = undefined
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
.then((tokens) => current.resolve(tokens))
.catch((err) => current.reject(err))
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_SUCCESS)
return
}
if (url.pathname === "/cancel") {
pendingOAuth?.reject(new Error("Login cancelled"))
pendingOAuth = undefined
res.writeHead(200)
res.end("Login cancelled")
return
}
res.writeHead(404)
res.end("Not found")
})
await new Promise<void>((resolve, reject) => {
oauthServer!.listen(OAUTH_PORT, () => {
log.info("codex oauth server started", { port: OAUTH_PORT })
resolve()
})
oauthServer!.on("error", reject)
})
log.info("codex oauth server started", { port: OAUTH_PORT })
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
}
function stopOAuthServer() {
if (oauthServer) {
oauthServer.stop()
oauthServer.close(() => {
log.info("codex oauth server stopped")
})
oauthServer = undefined
log.info("codex oauth server stopped")
}
}

View File

@@ -4,7 +4,7 @@ import { Bus } from "../bus"
import { Log } from "../util/log"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { Server } from "../server/server"
import { BunProc } from "../bun"
import { Npm } from "../npm"
import { Instance } from "../project/instance"
import { Flag } from "../flag/flag"
import { CodexAuthPlugin } from "./codex"
@@ -30,7 +30,9 @@ export namespace Plugin {
: undefined,
fetch: async (...args) => Server.Default().fetch(...args),
})
log.info("loading config")
const config = await Config.get()
log.info("config loaded")
const hooks: Hooks[] = []
const input: PluginInput = {
client,
@@ -40,7 +42,8 @@ export namespace Plugin {
get serverUrl(): URL {
return Server.url ?? new URL("http://localhost:4096")
},
$: Bun.$,
// @ts-expect-error
$: typeof Bun === "undefined" ? undefined : Bun.$,
}
for (const plugin of INTERNAL_PLUGINS) {
@@ -59,16 +62,13 @@ export namespace Plugin {
if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue
log.info("loading plugin", { path: plugin })
if (!plugin.startsWith("file://")) {
const lastAtIndex = plugin.lastIndexOf("@")
const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest"
plugin = await BunProc.install(pkg, version).catch((err) => {
plugin = await Npm.add(plugin).catch((err) => {
const cause = err instanceof Error ? err.cause : err
const detail = cause instanceof Error ? cause.message : String(cause ?? err)
log.error("failed to install plugin", { pkg, version, error: detail })
log.error("failed to install plugin", { plugin, error: detail })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to install plugin ${pkg}@${version}: ${detail}`,
message: `Failed to install plugin ${plugin}: ${detail}`,
}).toObject(),
})
return ""

View File

@@ -106,7 +106,7 @@ export namespace ProviderAuth {
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const auth = yield* Auth.AuthEffect.Service
const auth = yield* Auth.Auth.Service
const hooks = yield* Effect.promise(async () => {
const mod = await import("../plugin")
const plugins = await mod.Plugin.list()
@@ -213,7 +213,7 @@ export namespace ProviderAuth {
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Auth.AuthEffect.layer))
export const defaultLayer = layer.pipe(Layer.provide(Auth.Auth.layer))
export async function methods() {
return runPromiseInstance(Service.use((svc) => svc.methods()))

View File

@@ -5,7 +5,7 @@ import { Config } from "../config/config"
import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
import { NoSuchModelError, type Provider as SDK } from "ai"
import { Log } from "../util/log"
import { BunProc } from "../bun"
import { Npm } from "../npm"
import { Hash } from "../util/hash"
import { Plugin } from "../plugin"
import { NamedError } from "@opencode-ai/util/error"
@@ -184,6 +184,15 @@ export namespace Provider {
options: {},
}
},
xai: async () => {
return {
autoload: false,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
return sdk.responses(modelID)
},
options: {},
}
},
"github-copilot": async () => {
return {
autoload: false,
@@ -1187,7 +1196,7 @@ export namespace Provider {
let installedPath: string
if (!model.api.npm.startsWith("file://")) {
installedPath = await BunProc.install(model.api.npm, "latest")
installedPath = await Npm.add(model.api.npm)
} else {
log.info("loading local provider", { pkg: model.api.npm })
installedPath = model.api.npm

View File

@@ -23,6 +23,8 @@ export namespace Pty {
close: (code?: number, reason?: string) => void
}
const key = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws)
// WebSocket control frame: 0x00 + UTF-8 JSON.
const meta = (cursor: number) => {
const json = JSON.stringify({ cursor })
@@ -97,9 +99,9 @@ export namespace Pty {
try {
session.process.kill()
} catch {}
for (const [key, ws] of session.subscribers.entries()) {
for (const [id, ws] of session.subscribers.entries()) {
try {
if (ws.data === key) ws.close()
if (key(ws) === id) ws.close()
} catch {
// ignore
}
@@ -230,9 +232,9 @@ export namespace Pty {
try {
session.process.kill()
} catch {}
for (const [key, ws] of session.subscribers.entries()) {
for (const [id, ws] of session.subscribers.entries()) {
try {
if (ws.data === key) ws.close()
if (key(ws) === id) ws.close()
} catch {
// ignore
}
@@ -263,16 +265,13 @@ export namespace Pty {
}
log.info("client connected to session", { id })
// Use ws.data as the unique key for this connection lifecycle.
// If ws.data is undefined, fallback to ws object.
const connectionKey = ws.data && typeof ws.data === "object" ? ws.data : ws
const sub = key(ws)
// Optionally cleanup if the key somehow exists
session.subscribers.delete(connectionKey)
session.subscribers.set(connectionKey, ws)
session.subscribers.delete(sub)
session.subscribers.set(sub, ws)
const cleanup = () => {
session.subscribers.delete(connectionKey)
session.subscribers.delete(sub)
}
const start = session.bufferCursor

View File

@@ -29,7 +29,7 @@ export const ProjectRoutes = lazy(() =>
},
}),
async (c) => {
const projects = await Project.list()
const projects = Project.list()
return c.json(projects)
},
)

View File

@@ -1,15 +1,14 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import { upgradeWebSocket } from "hono/bun"
import type { UpgradeWebSocket } from "hono/ws"
import z from "zod"
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { NotFoundError } from "../../storage/db"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
export const PtyRoutes = lazy(() =>
new Hono()
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
return new Hono()
.get(
"/",
describeRoute({
@@ -197,5 +196,5 @@ export const PtyRoutes = lazy(() =>
},
}
}),
),
)
)
}

View File

@@ -1,4 +1,7 @@
import { streamSSE } from "hono/streaming"
import { Log } from "../util/log"
import { Bus } from "../bus"
import { BusEvent } from "../bus/bus-event"
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
import { Hono } from "hono"
import { cors } from "hono/cors"
@@ -25,7 +28,7 @@ import { ProviderID } from "../provider/schema"
import { WorkspaceRouterMiddleware } from "../control-plane/workspace-router-middleware"
import { ProjectRoutes } from "./routes/project"
import { SessionRoutes } from "./routes/session"
import { PtyRoutes } from "./routes/pty"
// import { PtyRoutes } from "./routes/pty"
import { McpRoutes } from "./routes/mcp"
import { FileRoutes } from "./routes/file"
import { ConfigRoutes } from "./routes/config"
@@ -35,7 +38,8 @@ import { EventRoutes } from "./routes/event"
import { InstanceBootstrap } from "../project/bootstrap"
import { NotFoundError } from "../storage/db"
import type { ContentfulStatusCode } from "hono/utils/http-status"
import { websocket } from "hono/bun"
import { createAdaptorServer, type ServerType } from "@hono/node-server"
import { createNodeWebSocket } from "@hono/node-ws"
import { HTTPException } from "hono/http-exception"
import { errors } from "./error"
import { Filesystem } from "@/util/filesystem"
@@ -49,13 +53,20 @@ import { lazy } from "@/util/lazy"
globalThis.AI_SDK_LOG_WARNINGS = false
export namespace Server {
const log = Log.create({ service: "server" })
export type Listener = {
hostname: string
port: number
url: URL
stop: (close?: boolean) => Promise<void>
}
export const Default = lazy(() => createApp({}))
export const Default = lazy(() => create({}).app)
export const createApp = (opts: { cors?: string[] }): Hono => {
function create(opts: { cors?: string[] }) {
const log = Log.create({ service: "server" })
const app = new Hono()
return app
const ws = createNodeWebSocket({ app })
const route = app
.onError((err, c) => {
log.error("failed", {
error: err,
@@ -241,7 +252,6 @@ export namespace Server {
),
)
.route("/project", ProjectRoutes())
.route("/pty", PtyRoutes())
.route("/config", ConfigRoutes())
.route("/experimental", ExperimentalRoutes())
.route("/session", SessionRoutes())
@@ -497,22 +507,70 @@ export namespace Server {
return c.json(await Format.status())
},
)
.all("/*", async (c) => {
const path = c.req.path
const response = await proxy(`https://app.opencode.ai${path}`, {
...c.req,
headers: {
...c.req.raw.headers,
host: "app.opencode.ai",
.get(
"/event",
describeRoute({
summary: "Subscribe to events",
description: "Get events",
operationId: "event.subscribe",
responses: {
200: {
description: "Event stream",
content: {
"text/event-stream": {
schema: resolver(BusEvent.payloads()),
},
},
},
},
})
response.headers.set(
"Content-Security-Policy",
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:",
)
return response
})
}),
async (c) => {
log.info("event connected")
c.header("X-Accel-Buffering", "no")
c.header("X-Content-Type-Options", "nosniff")
return streamSSE(c, async (stream) => {
stream.writeSSE({
data: JSON.stringify({
type: "server.connected",
properties: {},
}),
})
const unsub = Bus.subscribeAll(async (event) => {
await stream.writeSSE({
data: JSON.stringify(event),
})
if (event.type === Bus.InstanceDisposed.type) {
stream.close()
}
})
// Send heartbeat every 10s to prevent stalled proxy streams.
const heartbeat = setInterval(() => {
stream.writeSSE({
data: JSON.stringify({
type: "server.heartbeat",
properties: {},
}),
})
}, 10_000)
await new Promise<void>((resolve) => {
stream.onAbort(() => {
clearInterval(heartbeat)
unsub()
resolve()
log.info("event disconnected")
})
})
})
},
)
// .route("/pty", PtyRoutes(ws.upgradeWebSocket))
return {
app: route as Hono,
ws,
}
}
export async function openapi() {
@@ -530,52 +588,89 @@ export namespace Server {
return result
}
/** @deprecated do not use this dumb shit */
export let url: URL
export function listen(opts: {
export async function listen(opts: {
port: number
hostname: string
mdns?: boolean
mdnsDomain?: string
cors?: string[]
}) {
url = new URL(`http://${opts.hostname}:${opts.port}`)
const app = createApp(opts)
const args = {
hostname: opts.hostname,
idleTimeout: 0,
fetch: app.fetch,
websocket: websocket,
} as const
const tryServe = (port: number) => {
try {
return Bun.serve({ ...args, port })
} catch {
return undefined
}
}): Promise<Listener> {
const log = Log.create({ service: "server" })
const built = create({
...opts,
})
const start = (port: number) =>
new Promise<ServerType>((resolve, reject) => {
const server = createAdaptorServer({ fetch: built.app.fetch })
built.ws.injectWebSocket(server)
const fail = (err: Error) => {
cleanup()
reject(err)
}
const ready = () => {
cleanup()
resolve(server)
}
const cleanup = () => {
server.off("error", fail)
server.off("listening", ready)
}
server.once("error", fail)
server.once("listening", ready)
server.listen(port, opts.hostname)
})
const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
const addr = server.address()
if (!addr || typeof addr === "string") {
throw new Error(`Failed to resolve server address for port ${opts.port}`)
}
const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port)
if (!server) throw new Error(`Failed to start server on port ${opts.port}`)
const url = new URL("http://localhost")
url.hostname = opts.hostname
url.port = String(addr.port)
Server.url = url
const shouldPublishMDNS =
opts.mdns &&
server.port &&
addr.port &&
opts.hostname !== "127.0.0.1" &&
opts.hostname !== "localhost" &&
opts.hostname !== "::1"
if (shouldPublishMDNS) {
MDNS.publish(server.port!, opts.mdnsDomain)
MDNS.publish(addr.port, opts.mdnsDomain)
} else if (opts.mdns) {
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
}
const originalStop = server.stop.bind(server)
server.stop = async (closeActiveConnections?: boolean) => {
if (shouldPublishMDNS) MDNS.unpublish()
return originalStop(closeActiveConnections)
let closing: Promise<void> | undefined
return {
hostname: opts.hostname,
port: addr.port,
url,
stop(close?: boolean) {
closing ??= new Promise((resolve, reject) => {
if (shouldPublishMDNS) MDNS.unpublish()
server.close((err) => {
if (err) {
reject(err)
return
}
resolve()
})
if (close) {
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
server.closeAllConnections()
}
if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
server.closeIdleConnections()
}
}
})
return closing
},
}
return server
}
}

View File

@@ -13,7 +13,7 @@ import { STATUS_CODES } from "http"
import { Storage } from "@/storage/storage"
import { ProviderError } from "@/provider/error"
import { iife } from "@/util/iife"
import { type SystemError } from "bun"
import type { SystemError } from "bun"
import type { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"

View File

@@ -28,11 +28,11 @@ import { MCP } from "../mcp"
import { LSP } from "../lsp"
import { ReadTool } from "../tool/read"
import { FileTime } from "../file/time"
import { NotFoundError } from "@/storage/db"
import { Flag } from "../flag/flag"
import { ulid } from "ulid"
import { spawn } from "child_process"
import { Command } from "../command"
import { $ } from "bun"
import { pathToFileURL, fileURLToPath } from "url"
import { ConfigMarkdown } from "../config/markdown"
import { SessionSummary } from "./summary"
@@ -48,6 +48,7 @@ import { iife } from "@/util/iife"
import { Shell } from "@/shell/shell"
import { Truncate } from "@/tool/truncate"
import { decodeDataUrl } from "@/util/data-url"
import { Process } from "@/util/process"
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -318,11 +319,7 @@ export namespace SessionPrompt {
}
if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
if (
lastAssistant?.finish &&
!["tool-calls", "unknown"].includes(lastAssistant.finish) &&
lastUser.id < lastAssistant.id
) {
if (shouldExitLoop(lastUser, lastAssistant)) {
log.info("exiting loop", { sessionID })
break
}
@@ -1812,15 +1809,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the
template = template + "\n\n" + input.arguments
}
const shell = ConfigMarkdown.shell(template)
if (shell.length > 0) {
const shellMatches = ConfigMarkdown.shell(template)
if (shellMatches.length > 0) {
const sh = Shell.preferred()
const results = await Promise.all(
shell.map(async ([, cmd]) => {
try {
return await $`${{ raw: cmd }}`.quiet().nothrow().text()
} catch (error) {
return `Error executing command: ${error instanceof Error ? error.message : String(error)}`
}
shellMatches.map(async ([, cmd]) => {
const out = await Process.text([cmd], { shell: sh, nothrow: true })
return out.text
}),
)
let index = 0
@@ -1990,7 +1985,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (!cleaned) return
const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned
return Session.setTitle({ sessionID: input.session.id, title })
return Session.setTitle({ sessionID: input.session.id, title }).catch((err) => {
if (NotFoundError.isInstance(err)) return
throw err
})
}
}
/** @internal Exported for testing — determines whether the prompt loop should exit */
export function shouldExitLoop(
lastUser: MessageV2.User | undefined,
lastAssistant: MessageV2.Assistant | undefined,
): boolean {
if (!lastUser) return false
if (!lastAssistant?.finish) return false
if (["tool-calls", "unknown"].includes(lastAssistant.finish)) return false
return lastAssistant.parentID === lastUser.id
}
}

View File

@@ -9,6 +9,7 @@ import { Snapshot } from "@/snapshot"
import { Storage } from "@/storage/storage"
import { Bus } from "@/bus"
import { NotFoundError } from "@/storage/db"
export namespace SessionSummary {
function unquoteGitPath(input: string) {
@@ -73,11 +74,17 @@ export namespace SessionSummary {
messageID: MessageID.zod,
}),
async (input) => {
const all = await Session.messages({ sessionID: input.sessionID })
await Promise.all([
summarizeSession({ sessionID: input.sessionID, messages: all }),
summarizeMessage({ messageID: input.messageID, messages: all }),
])
await Session.messages({ sessionID: input.sessionID })
.then((all) =>
Promise.all([
summarizeSession({ sessionID: input.sessionID, messages: all }),
summarizeMessage({ messageID: input.messageID, messages: all }),
]),
)
.catch((err) => {
if (NotFoundError.isInstance(err)) return
throw err
})
},
)
@@ -102,7 +109,8 @@ export namespace SessionSummary {
const messages = input.messages.filter(
(m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID),
)
const msgWithParts = messages.find((m) => m.info.id === input.messageID)!
const msgWithParts = messages.find((m) => m.info.id === input.messageID)
if (!msgWithParts) return
const userMsg = msgWithParts.info as MessageV2.User
const diffs = await computeDiff({ messages })
userMsg.summary = {

View File

@@ -10,6 +10,7 @@ import { createTwoFilesPatch, diffLines } from "diff"
import { assertExternalDirectory } from "./external-directory"
import { trimDiff } from "./edit"
import { LSP } from "../lsp"
import { Format } from "../format"
import { Filesystem } from "../util/filesystem"
import DESCRIPTION from "./apply_patch.txt"
import { File } from "../file"
@@ -220,6 +221,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
}
if (edited) {
await Format.run(edited)
await Bus.publish(File.Event.Edited, {
file: edited,
})

View File

@@ -13,6 +13,7 @@ import { File } from "../file"
import { FileWatcher } from "../file/watcher"
import { Bus } from "../bus"
import { FileTime } from "../file/time"
import { Format } from "../format"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Snapshot } from "@/snapshot"
@@ -71,6 +72,7 @@ export const EditTool = Tool.define("edit", {
},
})
await Filesystem.write(filePath, params.newString)
await Format.run(filePath)
await Bus.publish(File.Event.Edited, {
file: filePath,
})
@@ -108,6 +110,7 @@ export const EditTool = Tool.define("edit", {
})
await Filesystem.write(filePath, contentNew)
await Format.run(filePath)
await Bus.publish(File.Event.Edited, {
file: filePath,
})

View File

@@ -14,6 +14,7 @@ import { InvalidTool } from "./invalid"
import { SkillTool } from "./skill"
import type { Agent } from "../agent/agent"
import { Tool } from "./tool"
import { Instance } from "../project/instance"
import { Config } from "../config/config"
import path from "path"
import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
@@ -30,181 +31,102 @@ import { Truncate } from "./truncate"
import { ApplyPatchTool } from "./apply_patch"
import { Glob } from "../util/glob"
import { pathToFileURL } from "url"
import { Effect, Fiber, Layer, ServiceMap } from "effect"
import { InstanceContext } from "@/effect/instance-context"
import { runPromiseInstance } from "@/effect/runtime"
export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" })
export interface Interface {
readonly register: (tool: Tool.Info) => Effect.Effect<void>
readonly ids: () => Effect.Effect<string[]>
readonly tools: (
model: { providerID: ProviderID; modelID: ModelID },
agent?: Agent.Info,
) => Effect.Effect<(Awaited<ReturnType<Tool.Info["init"]>> & { id: string })[]>
export const state = Instance.state(async () => {
const custom = [] as Tool.Info[]
const matches = await Config.directories().then((dirs) =>
dirs.flatMap((dir) =>
Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
),
)
if (matches.length) await Config.waitForDependencies()
for (const match of matches) {
const namespace = path.basename(match, path.extname(match))
const mod = await import(process.platform === "win32" ? match : pathToFileURL(match).href)
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
}
}
const plugins = await Plugin.list()
for (const plugin of plugins) {
for (const [id, def] of Object.entries(plugin.tool ?? {})) {
custom.push(fromPlugin(id, def))
}
}
return { custom }
})
function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
return {
id,
init: async (initCtx) => ({
parameters: z.object(def.args),
description: def.description,
execute: async (args, ctx) => {
const pluginCtx = {
...ctx,
directory: Instance.directory,
worktree: Instance.worktree,
} as unknown as PluginToolContext
const result = await def.execute(args as any, pluginCtx)
const out = await Truncate.output(result, {}, initCtx?.agent)
return {
title: "",
output: out.truncated ? out.content : result,
metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined },
}
},
}),
}
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ToolRegistry") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const instance = yield* InstanceContext
const custom: Tool.Info[] = []
const load = Effect.fn("ToolRegistry.load")(function* () {
yield* Effect.promise(async () => {
const matches = await Config.directories().then((dirs) =>
dirs.flatMap((dir) =>
Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
),
)
if (matches.length) await Config.waitForDependencies()
for (const match of matches) {
const namespace = path.basename(match, path.extname(match))
const mod = await import(pathToFileURL(match).href)
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
}
}
const plugins = await Plugin.list()
for (const plugin of plugins) {
for (const [id, def] of Object.entries(plugin.tool ?? {})) {
custom.push(fromPlugin(id, def))
}
}
})
})
const loadFiber = yield* load().pipe(
Effect.catchCause((cause) => Effect.sync(() => log.error("init failed", { cause }))),
Effect.forkScoped,
)
function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
return {
id,
init: async (initCtx) => ({
parameters: z.object(def.args),
description: def.description,
execute: async (args, ctx) => {
const pluginCtx = {
...ctx,
directory: instance.directory,
worktree: instance.worktree,
} as unknown as PluginToolContext
const result = await def.execute(args as any, pluginCtx)
const out = await Truncate.output(result, {}, initCtx?.agent)
return {
title: "",
output: out.truncated ? out.content : result,
metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined },
}
},
}),
}
}
async function all(): Promise<Tool.Info[]> {
const config = await Config.get()
const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
return [
InvalidTool,
...(question ? [QuestionTool] : []),
BashTool,
ReadTool,
GlobTool,
GrepTool,
EditTool,
WriteTool,
TaskTool,
WebFetchTool,
TodoWriteTool,
// TodoReadTool,
WebSearchTool,
CodeSearchTool,
SkillTool,
ApplyPatchTool,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []),
...custom,
]
}
const register = Effect.fn("ToolRegistry.register")(function* (tool: Tool.Info) {
yield* Fiber.join(loadFiber)
const idx = custom.findIndex((t) => t.id === tool.id)
if (idx >= 0) {
custom.splice(idx, 1, tool)
return
}
custom.push(tool)
})
const ids = Effect.fn("ToolRegistry.ids")(function* () {
yield* Fiber.join(loadFiber)
const tools = yield* Effect.promise(() => all())
return tools.map((t) => t.id)
})
const tools = Effect.fn("ToolRegistry.tools")(function* (
model: { providerID: ProviderID; modelID: ModelID },
agent?: Agent.Info,
) {
yield* Fiber.join(loadFiber)
const allTools = yield* Effect.promise(() => all())
return yield* Effect.promise(() =>
Promise.all(
allTools
.filter((t) => {
// Enable websearch/codesearch for zen users OR via enable flag
if (t.id === "codesearch" || t.id === "websearch") {
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
}
// use apply tool in same format as codex
const usePatch =
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
if (t.id === "apply_patch") return usePatch
if (t.id === "edit" || t.id === "write") return !usePatch
return true
})
.map(async (t) => {
using _ = log.time(t.id)
const tool = await t.init({ agent })
const output = {
description: tool.description,
parameters: tool.parameters,
}
await Plugin.trigger("tool.definition", { toolID: t.id }, output)
return {
id: t.id,
...tool,
description: output.description,
parameters: output.parameters,
}
}),
),
)
})
return Service.of({ register, ids, tools })
}),
)
export async function register(tool: Tool.Info) {
return runPromiseInstance(Service.use((svc) => svc.register(tool)))
const { custom } = await state()
const idx = custom.findIndex((t) => t.id === tool.id)
if (idx >= 0) {
custom.splice(idx, 1, tool)
return
}
custom.push(tool)
}
async function all(): Promise<Tool.Info[]> {
const custom = await state().then((x) => x.custom)
const config = await Config.get()
const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
return [
InvalidTool,
...(question ? [QuestionTool] : []),
BashTool,
ReadTool,
GlobTool,
GrepTool,
EditTool,
WriteTool,
TaskTool,
WebFetchTool,
TodoWriteTool,
// TodoReadTool,
WebSearchTool,
CodeSearchTool,
SkillTool,
ApplyPatchTool,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []),
...custom,
]
}
export async function ids() {
return runPromiseInstance(Service.use((svc) => svc.ids()))
return all().then((x) => x.map((t) => t.id))
}
export async function tools(
@@ -214,6 +136,39 @@ export namespace ToolRegistry {
},
agent?: Agent.Info,
) {
return runPromiseInstance(Service.use((svc) => svc.tools(model, agent)))
const tools = await all()
const result = await Promise.all(
tools
.filter((t) => {
// Enable websearch/codesearch for zen users OR via enable flag
if (t.id === "codesearch" || t.id === "websearch") {
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
}
// use apply tool in same format as codex
const usePatch =
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
if (t.id === "apply_patch") return usePatch
if (t.id === "edit" || t.id === "write") return !usePatch
return true
})
.map(async (t) => {
using _ = log.time(t.id)
const tool = await t.init({ agent })
const output = {
description: tool.description,
parameters: tool.parameters,
}
await Plugin.trigger("tool.definition", { toolID: t.id }, output)
return {
id: t.id,
...tool,
description: output.description,
parameters: output.parameters,
}
}),
)
return result
}
}

View File

@@ -3,13 +3,13 @@ import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect"
import path from "path"
import type { Agent } from "../agent/agent"
import { AppFileSystem } from "@/filesystem"
import { PermissionNext } from "../permission"
import { evaluate } from "@/permission/evaluate"
import { Identifier } from "../id/id"
import { Log } from "../util/log"
import { ToolID } from "./schema"
import { TRUNCATION_DIR } from "./truncation-dir"
export namespace TruncateEffect {
export namespace Truncate {
const log = Log.create({ service: "truncation" })
const RETENTION = Duration.days(7)
@@ -28,7 +28,7 @@ export namespace TruncateEffect {
function hasTaskTool(agent?: Agent.Info) {
if (!agent?.permission) return false
return PermissionNext.evaluate("task", "*", agent.permission).action !== "deny"
return evaluate("task", "*", agent.permission).action !== "deny"
}
export interface Interface {

View File

@@ -1,6 +1,6 @@
import type { Agent } from "../agent/agent"
import { runtime } from "@/effect/runtime"
import { TruncateEffect as S } from "./truncate-effect"
import { Truncate as S } from "./truncate-effect"
export namespace Truncate {
export const MAX_LINES = S.MAX_LINES

View File

@@ -8,6 +8,7 @@ import { Bus } from "../bus"
import { File } from "../file"
import { FileWatcher } from "../file/watcher"
import { FileTime } from "../file/time"
import { Format } from "../format"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { trimDiff } from "./edit"
@@ -42,6 +43,7 @@ export const WriteTool = Tool.define("write", {
})
await Filesystem.write(filepath, params.content)
await Format.run(filepath)
await Bus.publish(File.Event.Edited, {
file: filepath,
})

View File

@@ -61,9 +61,9 @@ export namespace Process {
const proc = launch(cmd[0], cmd.slice(1), {
cwd: opts.cwd,
shell: opts.shell,
env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined,
stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"],
shell: opts.shell,
windowsHide: process.platform === "win32",
})

View File

@@ -1,9 +1,13 @@
import whichPkg from "which"
import path from "path"
import { Global } from "../global"
export function which(cmd: string, env?: NodeJS.ProcessEnv) {
const base = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? ""
const full = base ? base + path.delimiter + Global.Path.bin : Global.Path.bin
const result = whichPkg.sync(cmd, {
nothrow: true,
path: env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path,
path: full,
pathExt: env?.PATHEXT ?? env?.PathExt ?? process.env.PATHEXT ?? process.env.PathExt,
})
return typeof result === "string" ? result : null

View File

@@ -3,7 +3,7 @@ import { Duration, Effect, Layer, Option, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AccountRepo } from "../../src/account/repo"
import { AccountEffect } from "../../src/account/effect"
import { Account } from "../../src/account/effect"
import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
import { Database } from "../../src/storage/db"
import { testEffect } from "../lib/effect"
@@ -19,7 +19,7 @@ const truncate = Layer.effectDiscard(
const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
const live = (client: HttpClient.HttpClient) =>
AccountEffect.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
Account.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
HttpClientResponse.fromWeb(
@@ -52,7 +52,7 @@ const deviceTokenClient = (body: unknown, status = 400) =>
)
const poll = (body: unknown, status = 400) =>
AccountEffect.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status))))
Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status))))
it.effect("orgsByAccount groups orgs per account", () =>
Effect.gen(function* () {
@@ -97,7 +97,7 @@ it.effect("orgsByAccount groups orgs per account", () =>
}),
)
const rows = yield* AccountEffect.Service.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
const rows = yield* Account.Service.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([
[AccountID.make("user-1"), [OrgID.make("org-1")]],
@@ -135,7 +135,7 @@ it.effect("token refresh persists the new token", () =>
),
)
const token = yield* AccountEffect.Service.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
const token = yield* Account.Service.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
expect(Option.getOrThrow(token)).toBeDefined()
expect(String(Option.getOrThrow(token))).toBe("at_new")
@@ -178,7 +178,7 @@ it.effect("config sends the selected org header", () =>
}),
)
const cfg = yield* AccountEffect.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe(
const cfg = yield* Account.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe(
Effect.provide(live(client)),
)
@@ -209,7 +209,7 @@ it.effect("poll stores the account and first org on success", () =>
),
)
const res = yield* AccountEffect.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(client)))
const res = yield* Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(client)))
expect(res._tag).toBe("PollSuccess")
if (res._tag === "PollSuccess") {

View File

@@ -38,7 +38,7 @@ test("build agent has correct default properties", async () => {
expect(build).toBeDefined()
expect(build?.mode).toBe("primary")
expect(build?.native).toBe(true)
expect(evalPerm(build, "edit")).toBe("allow")
expect(evalPerm(build, "edit")).toBe("ask")
expect(evalPerm(build, "bash")).toBe("allow")
},
})
@@ -217,8 +217,8 @@ test("agent permission config merges with defaults", async () => {
expect(build).toBeDefined()
// Specific pattern is denied
expect(PermissionNext.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny")
// Edit still allowed
expect(evalPerm(build, "edit")).toBe("allow")
// Edit still asks (default behavior)
expect(evalPerm(build, "edit")).toBe("ask")
},
})
})

View File

@@ -1,53 +0,0 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
describe("BunProc registry configuration", () => {
test("should not contain hardcoded registry parameters", async () => {
// Read the bun/index.ts file
const bunIndexPath = path.join(__dirname, "../src/bun/index.ts")
const content = await fs.readFile(bunIndexPath, "utf-8")
// Verify that no hardcoded registry is present
expect(content).not.toContain("--registry=")
expect(content).not.toContain("hasNpmRcConfig")
expect(content).not.toContain("NpmRc")
})
test("should use Bun's default registry resolution", async () => {
// Read the bun/index.ts file
const bunIndexPath = path.join(__dirname, "../src/bun/index.ts")
const content = await fs.readFile(bunIndexPath, "utf-8")
// Verify that it uses Bun's default resolution
expect(content).toContain("Bun's default registry resolution")
expect(content).toContain("Bun will use them automatically")
expect(content).toContain("No need to pass --registry flag")
})
test("should have correct command structure without registry", async () => {
// Read the bun/index.ts file
const bunIndexPath = path.join(__dirname, "../src/bun/index.ts")
const content = await fs.readFile(bunIndexPath, "utf-8")
// Extract the install function
const installFunctionMatch = content.match(/export async function install[\s\S]*?^ }/m)
expect(installFunctionMatch).toBeTruthy()
if (installFunctionMatch) {
const installFunction = installFunctionMatch[0]
// Verify expected arguments are present
expect(installFunction).toContain('"add"')
expect(installFunction).toContain('"--force"')
expect(installFunction).toContain('"--exact"')
expect(installFunction).toContain('"--cwd"')
expect(installFunction).toContain("Global.Path.cache")
expect(installFunction).toContain('pkg + "@" + version')
// Verify no registry argument is added
expect(installFunction).not.toContain('"--registry"')
expect(installFunction).not.toContain('args.push("--registry')
}
})
})

View File

@@ -0,0 +1,372 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Deferred, Effect, Stream } from "effect"
import z from "zod"
import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event"
import { GlobalBus } from "../../src/bus/global"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
// ---------------------------------------------------------------------------
// Test event definitions
// ---------------------------------------------------------------------------
const TestEvent = {
Ping: BusEvent.define("test.ping", z.object({ value: z.number() })),
Pong: BusEvent.define("test.pong", z.object({ message: z.string() })),
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function withInstance(directory: string, fn: () => Promise<void>) {
return Instance.provide({ directory, fn })
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("Bus", () => {
afterEach(() => Instance.disposeAll())
describe("publish + subscribe", () => {
test("subscriber receives matching events", async () => {
await using tmp = await tmpdir()
const received: number[] = []
await withInstance(tmp.path, async () => {
Bus.subscribe(TestEvent.Ping, (evt) => {
received.push(evt.properties.value)
})
await Bus.publish(TestEvent.Ping, { value: 42 })
await Bus.publish(TestEvent.Ping, { value: 99 })
})
expect(received).toEqual([42, 99])
})
test("subscriber does not receive events of other types", async () => {
await using tmp = await tmpdir()
const pings: number[] = []
await withInstance(tmp.path, async () => {
Bus.subscribe(TestEvent.Ping, (evt) => {
pings.push(evt.properties.value)
})
await Bus.publish(TestEvent.Pong, { message: "hello" })
await Bus.publish(TestEvent.Ping, { value: 1 })
})
expect(pings).toEqual([1])
})
test("publish with no subscribers does not throw", async () => {
await using tmp = await tmpdir()
await withInstance(tmp.path, async () => {
await Bus.publish(TestEvent.Ping, { value: 1 })
})
})
})
describe("multiple subscribers", () => {
test("all subscribers for same event type are called", async () => {
await using tmp = await tmpdir()
const a: number[] = []
const b: number[] = []
await withInstance(tmp.path, async () => {
Bus.subscribe(TestEvent.Ping, (evt) => a.push(evt.properties.value))
Bus.subscribe(TestEvent.Ping, (evt) => b.push(evt.properties.value))
await Bus.publish(TestEvent.Ping, { value: 7 })
})
expect(a).toEqual([7])
expect(b).toEqual([7])
})
test("subscribers are called in registration order", async () => {
await using tmp = await tmpdir()
const order: string[] = []
await withInstance(tmp.path, async () => {
Bus.subscribe(TestEvent.Ping, () => order.push("first"))
Bus.subscribe(TestEvent.Ping, () => order.push("second"))
Bus.subscribe(TestEvent.Ping, () => order.push("third"))
await Bus.publish(TestEvent.Ping, { value: 0 })
})
expect(order).toEqual(["first", "second", "third"])
})
})
describe("unsubscribe", () => {
test("unsubscribe stops delivery", async () => {
await using tmp = await tmpdir()
const received: number[] = []
await withInstance(tmp.path, async () => {
const unsub = Bus.subscribe(TestEvent.Ping, (evt) => {
received.push(evt.properties.value)
})
await Bus.publish(TestEvent.Ping, { value: 1 })
unsub()
await Bus.publish(TestEvent.Ping, { value: 2 })
})
expect(received).toEqual([1])
})
test("unsubscribe is idempotent", async () => {
await using tmp = await tmpdir()
await withInstance(tmp.path, async () => {
const unsub = Bus.subscribe(TestEvent.Ping, () => {})
unsub()
unsub() // should not throw
})
})
test("unsubscribing one does not affect others", async () => {
await using tmp = await tmpdir()
const a: number[] = []
const b: number[] = []
await withInstance(tmp.path, async () => {
const unsubA = Bus.subscribe(TestEvent.Ping, (evt) => a.push(evt.properties.value))
Bus.subscribe(TestEvent.Ping, (evt) => b.push(evt.properties.value))
await Bus.publish(TestEvent.Ping, { value: 1 })
unsubA()
await Bus.publish(TestEvent.Ping, { value: 2 })
})
expect(a).toEqual([1])
expect(b).toEqual([1, 2])
})
})
describe("subscribeAll", () => {
test("receives events of all types", async () => {
await using tmp = await tmpdir()
const all: string[] = []
await withInstance(tmp.path, async () => {
Bus.subscribeAll((evt) => {
all.push(evt.type)
})
await Bus.publish(TestEvent.Ping, { value: 1 })
await Bus.publish(TestEvent.Pong, { message: "hi" })
})
expect(all).toEqual(["test.ping", "test.pong"])
})
test("subscribeAll + typed subscribe both fire", async () => {
await using tmp = await tmpdir()
const typed: number[] = []
const wild: string[] = []
await withInstance(tmp.path, async () => {
Bus.subscribe(TestEvent.Ping, (evt) => typed.push(evt.properties.value))
Bus.subscribeAll((evt) => wild.push(evt.type))
await Bus.publish(TestEvent.Ping, { value: 5 })
})
expect(typed).toEqual([5])
expect(wild).toEqual(["test.ping"])
})
test("unsubscribe from subscribeAll", async () => {
await using tmp = await tmpdir()
const all: string[] = []
await withInstance(tmp.path, async () => {
const unsub = Bus.subscribeAll((evt) => all.push(evt.type))
await Bus.publish(TestEvent.Ping, { value: 1 })
unsub()
await Bus.publish(TestEvent.Pong, { message: "missed" })
})
expect(all).toEqual(["test.ping"])
})
test("subscribeAll delivers InstanceDisposed on disposal", async () => {
await using tmp = await tmpdir()
const all: string[] = []
await withInstance(tmp.path, async () => {
Bus.subscribeAll((evt) => {
all.push(evt.type)
})
await Bus.publish(TestEvent.Ping, { value: 1 })
})
await Instance.disposeAll()
expect(all).toContain("test.ping")
expect(all).toContain(Bus.InstanceDisposed.type)
})
test("manual unsubscribe suppresses InstanceDisposed", async () => {
await using tmp = await tmpdir()
const all: string[] = []
let unsub = () => {}
await withInstance(tmp.path, async () => {
unsub = Bus.subscribeAll((evt) => {
all.push(evt.type)
})
})
unsub()
await Instance.disposeAll()
expect(all).not.toContain(Bus.InstanceDisposed.type)
})
})
describe("GlobalBus forwarding", () => {
test("publish emits to GlobalBus with directory", async () => {
await using tmp = await tmpdir()
const globalEvents: Array<{ directory?: string; payload: any }> = []
const handler = (evt: any) => globalEvents.push(evt)
GlobalBus.on("event", handler)
try {
await withInstance(tmp.path, async () => {
await Bus.publish(TestEvent.Ping, { value: 42 })
})
const ping = globalEvents.find((e) => e.payload.type === "test.ping")
expect(ping).toBeDefined()
expect(ping!.directory).toBe(tmp.path)
expect(ping!.payload).toEqual({
type: "test.ping",
properties: { value: 42 },
})
} finally {
GlobalBus.off("event", handler)
}
})
})
describe("instance isolation", () => {
test("subscribers in one instance do not receive events from another", async () => {
await using tmpA = await tmpdir()
await using tmpB = await tmpdir()
const eventsA: number[] = []
const eventsB: number[] = []
await withInstance(tmpA.path, async () => {
Bus.subscribe(TestEvent.Ping, (evt) => eventsA.push(evt.properties.value))
})
await withInstance(tmpB.path, async () => {
Bus.subscribe(TestEvent.Ping, (evt) => eventsB.push(evt.properties.value))
})
await withInstance(tmpA.path, async () => {
await Bus.publish(TestEvent.Ping, { value: 1 })
})
await withInstance(tmpB.path, async () => {
await Bus.publish(TestEvent.Ping, { value: 2 })
})
expect(eventsA).toEqual([1])
expect(eventsB).toEqual([2])
})
})
describe("async subscribers", () => {
test("publish is fire-and-forget (does not await subscriber callbacks)", async () => {
await using tmp = await tmpdir()
const received: number[] = []
await withInstance(tmp.path, async () => {
Bus.subscribe(TestEvent.Ping, async (evt) => {
await new Promise((r) => setTimeout(r, 10))
received.push(evt.properties.value)
})
await Bus.publish(TestEvent.Ping, { value: 1 })
// Give the async subscriber time to complete
await new Promise((r) => setTimeout(r, 50))
})
expect(received).toEqual([1])
})
})
describe("Effect service", () => {
test("subscribeAll stream receives published events", async () => {
await using tmp = await tmpdir()
const received: string[] = []
await withInstance(tmp.path, () =>
Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const svc = yield* Bus.Service
const done = yield* Deferred.make<void>()
let count = 0
yield* Effect.forkScoped(
svc.subscribeAll().pipe(
Stream.runForEach((msg) =>
Effect.gen(function* () {
received.push(msg.type)
if (++count >= 2) yield* Deferred.succeed(done, undefined)
}),
),
),
)
// Let the forked fiber start and subscribe to the PubSub
yield* Effect.yieldNow
yield* svc.publish(TestEvent.Ping, { value: 1 })
yield* svc.publish(TestEvent.Pong, { message: "hi" })
yield* Deferred.await(done)
}),
).pipe(Effect.provide(Bus.layer)),
),
)
expect(received).toEqual(["test.ping", "test.pong"])
})
test("subscribeAll stream ends with ensuring when scope closes", async () => {
await using tmp = await tmpdir()
let ensuringFired = false
await withInstance(tmp.path, () =>
Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const svc = yield* Bus.Service
yield* Effect.forkScoped(
svc.subscribeAll().pipe(
Stream.runForEach(() => Effect.void),
Effect.ensuring(Effect.sync(() => {
ensuringFired = true
})),
),
)
yield* svc.publish(TestEvent.Ping, { value: 1 })
yield* Effect.yieldNow
}),
).pipe(Effect.provide(Bus.layer)),
),
)
expect(ensuringFired).toBe(true)
})
})
})

View File

@@ -1,4 +1,4 @@
import { test, expect, describe, mock, afterEach, spyOn } from "bun:test"
import { test, expect, describe, mock, afterEach } from "bun:test"
import { Config } from "../../src/config/config"
import { Instance } from "../../src/project/instance"
import { Auth } from "../../src/auth"
@@ -10,7 +10,6 @@ import { pathToFileURL } from "url"
import { Global } from "../../src/global"
import { ProjectID } from "../../src/project/schema"
import { Filesystem } from "../../src/util/filesystem"
import { BunProc } from "../../src/bun"
// Get managed config directory from environment (set in preload.ts)
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
@@ -764,39 +763,6 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
}
})
test("serializes concurrent config dependency installs", async () => {
await using tmp = await tmpdir()
const dirs = [path.join(tmp.path, "a"), path.join(tmp.path, "b")]
await Promise.all(dirs.map((dir) => fs.mkdir(dir, { recursive: true })))
const seen: string[] = []
let active = 0
let max = 0
const run = spyOn(BunProc, "run").mockImplementation(async (_cmd, opts) => {
active++
max = Math.max(max, active)
seen.push(opts?.cwd ?? "")
await new Promise((resolve) => setTimeout(resolve, 25))
active--
return {
code: 0,
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
}
})
try {
await Promise.all(dirs.map((dir) => Config.installDependencies(dir)))
} finally {
run.mockRestore()
}
expect(max).toBe(1)
expect(seen.toSorted()).toEqual(dirs.toSorted())
expect(await Filesystem.exists(path.join(dirs[0], "package.json"))).toBe(true)
expect(await Filesystem.exists(path.join(dirs[1], "package.json"))).toBe(true)
})
test("resolves scoped npm plugins in config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {

View File

@@ -1,47 +1,155 @@
import { afterEach, describe, expect, test } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Effect, Layer, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { Installation } from "../../src/installation"
const fetch0 = globalThis.fetch
const encoder = new TextEncoder()
afterEach(() => {
globalThis.fetch = fetch0
})
function mockHttpClient(handler: (request: HttpClientRequest.HttpClientRequest) => Response) {
const client = HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, handler(request))))
return Layer.succeed(HttpClient.HttpClient, client)
}
function mockSpawner(handler: (cmd: string, args: readonly string[]) => string = () => "") {
const spawner = ChildProcessSpawner.make((command) => {
const std = ChildProcess.isStandardCommand(command) ? command : undefined
const output = handler(std?.command ?? "", std?.args ?? [])
return Effect.succeed(
ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(0),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
isRunning: Effect.succeed(false),
kill: () => Effect.void,
stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any,
stdout: output ? Stream.make(encoder.encode(output)) : Stream.empty,
stderr: Stream.empty,
all: Stream.empty,
getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any,
getOutputFd: () => Stream.empty,
}),
)
})
return Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)
}
function jsonResponse(body: unknown) {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
})
}
function testLayer(
httpHandler: (request: HttpClientRequest.HttpClientRequest) => Response,
spawnHandler?: (cmd: string, args: readonly string[]) => string,
) {
return Installation.layer.pipe(
Layer.provide(mockHttpClient(httpHandler)),
Layer.provide(mockSpawner(spawnHandler)),
)
}
describe("installation", () => {
test("reads release version from GitHub releases", async () => {
globalThis.fetch = (async () =>
new Response(JSON.stringify({ tag_name: "v1.2.3" }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch
describe("latest", () => {
test("reads release version from GitHub releases", async () => {
const layer = testLayer(() => jsonResponse({ tag_name: "v1.2.3" }))
expect(await Installation.latest("unknown")).toBe("1.2.3")
})
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("unknown")).pipe(Effect.provide(layer)),
)
expect(result).toBe("1.2.3")
})
test("reads scoop manifest versions", async () => {
globalThis.fetch = (async () =>
new Response(JSON.stringify({ version: "2.3.4" }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch
test("strips v prefix from GitHub release tag", async () => {
const layer = testLayer(() => jsonResponse({ tag_name: "v4.0.0-beta.1" }))
expect(await Installation.latest("scoop")).toBe("2.3.4")
})
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("curl")).pipe(Effect.provide(layer)),
)
expect(result).toBe("4.0.0-beta.1")
})
test("reads chocolatey feed versions", async () => {
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
d: {
results: [{ Version: "3.4.5" }],
},
}),
{
status: 200,
headers: { "content-type": "application/json" },
test("reads npm registry versions", async () => {
const layer = testLayer(
() => jsonResponse({ version: "1.5.0" }),
(cmd, args) => {
if (cmd === "npm" && args.includes("registry")) return "https://registry.npmjs.org\n"
return ""
},
)) as unknown as typeof fetch
)
expect(await Installation.latest("choco")).toBe("3.4.5")
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("npm")).pipe(Effect.provide(layer)),
)
expect(result).toBe("1.5.0")
})
test("reads npm registry versions for bun method", async () => {
const layer = testLayer(
() => jsonResponse({ version: "1.6.0" }),
() => "",
)
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("bun")).pipe(Effect.provide(layer)),
)
expect(result).toBe("1.6.0")
})
test("reads scoop manifest versions", async () => {
const layer = testLayer(() => jsonResponse({ version: "2.3.4" }))
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("scoop")).pipe(Effect.provide(layer)),
)
expect(result).toBe("2.3.4")
})
test("reads chocolatey feed versions", async () => {
const layer = testLayer(() => jsonResponse({ d: { results: [{ Version: "3.4.5" }] } }))
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("choco")).pipe(Effect.provide(layer)),
)
expect(result).toBe("3.4.5")
})
test("reads brew formulae API versions", async () => {
const layer = testLayer(
() => jsonResponse({ versions: { stable: "2.0.0" } }),
(cmd, args) => {
// getBrewFormula: return core formula (no tap)
if (cmd === "brew" && args.includes("--formula") && args.includes("anomalyco/tap/opencode")) return ""
if (cmd === "brew" && args.includes("--formula") && args.includes("opencode")) return "opencode"
return ""
},
)
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("brew")).pipe(Effect.provide(layer)),
)
expect(result).toBe("2.0.0")
})
test("reads brew tap info JSON via CLI", async () => {
const brewInfoJson = JSON.stringify({
formulae: [{ versions: { stable: "2.1.0" } }],
})
const layer = testLayer(
() => jsonResponse({}), // HTTP not used for tap formula
(cmd, args) => {
if (cmd === "brew" && args.includes("anomalyco/tap/opencode") && args.includes("--formula"))
return "opencode"
if (cmd === "brew" && args.includes("--json=v2")) return brewInfoJson
return ""
},
)
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("brew")).pipe(Effect.provide(layer)),
)
expect(result).toBe("2.1.0")
})
})
})

View File

@@ -0,0 +1,85 @@
import { describe, expect, test } from "bun:test"
import type { MessageV2 } from "../../src/session/message-v2"
import { SessionPrompt } from "../../src/session/prompt"
function makeUser(id: string): MessageV2.User {
return {
id,
role: "user",
sessionID: "session-1",
time: { created: Date.now() },
agent: "default",
model: { providerID: "openai", modelID: "gpt-4" },
} as MessageV2.User
}
function makeAssistant(
id: string,
parentID: string,
finish?: string,
): MessageV2.Assistant {
return {
id,
role: "assistant",
sessionID: "session-1",
parentID,
mode: "default",
agent: "default",
path: { cwd: "/tmp", root: "/tmp" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID: "gpt-4",
providerID: "openai",
time: { created: Date.now() },
finish,
} as MessageV2.Assistant
}
describe("shouldExitLoop", () => {
test("normal case: user ID < assistant ID, parentID matches, finish=end_turn → exits", () => {
const user = makeUser("01AAA")
const assistant = makeAssistant("01BBB", "01AAA", "end_turn")
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(true)
})
test("clock skew: user ID > assistant ID, parentID matches, finish=stop → exits", () => {
// Simulates client clock ahead: user message ID sorts AFTER the assistant ID
const user = makeUser("01ZZZ")
const assistant = makeAssistant("01AAA", "01ZZZ", "stop")
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(true)
})
test("unfinished assistant: finish=tool-calls → does NOT exit", () => {
const user = makeUser("01AAA")
const assistant = makeAssistant("01BBB", "01AAA", "tool-calls")
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(false)
})
test("unfinished assistant: finish=unknown → does NOT exit", () => {
const user = makeUser("01AAA")
const assistant = makeAssistant("01BBB", "01AAA", "unknown")
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(false)
})
test("no assistant yet → does NOT exit", () => {
const user = makeUser("01AAA")
expect(SessionPrompt.shouldExitLoop(user, undefined)).toBe(false)
})
test("assistant has no finish → does NOT exit", () => {
const user = makeUser("01AAA")
const assistant = makeAssistant("01BBB", "01AAA", undefined)
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(false)
})
test("parentID mismatch → does NOT exit", () => {
const user = makeUser("01AAA")
const assistant = makeAssistant("01BBB", "01OTHER", "end_turn")
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(false)
})
test("no user message → does NOT exit", () => {
const assistant = makeAssistant("01BBB", "01AAA", "end_turn")
expect(SessionPrompt.shouldExitLoop(undefined, assistant)).toBe(false)
})
})

View File

@@ -2,14 +2,16 @@ import { describe, test, expect } from "bun:test"
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, FileSystem, Layer } from "effect"
import { Truncate } from "../../src/tool/truncate"
import { TruncateEffect } from "../../src/tool/truncate-effect"
import { Truncate as TruncateSvc } from "../../src/tool/truncate-effect"
import { Identifier } from "../../src/id/id"
import { Process } from "../../src/util/process"
import { Filesystem } from "../../src/util/filesystem"
import path from "path"
import { testEffect } from "../lib/effect"
import { writeFileStringScoped } from "../lib/filesystem"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
const ROOT = path.resolve(import.meta.dir, "..", "..")
describe("Truncate", () => {
describe("output", () => {
@@ -125,11 +127,19 @@ describe("Truncate", () => {
if (result.truncated) throw new Error("expected not truncated")
expect("outputPath" in result).toBe(false)
})
test("loads truncate effect in a fresh process", async () => {
const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate-effect.ts")], {
cwd: ROOT,
})
expect(out.code).toBe(0)
}, 20000)
})
describe("cleanup", () => {
const DAY_MS = 24 * 60 * 60 * 1000
const it = testEffect(Layer.mergeAll(TruncateEffect.defaultLayer, NodeFileSystem.layer))
const it = testEffect(Layer.mergeAll(TruncateSvc.defaultLayer, NodeFileSystem.layer))
it.effect("deletes files older than 7 days and preserves recent files", () =>
Effect.gen(function* () {
@@ -142,7 +152,7 @@ describe("Truncate", () => {
yield* writeFileStringScoped(old, "old content")
yield* writeFileStringScoped(recent, "recent content")
yield* TruncateEffect.Service.use((s) => s.cleanup())
yield* TruncateSvc.Service.use((s) => s.cleanup())
expect(yield* fs.exists(old)).toBe(false)
expect(yield* fs.exists(recent)).toBe(true)

View File

@@ -1009,6 +1009,392 @@ export type GlobalEvent = {
payload: Event
}
/**
* Custom keybind configurations
*/
export type KeybindsConfig = {
/**
* Leader key for keybind combinations
*/
leader?: string
/**
* Exit the application
*/
app_exit?: string
/**
* Open external editor
*/
editor_open?: string
/**
* List available themes
*/
theme_list?: string
/**
* Toggle sidebar
*/
sidebar_toggle?: string
/**
* Toggle session scrollbar
*/
scrollbar_toggle?: string
/**
* Toggle username visibility
*/
username_toggle?: string
/**
* View status
*/
status_view?: string
/**
* Export session to editor
*/
session_export?: string
/**
* Create a new session
*/
session_new?: string
/**
* List all sessions
*/
session_list?: string
/**
* Show session timeline
*/
session_timeline?: string
/**
* Fork session from message
*/
session_fork?: string
/**
* Rename session
*/
session_rename?: string
/**
* Delete session
*/
session_delete?: string
/**
* Delete stash entry
*/
stash_delete?: string
/**
* Open provider list from model dialog
*/
model_provider_list?: string
/**
* Toggle model favorite status
*/
model_favorite_toggle?: string
/**
* Share current session
*/
session_share?: string
/**
* Unshare current session
*/
session_unshare?: string
/**
* Interrupt current session
*/
session_interrupt?: string
/**
* Compact the session
*/
session_compact?: string
/**
* Scroll messages up by one page
*/
messages_page_up?: string
/**
* Scroll messages down by one page
*/
messages_page_down?: string
/**
* Scroll messages up by one line
*/
messages_line_up?: string
/**
* Scroll messages down by one line
*/
messages_line_down?: string
/**
* Scroll messages up by half page
*/
messages_half_page_up?: string
/**
* Scroll messages down by half page
*/
messages_half_page_down?: string
/**
* Navigate to first message
*/
messages_first?: string
/**
* Navigate to last message
*/
messages_last?: string
/**
* Navigate to next message
*/
messages_next?: string
/**
* Navigate to previous message
*/
messages_previous?: string
/**
* Navigate to last user message
*/
messages_last_user?: string
/**
* Copy message
*/
messages_copy?: string
/**
* Undo message
*/
messages_undo?: string
/**
* Redo message
*/
messages_redo?: string
/**
* Toggle code block concealment in messages
*/
messages_toggle_conceal?: string
/**
* Toggle tool details visibility
*/
tool_details?: string
/**
* List available models
*/
model_list?: string
/**
* Next recently used model
*/
model_cycle_recent?: string
/**
* Previous recently used model
*/
model_cycle_recent_reverse?: string
/**
* Next favorite model
*/
model_cycle_favorite?: string
/**
* Previous favorite model
*/
model_cycle_favorite_reverse?: string
/**
* List available commands
*/
command_list?: string
/**
* List agents
*/
agent_list?: string
/**
* Next agent
*/
agent_cycle?: string
/**
* Previous agent
*/
agent_cycle_reverse?: string
/**
* Toggle auto-accept mode for permissions
*/
permission_auto_accept_toggle?: string
/**
* Cycle model variants
*/
variant_cycle?: string
/**
* Clear input field
*/
input_clear?: string
/**
* Paste from clipboard
*/
input_paste?: string
/**
* Submit input
*/
input_submit?: string
/**
* Insert newline in input
*/
input_newline?: string
/**
* Move cursor left in input
*/
input_move_left?: string
/**
* Move cursor right in input
*/
input_move_right?: string
/**
* Move cursor up in input
*/
input_move_up?: string
/**
* Move cursor down in input
*/
input_move_down?: string
/**
* Select left in input
*/
input_select_left?: string
/**
* Select right in input
*/
input_select_right?: string
/**
* Select up in input
*/
input_select_up?: string
/**
* Select down in input
*/
input_select_down?: string
/**
* Move to start of line in input
*/
input_line_home?: string
/**
* Move to end of line in input
*/
input_line_end?: string
/**
* Select to start of line in input
*/
input_select_line_home?: string
/**
* Select to end of line in input
*/
input_select_line_end?: string
/**
* Move to start of visual line in input
*/
input_visual_line_home?: string
/**
* Move to end of visual line in input
*/
input_visual_line_end?: string
/**
* Select to start of visual line in input
*/
input_select_visual_line_home?: string
/**
* Select to end of visual line in input
*/
input_select_visual_line_end?: string
/**
* Move to start of buffer in input
*/
input_buffer_home?: string
/**
* Move to end of buffer in input
*/
input_buffer_end?: string
/**
* Select to start of buffer in input
*/
input_select_buffer_home?: string
/**
* Select to end of buffer in input
*/
input_select_buffer_end?: string
/**
* Delete line in input
*/
input_delete_line?: string
/**
* Delete to end of line in input
*/
input_delete_to_line_end?: string
/**
* Delete to start of line in input
*/
input_delete_to_line_start?: string
/**
* Backspace in input
*/
input_backspace?: string
/**
* Delete character in input
*/
input_delete?: string
/**
* Undo in input
*/
input_undo?: string
/**
* Redo in input
*/
input_redo?: string
/**
* Move word forward in input
*/
input_word_forward?: string
/**
* Move word backward in input
*/
input_word_backward?: string
/**
* Select word forward in input
*/
input_select_word_forward?: string
/**
* Select word backward in input
*/
input_select_word_backward?: string
/**
* Delete word forward in input
*/
input_delete_word_forward?: string
/**
* Delete word backward in input
*/
input_delete_word_backward?: string
/**
* Previous history item
*/
history_previous?: string
/**
* Next history item
*/
history_next?: string
/**
* Next child session
*/
session_child_cycle?: string
/**
* Previous child session
*/
session_child_cycle_reverse?: string
/**
* Go to parent session
*/
session_parent?: string
/**
* Suspend terminal
*/
terminal_suspend?: string
/**
* Toggle terminal title
*/
terminal_title_toggle?: string
/**
* Toggle tips on home screen
*/
tips_toggle?: string
/**
* Toggle thinking blocks visibility
*/
display_thinking?: string
}
/**
* Log level
*/

View File

@@ -0,0 +1,40 @@
import type { AssistantMessage, Message as MessageType } from "@opencode-ai/sdk/v2/client"
/**
* Find assistant messages that are replies to a given user message.
*
* Scans forward from the user message index first, then falls back to scanning
* backward. The backward scan handles clock skew where assistant messages
* (generated server-side) sort before the user message (generated client-side
* with an ahead clock) in the ID-sorted array.
*/
export function findAssistantMessages(
messages: MessageType[],
userIndex: number,
userID: string,
): AssistantMessage[] {
if (userIndex < 0 || userIndex >= messages.length) return []
const result: AssistantMessage[] = []
// Scan forward from user message
for (let i = userIndex + 1; i < messages.length; i++) {
const item = messages[i]
if (!item) continue
if (item.role === "user") break
if (item.role === "assistant" && item.parentID === userID) result.push(item as AssistantMessage)
}
// Scan backward to find assistant messages that sort before the user
// message due to clock skew between client and server
if (result.length === 0) {
for (let i = userIndex - 1; i >= 0; i--) {
const item = messages[i]
if (!item) continue
if (item.role === "user") break
if (item.role === "assistant" && item.parentID === userID) result.push(item as AssistantMessage)
}
}
return result
}

View File

@@ -827,7 +827,7 @@
[data-slot="question-body"] {
display: flex;
flex-direction: column;
gap: 16px;
gap: 0;
flex: 1;
min-height: 0;
padding: 8px 8px 0;
@@ -907,7 +907,7 @@
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
color: var(--text-strong);
padding: 0 10px;
padding: 16px 10px 0;
}
[data-slot="question-hint"] {
@@ -1050,8 +1050,26 @@
line-height: var(--line-height-large);
color: var(--text-base);
min-width: 0;
overflow-wrap: anywhere;
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-slot="question-option"][data-custom="true"] {
[data-slot="option-description"] {
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
}
&[data-picked="true"] {
[data-slot="question-custom-input"]:focus-visible {
outline: none;
outline-offset: 0;
border-radius: 0;
}
}
}
[data-slot="question-custom"] {

View File

@@ -9,6 +9,7 @@ import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } fr
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { AssistantParts, Message, MessageDivider, PART_MAPPING, type UserActions } from "./message-part"
import { findAssistantMessages } from "./find-assistant-messages"
import { Card } from "./card"
import { Accordion } from "./accordion"
import { StickyAccordionHeader } from "./sticky-accordion-header"
@@ -267,14 +268,7 @@ export function SessionTurn(
const index = messageIndex()
if (index < 0) return emptyAssistant
const result: AssistantMessage[] = []
for (let i = index + 1; i < messages.length; i++) {
const item = messages[i]
if (!item) continue
if (item.role === "user") break
if (item.role === "assistant" && item.parentID === msg.id) result.push(item as AssistantMessage)
}
return result
return findAssistantMessages(messages, index, msg.id)
},
emptyAssistant,
{ equals: same },

View File

@@ -0,0 +1,108 @@
diff --git a/dist/index.mjs b/dist/index.mjs
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -959,7 +959,7 @@
model: z4.string().nullish(),
object: z4.literal("response"),
output: z4.array(outputItemSchema),
- usage: xaiResponsesUsageSchema,
+ usage: xaiResponsesUsageSchema.nullish(),
status: z4.string()
});
var xaiResponsesChunkSchema = z4.union([
\ No newline at end of file
@@ -1143,6 +1143,18 @@
z4.object({
type: z4.literal("response.completed"),
response: xaiResponsesResponseSchema
+ }),
+ z4.object({
+ type: z4.literal("response.function_call_arguments.delta"),
+ item_id: z4.string(),
+ output_index: z4.number(),
+ delta: z4.string()
+ }),
+ z4.object({
+ type: z4.literal("response.function_call_arguments.done"),
+ item_id: z4.string(),
+ output_index: z4.number(),
+ arguments: z4.string()
})
]);
\ No newline at end of file
@@ -1940,6 +1952,9 @@
if (response2.status) {
finishReason = mapXaiResponsesFinishReason(response2.status);
}
+ if (seenToolCalls.size > 0 && finishReason !== "tool-calls") {
+ finishReason = "tool-calls";
+ }
return;
}
if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
\ No newline at end of file
@@ -2024,7 +2039,7 @@
}
}
} else if (part.type === "function_call") {
- if (!seenToolCalls.has(part.call_id)) {
+ if (event.type === "response.output_item.done" && !seenToolCalls.has(part.call_id)) {
seenToolCalls.add(part.call_id);
controller.enqueue({
type: "tool-input-start",
\ No newline at end of file
diff --git a/dist/index.js b/dist/index.js
--- a/dist/index.js
+++ b/dist/index.js
@@ -964,7 +964,7 @@
model: import_v44.z.string().nullish(),
object: import_v44.z.literal("response"),
output: import_v44.z.array(outputItemSchema),
- usage: xaiResponsesUsageSchema,
+ usage: xaiResponsesUsageSchema.nullish(),
status: import_v44.z.string()
});
var xaiResponsesChunkSchema = import_v44.z.union([
\ No newline at end of file
@@ -1148,6 +1148,18 @@
import_v44.z.object({
type: import_v44.z.literal("response.completed"),
response: xaiResponsesResponseSchema
+ }),
+ import_v44.z.object({
+ type: import_v44.z.literal("response.function_call_arguments.delta"),
+ item_id: import_v44.z.string(),
+ output_index: import_v44.z.number(),
+ delta: import_v44.z.string()
+ }),
+ import_v44.z.object({
+ type: import_v44.z.literal("response.function_call_arguments.done"),
+ item_id: import_v44.z.string(),
+ output_index: import_v44.z.number(),
+ arguments: import_v44.z.string()
})
]);
\ No newline at end of file
@@ -1935,6 +1947,9 @@
if (response2.status) {
finishReason = mapXaiResponsesFinishReason(response2.status);
}
+ if (seenToolCalls.size > 0 && finishReason !== "tool-calls") {
+ finishReason = "tool-calls";
+ }
return;
}
if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
\ No newline at end of file
@@ -2019,7 +2034,7 @@
}
}
} else if (part.type === "function_call") {
- if (!seenToolCalls.has(part.call_id)) {
+ if (event.type === "response.output_item.done" && !seenToolCalls.has(part.call_id)) {
seenToolCalls.add(part.call_id);
controller.enqueue({
type: "tool-input-start",
\ No newline at end of file

View File

@@ -0,0 +1,58 @@
diff --git a/Users/brendonovich/github.com/anomalyco/opencode/node_modules/solid-js/.bun-tag-6fcb6b48d6947d2c b/.bun-tag-6fcb6b48d6947d2c
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/Users/brendonovich/github.com/anomalyco/opencode/node_modules/solid-js/.bun-tag-b272f631c12927b0 b/.bun-tag-b272f631c12927b0
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/dist/dev.cjs b/dist/dev.cjs
index 7104749486e4361e8c4ee7836a8046582cec7aa1..0501eb1ec5d13b81ecb13a5ac1a82db42502b976 100644
--- a/dist/dev.cjs
+++ b/dist/dev.cjs
@@ -764,6 +764,8 @@ function runComputation(node, value, time) {
if (node.updatedAt != null && "observers" in node) {
writeSignal(node, nextValue, true);
} else if (Transition && Transition.running && node.pure) {
+ // On first computation during transition, also set committed value #2046
+ if (!Transition.sources.has(node)) node.value = nextValue;
Transition.sources.add(node);
node.tValue = nextValue;
} else node.value = nextValue;
diff --git a/dist/dev.js b/dist/dev.js
index ea5e4bc2fd4f0b3922a73d9134439529dc81339f..4b3ec07e624d20fdd23d6941a4fdde6d3a78cca3 100644
--- a/dist/dev.js
+++ b/dist/dev.js
@@ -762,6 +762,8 @@ function runComputation(node, value, time) {
if (node.updatedAt != null && "observers" in node) {
writeSignal(node, nextValue, true);
} else if (Transition && Transition.running && node.pure) {
+ // On first computation during transition, also set committed value #2046
+ if (!Transition.sources.has(node)) node.value = nextValue;
Transition.sources.add(node);
node.tValue = nextValue;
} else node.value = nextValue;
diff --git a/dist/solid.cjs b/dist/solid.cjs
index 7c133a2b254678a84fd61d719fbeffad766e1331..2f68c99f2698210cc0bac62f074cc8cd3beb2881 100644
--- a/dist/solid.cjs
+++ b/dist/solid.cjs
@@ -717,6 +717,8 @@ function runComputation(node, value, time) {
if (node.updatedAt != null && "observers" in node) {
writeSignal(node, nextValue, true);
} else if (Transition && Transition.running && node.pure) {
+ // On first computation during transition, also set committed value #2046
+ if (!Transition.sources.has(node)) node.value = nextValue;
Transition.sources.add(node);
node.tValue = nextValue;
} else node.value = nextValue;
diff --git a/dist/solid.js b/dist/solid.js
index 656fd26e7e5c794aa22df19c2377ff5c0591fc29..f08e9f5a7157c3506e5b6922fe2ef991335a80be 100644
--- a/dist/solid.js
+++ b/dist/solid.js
@@ -715,6 +715,8 @@ function runComputation(node, value, time) {
if (node.updatedAt != null && "observers" in node) {
writeSignal(node, nextValue, true);
} else if (Transition && Transition.running && node.pure) {
+ // On first computation during transition, also set committed value #2046
+ if (!Transition.sources.has(node)) node.value = nextValue;
Transition.sources.add(node);
node.tValue = nextValue;
} else node.value = nextValue;