Compare commits

...

96 Commits

Author SHA1 Message Date
Kit Langton 726a11ba4a test(client): review pass on sync-engine test suite
Two parallel reviews (quality + tests-as-spec) over the engine tests,
treating them as first-class code. Accuracy fixes to catalog claims,
fixture honesty, and typecheck coverage:

- typecheck now covers the sync-engine tests and scripts via
  tsconfig.test.json (old test files with pre-existing errors excluded);
  fixes the type errors this surfaced (fixture stream signal param,
  readonly snapshot arrays, invalid assistant literal)
- FakeSessionServer: file header stating the real-fold modeling claim,
  honors the abort signal, heal() so the sim reset cannot drift from the
  faults record, seq() so tests stop leaning on events.length, threads
  the constructor time into the snapshot, fixes AsyncQueue falsy trap
- laws: file header, law 10 numbering for the refresh-race test, vacuous
  law 2 wait replaced with engine.ready(), law 1/2 story comments,
  robust comment stripping in law 3
- sim: header, chaos distribution + drain-phase comments, heal(),
  ses_-prefixed session IDs everywhere
- divergence catalog: corrected engine-side claims (live reordering is
  ruled out by the ordered log, not the cursor check; activity resync
  wording; law 10 citation), header notes the raw-protocol retry test,
  per-call delta event IDs
- engine-data: header linking it to the laws, SNAPSHOT_RECENT named
  constant, error-DTO source-of-truth comment
2026-08-18 23:03:29 -04:00
Kit Langton d31fbab722 test(client): catalog legacy data layer divergence states 2026-08-18 22:42:38 -04:00
Kit Langton 4fcffdd05e docs: record future directions from architecture retrospective 2026-08-18 22:24:13 -04:00
Kit Langton a060459985 docs: clarify engine vs legacy size comparison 2026-08-18 22:14:44 -04:00
Kit Langton 16a45957fd test(client): cover server outages and restarts in sync engine suite 2026-08-18 22:11:51 -04:00
Kit Langton 90832ffe8d refactor(client): simplify engine render and store diff 2026-08-18 21:58:35 -04:00
Kit Langton 6cbf607849 refactor(client): clone only changed subtrees on engine publish 2026-08-18 21:29:12 -04:00
Kit Langton 378d46c076 chore(client): add session sync streaming benchmark 2026-08-18 21:29:11 -04:00
Kit Langton e0d4a0374c chore(client): regenerate on rebased protocol 2026-08-18 20:56:08 -04:00
Kit Langton f6f8606155 docs: record cursor honesty contract 2026-08-18 20:44:57 -04:00
Kit Langton ef31823a6f fix(client): recover from lossy log attach via snapshot 2026-08-18 20:44:57 -04:00
Kit Langton 4a42f4a724 fix(core): refuse log cursors without retained replay 2026-08-18 20:44:57 -04:00
Kit Langton ce27f07f15 fix(client): preserve minted prompt IDs 2026-08-18 20:44:57 -04:00
Kit Langton 80587d936f docs: wiring complete, regression dispositions 2026-08-18 20:44:57 -04:00
Kit Langton 09772d89d5 fix(tui): restore data-layer guarantees under engine wiring 2026-08-18 20:44:57 -04:00
Kit Langton 4f07025067 fix(client): restore engine refresh guarantees 2026-08-18 20:44:57 -04:00
Kit Langton f253bdaeef feat(tui): wire engine data layer 2026-08-18 20:44:57 -04:00
Kit Langton 93c24cafa7 feat(client): engine-backed data layer 2026-08-18 20:44:57 -04:00
Kit Langton 0963826120 docs: record lane status 2026-08-18 20:44:56 -04:00
Kit Langton c5df2e5c28 refactor(core): simplify snapshot and log changes 2026-08-18 20:44:56 -04:00
Kit Langton 13f0cdd0ea feat(core): session.log follow includes ephemeral events 2026-08-18 20:44:32 -04:00
Kit Langton 5887911c26 feat(core): typed seq-unavailable on session.log 2026-08-18 20:44:01 -04:00
Kit Langton 59bc653465 feat(protocol): session.snapshot endpoint 2026-08-18 20:44:01 -04:00
Kit Langton 8a4c14aed5 refactor(client): simplify sync engine 2026-08-18 20:43:47 -04:00
Kit Langton 15aef5f0f2 test(client): seeded chaos sim 2026-08-18 20:43:47 -04:00
Kit Langton 9b87120db6 test(client): six laws 2026-08-18 20:43:47 -04:00
Kit Langton af6407a1b1 feat(client): engine core 2026-08-18 20:43:47 -04:00
Kit Langton e4ff762483 feat(client): session fold 2026-08-18 20:43:47 -04:00
Kit Langton a00822b06e feat(core): Session.snapshot 2026-08-18 20:43:47 -04:00
Kit Langton 58c33f6870 docs: deletion inventory at adoption 2026-08-18 20:43:21 -04:00
Kit Langton 82d13c6715 docs: outbox ack contract, feature-grouped commits, opt-in ephemeral stream 2026-08-18 20:43:21 -04:00
Kit Langton 6c1d066a65 docs: drop adopt-strictness (S2), renumber lanes 2026-08-18 20:43:21 -04:00
Kit Langton 9b8801a3c6 docs: commit-by-commit implementation lanes, snapshot recent param 2026-08-18 20:43:21 -04:00
Kit Langton 8dad59cf99 docs: delivery is a parallel build for local comparison, not a staged migration 2026-08-18 20:43:21 -04:00
Kit Langton a023ece71f docs: snapshot-only entry contract, compaction/revert/fork, ambient bus filter (S4) 2026-08-18 20:43:21 -04:00
Kit Langton 2b5cc0090a docs: single session stream, overlay semantics, mid-stream reconnect 2026-08-18 20:43:21 -04:00
Kit Langton fc6b9e0dde docs: fold prior-art findings into snapshot decision 2026-08-18 20:43:20 -04:00
Kit Langton ab535c3875 docs: decide snapshot endpoint shape (tentative) 2026-08-18 20:43:20 -04:00
Kit Langton 4ca2f9dd83 docs: sync engine plan outline 2026-08-18 20:43:20 -04:00
Kyle Altendorf 044d04df06 fix(cli): preserve clean build manifest (#43287) 2026-08-18 16:32:10 -05:00
Shoubhit Dash 4b9d89e943 refactor(core): move image config into state (#43090) 2026-08-18 21:07:27 +00:00
Kit Langton 5ff6bb87cf fix(core): coalesce queued compactions (#43292) 2026-08-18 21:04:02 +00:00
opencode-agent[bot] 511b4556a2 fix(tui): navigate nested subagents (#43290)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-18 23:02:45 +02:00
Shoubhit Dash cb39ea1136 fix(core): prefer websocket with request hooks (#43234) 2026-08-19 02:20:57 +05:30
Shoubhit Dash ff9452bf03 refactor(core): move formatter config into state (#43087) 2026-08-19 02:20:24 +05:30
Aiden Cline 97265f8ac5 fix(ai): ignore orphaned Anthropic tool deltas (#43289) 2026-08-18 15:42:31 -05:00
Dax Raad 56e66656b8 fix(ci): limit cli publish build time 2026-08-18 15:43:47 -04:00
opencode-agent[bot] 02f3f3cb3e test(core): remove flaky webfetch checks (#43278)
Co-authored-by: nexxeln <nexxeln@users.noreply.github.com>
2026-08-18 20:26:26 +02:00
Dax b0c3a16ead feat(plugin): expose server plugin status 2026-08-18 14:22:32 -04:00
Filip 594c395576 fix(tui): restore MCP sign-in flow (#43274) 2026-08-18 20:02:10 +02:00
Dax b7402c264d fix(core): reduce noisy tracing spans
Reduce low-value tracing emitted during parallel tool execution and suppress per-statement SQL spans.
2026-08-18 13:42:50 -04:00
Dax 3f79699bce feat(core): persist websearch provider selection (#43268) 2026-08-18 17:32:08 +00:00
Dax 67fe76057e feat(cli): configure service environment (#43269) 2026-08-18 13:27:44 -04:00
opencode-agent[bot] bac474aaa0 fix(tui): scope session picker to active location (#43264)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-18 17:01:15 +00:00
Aiden Cline 98c717cb5b fix(core): migrate standalone small model (#43260) 2026-08-18 11:14:36 -05:00
Major Hayden 5c8d46ab4b fix(core): make Google Vertex models work with ADC credentials (#43077)
Signed-off-by: Major Hayden <major@mhtx.net>
2026-08-18 10:47:13 -05:00
Dax Raad d5e83fefda fix(core): reuse prompt cache for forks 2026-08-18 11:38:11 -04:00
Dax Raad 46378dda50 refactor(core): standardize builtin plugin ids 2026-08-18 11:07:11 -04:00
Dax b38d9d812f refactor(client): move service shutdown client-side (#43252) 2026-08-18 15:02:37 +00:00
Dax Raad 8df039d261 fix(cli): limit source maps to development channels 2026-08-18 10:53:42 -04:00
Dax Raad c92fb2d41b fix(cli): improve process failure logging 2026-08-18 10:49:52 -04:00
Shoubhit Dash 958308c913 fix(core): ignore malformed model costs (#43251) 2026-08-18 19:59:47 +05:30
Dax Raad 16390ca47d fix(cli): log background service startup 2026-08-18 10:15:36 -04:00
Dax Raad 643eed300d fix(cli): log managed service lease loss 2026-08-18 10:06:59 -04:00
Dax Raad c3a6721de2 refactor(tui): standardize builtin plugin ids 2026-08-18 10:06:59 -04:00
Shoubhit Dash 47af7462ea feat(cli): improve session export flow (#43229) 2026-08-18 18:00:12 +05:30
Simon Klee a76ab93673 tui: update OpenTUI to 0.5.4 (#43220) 2026-08-18 13:56:34 +02:00
Shoubhit Dash dfb60e37a3 feat(cli): add interactive auth commands (#43127) 2026-08-18 16:37:56 +05:30
Brendan Allan 5105913568 perf(app): keep home session index query-local (#43191) 2026-08-18 16:16:28 +08:00
Brendan Allan 28b4cade9e fix(client): remove order from message list query (#43204) 2026-08-18 16:15:53 +08:00
Luke Parker 1f61eb5ca9 refactor(frontend): enforce package boundaries (#43177) 2026-08-18 15:49:23 +10:00
opencode-agent[bot] ea1ff90e42 perf(fs): use augmented rope for text pagination (#42972)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-18 15:38:49 +10:00
Aiden Cline eb3c4c82fe fix(ai): support newer Anthropic system updates (#43180) 2026-08-18 00:22:19 -05:00
Aiden Cline 7188d42b99 feat(core): resume subagent sessions (#43172) 2026-08-17 23:43:13 -05:00
Aiden Cline 9391987443 fix(ai): settle pending Anthropic tool calls (#43136) 2026-08-17 22:58:01 -05:00
opencode-agent[bot] 4d79337d42 chore: remove root vitest dependency (#43167)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-18 13:47:24 +10:00
opencode-agent[bot] 401a154ec3 fix(app): keep server details editable (#43170)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-18 13:32:43 +10:00
Luke Parker 18c25ae406 refactor(app): use one server event model (#43168) 2026-08-18 13:28:47 +10:00
opencode-agent[bot] cd156f9f1c test(tui): update web search label expectation (#43166)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-18 03:22:35 +00:00
Dax Raad b968a314d3 fix(tui): hide empty tab bar 2026-08-17 23:15:09 -04:00
Luke Parker 93cdfafa78 refactor(desktop): extract renderer platform (#43164) 2026-08-18 13:12:39 +10:00
Dax Raad b6a8d99da5 fix(cli): align automatic update methods 2026-08-17 23:09:49 -04:00
Dax Raad 8cacf150db chore: skip node cli publish build 2026-08-17 22:49:48 -04:00
Luke Parker 76e7b556b6 refactor(desktop): modularize main process (#43159) 2026-08-18 12:23:05 +10:00
Luke Parker a888ba4da7 refactor(app): use current session messages (#42766) 2026-08-18 12:17:39 +10:00
opencode-agent[bot] a1eca087d4 fix(tui): streamline generic tool rendering (#43158)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-17 21:04:07 -05:00
Aiden Cline 5252cefab2 fix(core): exclude native code mode MCP servers (#43157) 2026-08-17 20:54:25 -05:00
opencode-agent[bot] 839343e5de fix(cli): skip missing node distribution (#43156)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-08-17 21:42:34 -04:00
opencode-agent[bot] 30d778cf5f fix(tui): clarify web search provider labels (#43155)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-17 20:41:14 -05:00
Luke Parker 616aba7bbd refactor(desktop): establish typed ipc contract (#43150) 2026-08-18 11:31:05 +10:00
Aiden Cline 006a0a4b34 feat(plugin): expose MCP server transforms (#43125) 2026-08-17 20:17:48 -05:00
Kit Langton 3facbe1dd3 fix(core): support older previous-channel databases (#43142) 2026-08-18 00:45:36 +00:00
opencode-agent[bot] 1430a460f1 fix(ui): smooth shimmer loop (#43144)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-18 10:40:50 +10:00
Dax Raad 1f96af5ea7 docs: document heap snapshot debugging 2026-08-17 20:37:53 -04:00
Dax Raad 15f0fc9e00 chore: disable generate workflow 2026-08-17 20:26:57 -04:00
opencode-agent[bot] cd3133038b chore: generate 2026-08-18 00:19:50 +00:00
460 changed files with 13931 additions and 6593 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@opencode-ai/plugin": patch
"@opencode-ai/core": patch
---
Add transport-neutral Session model request hooks and provider-scoped hook registration so eligible OpenAI Responses requests can prefer WebSocket without bypassing HTTP-only middleware.
-52
View File
@@ -1,52 +0,0 @@
name: generate
on:
push:
branches:
- dev
- v2
jobs:
generate:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Generate
run: ./script/generate.ts
- name: Commit and push
run: |
if [ -z "$(git status --porcelain)" ]; then
echo "No changes to commit"
exit 0
fi
git add -A
git commit -m "chore: generate" --allow-empty
git push origin HEAD:${{ github.ref_name }} --no-verify
# if ! git push origin HEAD:${{ github.event.pull_request.head.ref || github.ref_name }} --no-verify; then
# echo ""
# echo "============================================"
# echo "Failed to push generated code."
# echo "Please run locally and push:"
# echo ""
# echo " ./script/generate.ts"
# echo " git add -A && git commit -m \"chore: generate\" && git push"
# echo ""
# echo "============================================"
# exit 1
# fi
+3 -1
View File
@@ -82,6 +82,7 @@ jobs:
build-cli:
needs: version
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 30
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
@@ -196,7 +197,7 @@ jobs:
build-node-cli:
needs: version
if: github.repository == 'anomalyco/opencode'
if: github.repository == 'anomalyco/opencode' && false # Temporarily disabled
strategy:
fail-fast: false
matrix:
@@ -594,6 +595,7 @@ jobs:
path: packages/cli/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: needs.build-node-cli.result == 'success'
with:
pattern: opencode-node-cli-*
path: packages/cli/dist/node
+204
View File
@@ -0,0 +1,204 @@
---
name: opencode-dev
description: Use when interactively running, debugging, or verifying opencode's own V2 CLI/TUI or server during development in this repo — starting the dev TUI, driving it with termctrl, comparing V2 against the legacy TUI, hitting the V2 server/API directly, reading log files, or attaching Bun's inspector.
---
# Debugging opencode itself
Workflow for interactively exercising the V2 CLI/TUI and server while developing in this repo. All commands below run from `packages/cli` unless noted otherwise.
## Server/client model
opencode V2 is a client/server system, not a single monolithic process:
- **Server process** runs the Effect HTTP API (`packages/server`) and owns all domain state: sessions, database, plugins, permissions, Location services. It's started by the `serve` command (`packages/cli/src/commands/handlers/serve.ts`).
- **TUI process** is a separate process that runs no application logic itself — it's an HTTP/SSE client of the server via the generated SDK (`createOpencodeClient` / `sdk.client.v2`).
- **Discovery**: CLI processes find the shared server through a JSON registration file at `~/.local/state/opencode/service.json` (or `service-local.json` for the local/dev channel) containing `{id, version, url, pid}`. A separate password file under `~/.config/opencode/service.json` provides HTTP Basic auth. Before reusing a registration, the client calls `GET /health` to confirm the server is alive, authenticated, and version-compatible.
- **Sharing**: because of this registration/health-check dance, many concurrent `opencode`/TUI invocations converge on one shared background daemon rather than each spawning their own. If no compatible healthy daemon is found, a new one is spawned detached (`serve --service`) and registers itself.
- **`bun dev service start|status|stop|restart`** manages this shared background daemon's lifecycle directly — useful when you need to force a fresh server, confirm one is running, or kill a stuck one.
- **Standalone mode** (`--standalone`) opts a single invocation out of the shared daemon: it spawns a private one-off `serve --stdio --port 0` child tied to that invocation's lifetime, with its own random password. Use this to isolate a debugging session from your other running opencode sessions.
- Every log line is tagged `role=server` or `role=cli` and a per-process `run=<id>`, so you can distinguish server-side and client-side activity in one shared log file (see "Logs" below) even when both roles are interleaved from concurrent processes.
## Starting the dev TUI
- This package is the V2 CLI adapter. Run its `dev` script when testing the TUI; do not use the repository-root `bun dev`, which launches the legacy `packages/opencode` CLI.
- Run commands from `packages/cli`. Use `bun dev` for most debugging so the TUI starts with a private V2 server.
## Interactive debugging with termctrl
- Use `termctrl` for interactive checks instead of starting the TUI as a blocking foreground process. It provides a real PTY, handles OpenTUI's host handshake, and can save reviewable screenshots.
- Use a dedicated session name and do not reuse or kill an unrelated session.
```bash
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev
termctrl wait opencode-v2-dev "Ask anything" --timeout 20000
termctrl show opencode-v2-dev
```
- Wait for visible text before interacting instead of relying on fixed sleeps. Use the text expected from the screen under test, such as `Ask anything` or `Connect a provider`.
- Drive the running TUI with `termctrl send`. Prefix typed input with `text:` and send control keys separately so the interaction matches real terminal input.
```bash
termctrl send opencode-v2-dev 'text:example prompt' enter
termctrl send opencode-v2-dev ctrl-c
```
- Use `termctrl show` after each meaningful interaction and inspect the full visible screen for rendering errors, stale state, error toasts, and unexpected exits.
- Save PNG evidence for every user-visible bug and fix. Do not save text captures; inspect the rendered PNG. Write temporary captures outside the repository unless the artifact is intended to be committed.
```bash
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2-tui.png
```
- For resize-sensitive changes, resize the viewport, wait for the expected content, and capture the screen again:
```bash
termctrl resize opencode-v2-dev --cols 100 --rows 30
termctrl show opencode-v2-dev
```
- Source changes may require restarting the process. Use `termctrl restart opencode-v2-dev` rather than assuming the running TUI reloaded the change.
- To exercise background-service behavior, use `bun dev service start`, `bun dev service status`, and `bun dev service stop`.
- Always clean up the Terminal Control session when the check is complete:
```bash
termctrl stop opencode-v2-dev
```
## Comparing V2 against the legacy TUI
Run both versions in separate Terminal Control sessions and save PNG-only captures at equivalent states:
```bash
# From packages/cli: local V2 TUI
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev
# Released legacy TUI behavior reference
termctrl start opencode-legacy --host opentui --cols 112 --rows 34 -- bunx opencode-ai@latest
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2.png
termctrl save opencode-legacy --format png --out /tmp/opencode/legacy.png
```
- Use the same viewport and send equivalent inputs to both sessions before comparing screenshots. The released CLI is a behavioral reference, not a source of V2 API design; keep the local implementation on V2 endpoints.
- Stop both sessions after comparison: `termctrl stop opencode-v2-dev` and `termctrl stop opencode-legacy`.
## Server/API debugging
- Use `bun dev api --help` from `packages/cli` to inspect the API debugging command. It sends one request to the V2 server using the same daemon discovery/auth path as the CLI.
- Use `bun dev api` to introspect the server-side data backing the TUI. This is useful when debugging UI bugs: compare what the screen renders with the raw session, message, event, agent, or health data returned by the API to determine whether the bug is in the server state, the client data layer, or the TUI rendering.
- `bun dev api` accepts either an OpenAPI operation ID or a raw HTTP method plus path:
```bash
bun dev api get /health
bun dev api get /openapi.json
bun dev api <operationId> --param key=value
```
- Pass JSON request bodies with `--data`/`-d`; the command sets `content-type: application/json` automatically unless you provide a header. Add extra headers with `--header`/`-H name:value`.
- If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control.
- Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters.
## Auditing installed `opencode2` sessions
Installed next-channel sessions normally use `~/.local/share/opencode/opencode-next.db` and `~/.local/share/opencode/log/opencode.log`; `OPENCODE_DB` can override the database. Before calling `opencode2 api`, inspect `~/.local/state/opencode/service.json` because the command may start a daemon when none is healthy.
For a supplied `ses_...` ID, compare three sources:
- `opencode2 api get /api/session/active` and the Session/message endpoints for live server state.
- The database's ordered `event` rows for durable history.
- `packages/tui/src/context/data.tsx` and the relevant route for client projection and rendering.
Locate an uncertain database without modifying it:
```bash
SESSION=ses_...
for db in ~/.local/share/opencode/*.db; do
sqlite3 "file:$db?mode=ro" "select 1 from session where id='$SESSION' limit 1" 2>/dev/null | grep -q 1 && printf '%s\n' "$db"
done
```
## Logs
- Log files live under `~/.local/share/opencode/log/`. In a local/dev checkout the active file is `opencode-local.log`; `opencode.log` is used for non-local (released) channel installs. Both are append-only, shared across every CLI and server process on the machine.
- Each line is structured `key=value` text: `timestamp`, `level`, `run=<id>` (per-process run ID), `message`, and a `role=cli` or `role=server` tag. Use `run=` to isolate one process's activity and `role=` to separate client-side from server-side log lines, since a shared daemon interleaves many processes' output in one file.
- Tail the live file while reproducing an issue instead of guessing from stale output:
```bash
tail -f ~/.local/share/opencode/log/opencode-local.log
```
- Filter to one run or role when the file is noisy:
```bash
grep 'run=8fc3b1d5' ~/.local/share/opencode/log/opencode-local.log
grep 'role=server' ~/.local/share/opencode/log/opencode-local.log
```
- `OPENCODE_LOG_LEVEL` controls verbosity (default `INFO`); set it before starting `bun dev` or `serve` to get `DEBUG` output for a specific repro.
- `OPENCODE_PRINT_LOGS=1` additionally tees log output to stderr of the process that emitted it, which is useful when a process fails before you'd think to check the shared log file.
- `termctrl logs <session>` surfaces stdout/stderr for a Terminal Control session specifically (e.g. inspector output or startup failures before the TUI renderer starts) — use the log file above for anything emitted by a separate server/daemon process instead.
## Heap snapshots
The CLI installs a `SIGUSR1` listener on non-Windows processes in `packages/cli/src/heap.ts`. Use it to capture the installed `opencode2` server without restarting it or attaching an inspector.
1. Find the processes and inspect their roles and memory:
```bash
pgrep -a -f 'opencode2\.exe|opencode2'
ps -o pid,ppid,rss,vsz,lstart,etime,cmd -p <pid>,<pid>
```
2. Signal the process whose heap needs investigation. For shared-service memory, target the `opencode2.exe serve --service` child, not the short wrapper/TUI process:
```bash
kill -USR1 <server-pid>
```
3. Wait for `heap snapshot written` in the channel's log before opening the file. Snapshots are written to the same log directory as `heap-<pid>-<timestamp>.heapsnapshot`; writing a large heap can take several seconds and the file is incomplete until the completion message appears:
```bash
grep 'heap snapshot' ~/.local/share/opencode/log/opencode.log | tail
find ~/.local/share/opencode/log -maxdepth 1 -name 'heap-<server-pid>-*.heapsnapshot' -printf '%T@ %s %p\n' | sort -nr | head
```
Use `opencode-local.log` instead for a local/dev channel process. The log's `path=` field is authoritative.
4. Analyze the snapshot with Chrome DevTools, a V8 heap snapshot parser, or a temporary tool installed outside the repository. Start with the largest retained objects, dominators, object counts grouped by constructor/name, and retainer paths back to GC roots. Relate suspicious names and paths back to the source tree rather than treating large shallow allocations as leaks.
For command-line analysis, install tooling under `/tmp/opencode`, not in the repository. For example, MemLab can rank dominators and trace a reported heap object ID back to a GC root:
```bash
npm install --prefix /tmp/opencode/heap-analysis @memlab/cli
/tmp/opencode/heap-analysis/node_modules/.bin/memlab analyze object-size --snapshot <snapshot>
/tmp/opencode/heap-analysis/node_modules/.bin/memlab analyze shape --snapshot <snapshot>
/tmp/opencode/heap-analysis/node_modules/.bin/memlab trace --snapshot <snapshot> --node-id=<id>
```
A single snapshot explains what retains memory at one point in time, but does not by itself prove a leak. For leak confirmation, capture a baseline, perform a controlled repeated workload, allow idle cleanup/GC when possible, capture another snapshot, and compare growth and retainer paths. Also compare snapshot heap size with process RSS: a large difference can indicate native allocations, database mappings, allocator fragmentation, or other memory outside the JavaScript heap.
```bash
cat /proc/<pid>/smaps_rollup
pmap -x <pid> | sort -k3 -nr | head -25
```
Heap serialization itself can temporarily increase RSS and allocator high-water marks, so record `ps`/`smaps_rollup` both before and after capture. Large anonymous mappings with a comparatively small live heap require native-allocation or allocator investigation; they cannot be explained from JavaScript retainer paths alone.
## Debugger
- To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL:
```bash
termctrl start opencode-v2-debug --host opentui --cols 112 --rows 34 -- \
bun run --inspect=ws://localhost:6499/ src/index.ts
```
- Use `--inspect-wait` or `--inspect-brk` when execution must pause until a debugger attaches.
- Use `termctrl logs opencode-v2-debug` for inspector output or startup failures emitted before the TUI renderer starts. Use `termctrl show` for the visible full-screen TUI.
## Verification
- Run `bun typecheck` from `packages/cli` after CLI adapter changes.
- Run `bun typecheck` and `bun test` from `packages/tui` after shared TUI changes. Do not run tests from the repository root.
- Treat automated checks and Terminal Control smoke tests as complementary. For user-visible changes, verify initial render, the changed interaction, Ctrl-C exit behavior, and save a screenshot of the corrected state.
+336
View File
@@ -0,0 +1,336 @@
# Session Sync: Two Architectures
How the legacy client data layer (`createData`) and the session sync engine
(`createEngineData`) keep a client's view of a session in sync with the
server — and why they feel so different to reason about.
Both produce the same thing: a fine-grained reactive Solid store the TUI (and
web app) render from. They differ in *where correctness lives*.
- **legacy** — correctness = the union of ~36 event handlers each being right
- **engine** — correctness = one pure fold + a handful of testable laws
---
## At a glance
| | Legacy `createData` | Engine `createEngineData` |
|---|---|---|
| Source of truth | the store itself, patched per event | server's durable log, folded |
| Ingestion | ambient `/api/event` SSE, all sessions | snapshot + per-session `/log` (durable + ephemeral, seq-cursored) |
| State shape | one mutable store, edited in place | immutable `EngineState`, new value per event |
| Writes | 20 mutation sites, 3 inline fetches interleaved | 2 mutation sites, zero interleaved I/O |
| Recovery | 8 refetch heuristics, band-aid invalidations | one seq watermark; gaps are loud (`SeqUnavailable` → re-snapshot) |
| Optimism | `evt_``msg_` ID rewriting, per-case | outbox of intents with client-minted IDs, acked by durable echo |
| Testability | drive the store, assert the store | law-test the pure fold; chaos-sim the engine |
```tree
src/solid
├── engine/
│ ├── fold.ts 580 lines · pure fold — (state, durableEvent) → state
│ └── engine.ts 498 lines · outbox · overlay · reconnect · render
├── engine-data.ts 271 lines · Solid adapter — identity diff + clone boundary
└── data.ts 1,527 lines · legacy layer (still serves the web app)
```
Size, honestly: the engine's 1,349 lines replace only `data.ts`'s *session
sync* portion (~654 lines) — the rest of `data.ts` covers other domains
(projects, locations, VCS, …) and still runs underneath as a shim. So the
engine is ~2× the code it replaces; the extra lines are the behaviors legacy
doesn't have at all (snapshot/cursor recovery, outbox, reconnect proofs).
---
## How a token delta flows
The hottest path in the system: one streamed text token.
### Legacy — a surgical poke
The actual handler, one of 36 case arms:
```typescript title="src/solid/data.ts" caption="In-place proxy mutation — done in one step"
case "session.text.delta":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestText(
message.assistant(draft, index, event.data.assistantMessageID),
)
if (match) match.text += event.data.delta // ← mutates shared store state in place
})
return
```
Cost: **0.3µs**. Nothing else moves. It is also the whole design: every event
type gets its own hand-written poke at shared mutable state.
### Engine — a value transition
The same token becomes a state transition through a pure pipeline:
```ts
// engine.ts — the sync loop receives the delta and replaces state
publish({ ...state, overlay: applyOverlay(state.overlay, event) })
const publish = (next: EngineState) => {
const previous = state
state = next
// identity guard: synced flips and stale replays render nothing
if (
next.folded === previous.folded &&
next.outbox === previous.outbox &&
next.overlay === previous.overlay
)
return
const view = render(state) // pure
listeners.forEach((listener) => listener(view)) // → adapter
}
function render(state: EngineState): SessionView {
const base = renderBase(state.folded, state.outbox) // ← WeakMap cache hit while streaming
return {
...state.folded,
session: usageSession(state.folded, state.overlay.get("usage")), // ← cache hit
messages: applyOverlayToMessages(base.messages, state.overlay), // remaps only touched messages
pending: base.pending,
}
}
```
```ts
// engine-data.ts — the adapter diffs consecutive views by identity
const update = (sessionID: string, view: SessionView) => {
const previous = rendered.get(sessionID)
rendered.set(sessionID, view)
// the fold is a persistent structure: unchanged parts keep their references,
// so reference inequality *is* the change detector
for (let index = 0; index < view.messages.length; index++)
if (view.messages[index] !== previous.messages[index])
setViews(sessionID, "messages", index, reconcile(clone(view.messages[index])))
// clone only what changed — the store never aliases engine state
}
```
Cost: **3.5µs**. Ten times the legacy poke, 0.035% of a core at 100 tok/s —
and the price buys the properties below.
---
## The legacy architecture
```mermaid
flowchart TD
SSE["/api/event — ambient SSE, every session"] --> HE["handleEvent(event)<br/>switch · 36 case arms in one function"]
HE --> M["setStore(produce)<br/>mutate in place · 20 sites"]
HE --> I["sync.invalidate()<br/>refetch on next read · 8 heuristics"]
HE --> F["api().fetch …<br/>fetch right now · 3 inline"]
M --> S[("one shared<br/>mutable store")]
I --> S
F --> S
S --> UI["TUI / web app"]
```
An outline of what it takes to be correct here:
- every case arm must patch exactly the right path in the store
- event-vs-fetch races must be reasoned about per case
(e.g. the `session.created` band-aid: skip racy initial reads so live
events can win over a stale fetch)
- a missed or misordered event **silently desyncs** — there is no watermark
to notice a gap, so recovery is "invalidate and hope the next read heals it"
- optimistic sends rewrite `evt_*` IDs into `msg_*` IDs inline
None of this is dumb code — it's each problem solved locally, at the site
where it hurt. The cost is that the invariants live in 36 places at once.
## The engine architecture
```mermaid
flowchart TD
SNAP["GET /snapshot<br/>session · inbox · messages · seq"] --> ES
LOG["GET /log?after=seq&follow&ephemeral<br/>replay ▸ log.synced ▸ live tail"] --> ES
subgraph ES["EngineState — immutable, replaced per event"]
FOLD["folded = fold(folded, durableEvent)<br/>pure · seq-ordered"]
OV["overlay = applyOverlay(overlay, event)<br/>ephemeral deltas"]
OB["outbox = [...intents]<br/>optimistic, client-minted IDs"]
end
ES --> R["render(state) → SessionView<br/>persistent structure: unchanged parts<br/>keep their references across versions"]
R --> D["identity diff →<br/>clone changed subtrees only"]
D --> ST[("Solid store")]
ST --> UI["TUI / web app"]
```
Mini outline of the modules:
- **`fold.ts`** — `(state, durableEvent) → state`. No I/O, no clock,
no randomness. Same events in, same state out, on any client.
- **`engine.ts`** — the machine around the fold: snapshot hydration,
log tailing, reconnect, outbox, overlay, `render`. One serial sender.
- **`engine-data.ts`** — the Solid adapter: identity diff, the clone
boundary (reconcile mutates in place, so engine state is never aliased
into the store), and the legacy-API shim.
### The write path — optimistic prompt
```mermaid
sequenceDiagram
participant UI as TUI
participant E as Engine
participant S as Server
participant L as Durable Log
UI->>E: submit(text)
E->>E: outbox += intent { id: client-minted msg ID }
Note over UI,E: view renders the pending row immediately
E->>S: POST /session/:id/prompt { id }
S->>L: session.inbox.enqueued { inboxID: id }
L-->>E: durable echo on the log stream
E->>E: fold(echo) inserts the real row + acks the intent
Note over UI,E: same publish — no flicker, no duplicate, ever
```
Admission is exactly-once *by construction*: the ID is the dedupe key, so a
retried POST after a lost response cannot double-admit.
### Recovery
```mermaid
flowchart TD
DC["disconnect"] --> RC["reconnect"]
RC --> T["GET /log?after=lastSeq"]
T -->|"rows retained for (after, head]"| RP["replay ▸ log.synced ▸ live tail"]
T -->|"gap in retention"| SU["SeqUnavailable — loud, never silent"]
SU --> RS["re-snapshot"]
RS --> OBX["resend unacked outbox"]
```
The server *proves* the replay covers the cursor range or refuses. The
client treats a `log.synced` marker past its folded seq the same way. There
is no silent-desync state.
---
## What the laws pin down
The fold's purity makes these mechanically testable
(`sync-engine-laws.test.ts`, plus a seeded chaos simulation):
1. idempotent admission under lost responses
2. durable echo determinism
3. fold purity (no I/O, replay-stable)
4. submission-order admission
5. multi-client convergence to the server fold
6. failure atomicity (a rejected intent vanishes with its optimistic row)
7. lossy-history recovery (pruned retention → re-snapshot, nothing lost)
8. attach-gap recovery (marker past folded seq → re-snapshot)
9. outage recovery (server down during recovery → retry until it returns)
The legacy layer can't state most of these, because "the fold" is smeared
across 36 case arms and the store itself.
---
## Performance
Benchmark: `packages/client` → `bun run bench:sync`
(200-message transcript, 2,000 streamed deltas, median of 7).
| per token delta | |
|---|---|
| legacy | 0.3µs |
| engine, first draft | 240µs — full-view `structuredClone` + reconcile per event |
| engine, shipped | **3.5µs** (69×) |
How the immutable path got cheap — each step exploits the same fact, that
the fold is a *persistent structure* (unchanged parts keep identity):
1. never clone the whole view; **diff consecutive views by reference** and
clone only changed subtrees, each clone at exactly one store path
2. `render` preserves references for everything an event didn't touch
(memoized durable base, overlay-touched messages only)
3. skip publish entirely when folded/outbox/overlay are identity-unchanged
4. recursive plain-JSON clone instead of `structuredClone`'s serializer
Remaining gap at scale: ~10ns per message per delta of identity walking
(0.24% CPU at 2,000 messages, 100 tok/s). Memory is flat in both layers.
---
## Honest tradeoffs
- **Outbox is in-memory** — process death loses unsent intents (admission
stays exactly-once; nothing duplicates).
- **One log SSE per open session** — fine for the TUI, needs multiplexing
thought for HTTP/1.1 web contexts.
- **State held twice** — the fold's state plus the store's cloned mirror.
Bounded, measured flat, and it *is* the aliasing safety boundary.
- **Ambient event stream still unfiltered** while the legacy shim handles
non-session domains.
- **~1.9× the raw lines touched** — but inverted concentration: the legacy
layer's complexity is *distributed* (36 arms × interleaved I/O × races);
the engine's is *concentrated* in one pure function you can hold in your
head, and law-test.
The trade in one sentence: the legacy layer optimizes for the cheapest
possible patch per event; the engine optimizes for the cheapest possible
*proof* that the client shows what the server knows.
---
## Future directions (design notes)
Each of these replaces a boundary around the engine, not the engine itself —
its `snapshot`/`stream`/`submit` transport seam and pure fold stay put.
### 1. One fold — tables as indexes, not truth
Objection: the server saves into SQL *tables*, not one state value, so how
can server and client share a fold? Answer: distinguish the aggregate's
**client-visible state** (what snapshots and views show) from the server's
**query indexes** (session lists, search). The shared fold defines only the
former. Two server shapes make it work:
- persist the event log as truth and compute snapshot responses by running
the shared fold (cached / checkpointed every N events), or
- persist the fold *output* transactionally with each event append and serve
snapshots from it.
Either way SQL tables become **derived indexes computed from fold output** —
free to take any shape, unable to disagree with what clients render.
Convergence stops being a law and becomes a construction. Near-term bridge:
the fold/projector equivalence test against the real embedded server (replay
recorded event streams, assert projected snapshot ≡ client fold).
### 2. Quark as the reactive layer
`~/code/open-source/quark` — explicit identity/equivalence reactivity:
values are immutable snapshots by law, `Keyed` collections split structure
from value publication, computeds cut off on reference equality and receive
their previous value, `Layout` compiles per-field diff bitmasks. Solid
adapter (`useValue`/`useSlot`/`KeyedFor`) plus an experimental direct
OpenTUI JSX runtime (`quark-opentui-jsx`).
The fit is exact: the engine's `SessionView` *is* Quark's input contract —
immutable, keyed by message ID, unchanged parts reference-stable. With
`Keyed.set(view.messages)` per publish, the entire adapter apparatus
(clone boundary, identity diff, `StoreSessionView`) disappears, because
nothing downstream mutates stored values — the one Solid behavior
(`reconcile` mutating in place) that forced it all.
Path: engine → Quark `Keyed` → Solid adapter inside the existing TUI
(incremental), with the OpenTUI JSX runtime as the eventual Solid-free
endgame. Caveats: month-old private prototype, flat keyed collections only
(fine for transcripts), needs productionizing.
### 3. Multiplexed transport
One WebSocket / RPC stream carrying `subscribe { aggregate, after }` frames
instead of one SSE per open session — per-aggregate cursors over a single
connection, ambient events become just another subscription (subsumes S4).
Only the transport implementation changes.
### 4. Smaller notes
- durable outbox (SQLite/IndexedDB spool) for offline-safe writes
- typed overlay part addresses instead of string keys
- windowed views: bounded recent fold window + paged history
+352
View File
@@ -0,0 +1,352 @@
# Sync Engine Plan (draft)
Redesign of client state management: consume the event-sourced server that
already exists. Goal: **less code, more reliable** — optimism, reconnect, and
retry become structural guarantees instead of defended special cases.
Status: outline for discussion. Sections marked `TODO` get filled in as we talk.
---
## 0. Context ledger (receipts — do not lose these)
Facts established by code audit and prototyping, with locations.
### The server is already event-sourced
- Durable per-session event log in SQLite: `event(aggregate_id, seq, type, data)`
+ `event_sequence` counter, unique index `(aggregate_id, seq)`
(`packages/core/src/event/sql.ts`, migration `20260323234822_events`).
- Event append, projections, and `commit` hooks all run in **one SQLite
transaction** (`packages/core/src/bus.ts:249-370`, `behavior: "immediate"`).
- 40 of 46 session event types are durable; the 6 ephemeral are streaming
deltas/progress/usage ticks (`packages/schema/src/session-event.ts:626`).
Durable events carry complete facts (`text.ended` has full text).
- All hydration tables are written **only** by projectors inside the append
transaction: `SessionMessageTable`, `SessionTable``session/projector.ts`;
`SessionInboxTable``inbox.ts` `project*` fns called only from
`projector.ts:521,555,571,577`. `SessionPendingTable` is dead (zero writers).
- Message rows carry the aggregate seq of their creating event
(`projector.ts:386`), and `Session.messages` already paginates by it
(`session.ts:501`).
- Replay+live log endpoint exists:
`GET /api/experimental/session/:id/log?after=seq&follow=true`
(`packages/protocol/src/groups/session.ts:631`). Only devtools consumes it.
- Idempotent admission ~exists: `SessionInbox.admit` (`inbox.ts:137`) adopts an
existing client-supplied `SessionMessage.ID`, re-checks after conflict
defects. Gap: adopts without comparing payloads (no conflict on mismatch).
- Reverts are committed server-side inside `Session.prompt`
(`core/src/session.ts:567` at audit time) — client-side revert.commit is
already redundant.
### The client ignores all of it
- The client data layer is `packages/client/src/solid/data.ts` (~1,530 lines;
`packages/tui/src/context/data.tsx` is now a 22-line wrapper) — shared by
TUI/desktop/web, so the engine lands once for all clients. Three racing
write paths into one Solid store: a ~60-case bus-event switch, fetch
handlers doing wholesale `reconcile()`, and (in PR #42807) an optimistic
ledger both must consult. `sync.complete(key)` markers dedupe fetches but
are not watermarks.
- Opening a session today = four separate fetches: `session.get`,
`session.list({parentID})` (children), `session.inbox.list`, messages
(`data.ts:1084-1140`) — four sync keys, no shared consistent point.
- Reconnect = `sync.reset()` + refetch everything; no gap proof.
### Prior art
- PRs #42807 (optimistic message send) and #42808 (optimistic session create):
the stopgap. They landed client-generated IDs (keep) and ~15 hand-placed
guard sites defending the optimistic ledger (delete when this plan ships).
- Prototype: `~/code/open-source/sync-proto` — Effect v4 + Solid. Engine core
~200 lines. Six laws as passing tests; seeded 2-client chaos sim (lost
requests, lost responses after durable write, rejections, connection cuts,
latency shifts) with convergence / ordering / uniqueness / no-flicker
invariants; sim caught a real protocol bug (validation-before-adoption) on
first run. Two-pane demo (await vs optimistic) with narrated scenarios.
- Proposal doc: `~/code/open-source/opencode-sync-proposal/PROPOSAL.md`
("Intents, Not Mutations").
### Vocabulary
- **fold** — pure reduction of durable events into state; same meaning on both
sides. **intent** — a user action not yet acknowledged, client ID, in the
**outbox**. **watermark** — the aggregate seq a snapshot reflects.
**echo determinism** — an intent renders byte-identical to the row its
admission event folds to, so acks are invisible.
- The six laws: idempotency, echo determinism, sync opacity, ordering,
convergence, failure atomicity.
### Ordering constraint (the one subtle thing)
Watermark reads must share a transaction with row reads:
`seq`-then-rows double-applies accumulators; rows-then-`seq` loses events.
---
## 1. Problem
TODO — expand together. Sketch:
- Optimism, reconnect, and retry are each defended point-by-point instead of
guaranteed structurally; every new sync path re-fights the same races.
- The client maintains a hand-written mutable mirror of state the server can
already replay deterministically.
## 2. The claim: less code, more reliable
TODO — expand together. Sketch:
- Fold cases ≈ today's event switch (that code stays, becomes pure).
- Deleted: optimistic ledger + guards, echo replace-in-place, sync-survival
merges, `submitTails` throttling, `sync.complete` session keys,
refetch-everything reconnect.
- Added: engine core (~200 lines proven in prototype) + watermark plumbing.
- Reliability from structure: the six laws hold by construction, not vigilance.
## 3. Design
```
state = fold(snapshot, durable events) server truth only
outbox = ordered pending intents client IDs, idempotent, serial resend
overlay = live ephemeral fragments deltas/progress, superseded by durable facts
view = render(state ⊕ outbox ⊕ overlay) derived
```
The entire client surface for one session — two calls, one stream:
```
GET /session/:id/snapshot fetch → { session, children, inbox,
messages: last(N), seq }
GET /session/:id/log?after=seq&follow=true one ordered stream:
replay: durable events seq+1..head (deltas are live-only, absent here)
marker: log.synced "caught up" — first-class engine signal
live: durable + ephemeral session events interleaved, publish order
```
- **Decided:** the log endpoint's follow phase carries ephemeral session
events too (widen its union from `Durable | Synced`). One server-merged
stream per session; the client never merges two event feeds for one
aggregate, so a delta can never precede its own `Started`.
- **Overlay semantics:** `Map<(messageID, ordinal), accumulated>` — never
enters the fold. The durable full-value boundary (`text.ended`, carries
complete text) supersedes and clears the overlay entry in the same atomic
update that folds it — same no-seam trick as an intent leaving the outbox
on ack. Dropped deltas are self-healing by construction. Same mechanism for
all 6 ephemeral types (`tool.progress`/`usage.updated` superseded by
`tool.success|failed`/`step.ended`).
- **Decided (mid-stream reconnect):** accept today's behavior — after
re-hydrate, overlay accumulates from now, so in-flight text may render with
a missing prefix until its `Ended` supersedes. Simplest code, self-healing,
no protocol change. (Optional later polish: if `Started` wasn't observed
live, show a streaming indicator instead of partial text.)
- Overlay entries render only once their base part exists in folded state
(covers the one degenerate case: a live delta arriving during the replay
window).
- **Outbox ack contract: the echo is the only ack.** Intents carry a
client-minted message ID. The HTTP 2xx is a latency hint, never trusted;
the intent leaves the outbox only when the durable event carrying its ID
folds in (same atomic store update — no flicker), or on a typed rejection
(drop + surface). Timeout/disconnect = unknown fate = intent just stays.
On reconnect: tail first, wait for `log.synced` (replay acks anything that
landed), then resend unacked serially — adopt-on-same-ID (`inbox.ts:137`)
makes resend duplicate-free. Lost request and lost response collapse into
the same case ("no echo yet"), which is why no guard machinery survives.
- **Contract: the snapshot is the only entry point.** The engine never replays
a session from genesis; fold's domain is "snapshot + events after its seq."
Forced by `session.forked` — the event carries only `{ parentID, boundary }`
and the projector copies parent rows, which no client fold could reproduce.
Bonus: licenses future log truncation (serve back to the oldest live
snapshot, not seq 0).
- Compaction/revert/fork all fold cleanly under that contract: compaction only
adds (summary message + epoch marker); revert folds exactly what the
projector does (drop messages/inbox at-or-after boundary, clear marker —
`projector.ts:660-690`); fork needs no fold case at all (a forked session's
snapshot already contains the copied messages).
- Reconnect: tail from last seq first; on typed seq-unavailable → snapshot →
resend unacknowledged → tail. Gapless by construction.
- Scope boundary: session aggregate only. Ambient state (catalog, agents,
projects, vcs, worktrees) stays on the existing global bus as-is.
- Render target: existing Solid store shape, so components don't change.
## 4. Server changes (small)
- S1: watermarked hydration — **decided (tentative, pending prior-art review):**
one new endpoint, `GET /api/session/:id/snapshot`, one read transaction:
```
GET /api/session/:id/snapshot?recent=N
→ { session, children, inbox, messages: last(N), seq }
```
`recent` defaults to 200 — today's first page exactly (`data.ts:1136`,
`limit: 200`). Tuning it smaller later is a parameter change, not a design
change.
- Named `snapshot`, not `state`: point-in-time consistency is the contract,
and `state` collides with the engine's `state = fold(...)`.
- One transaction → one watermark. Rejected alternative: adding `seq` to
each existing read — three watermarks force per-slice replay guards in
the engine, and the session row's accumulators (usage sums) double-count
on overlap replay. Server savings reappear as engine complexity, worse.
- `children` are info rows only; child sessions are separate aggregates and
hydrate their own messages/inbox when opened.
- Older history stays on the existing seq-cursored `messages` endpoint,
folded in as inert backfill (IDs dedupe; revert/compaction arrive on the
log).
- Prior art for snapshot+watermark+tail: Discord (`seq`/RESUME), Telegram
(`pts`/`getDifference`), Linear (`lastSyncId`), Postgres logical
replication (exported snapshot + LSN), Kafka/EventStore catch-up
subscriptions, Replicache (cookie — whose launch checklist requires the
watermark "read in the same transaction as the client view data").
Slack's deprecated `rtm.start` is the cautionary tale for unbounded
snapshots (their edge cache cut boot payloads 744×; 42-message pages).
Details: `notes/hydration-prior-art.md`.
- Adopted from prior art:
- `log?after=seq` returns a typed **seq-unavailable error** when the seq
is not servable → client full-rehydrates. Never silently clamp to the
oldest retained event (Discord op 9 / Telegram `differenceTooLong` /
Postgres explicitly warns against clamping).
- **Cursor honesty (S1 follow-up, landed):** `events.persist` defaults to
off (only workerd enables it), so `event` payload rows usually don't
exist even though `event_sequence` always advances. Old behavior:
`after < head` "replayed" nothing from the empty table and marked
synced — silent desync. Now `openLog` proves retained rows cover
`(after, head]` (`Bus.retainedCount`) and returns seq-unavailable
otherwise; the engine re-snapshots. A cursor is a resumption claim and
gets the strict check; a cursorless read remains "what's retained".
Client side, the engine also treats a `log.synced` marker past its
folded seq as a gap → snapshot recovery, closing the residual
in-process race between the head check and the live subscription.
Persistence therefore stays optional: snapshot + live tail + honest
refusal is a complete contract. A short retention window (persist on +
pruning sweep) can drop in later behind the same check to make busy
reconnects replay instead of refetch.
- Resume-first discipline: reconnect tries the tail from the last seq
before re-snapshotting (Discord meters full rehydrates, not resumes).
- Keep the existing `log.synced` caught-up marker as a first-class engine
signal (EventStoreDB added `CaughtUp` after pain; suppress notifications
during replay).
- ~~S2: adopt strictness~~ — dropped. The engine only needs adopt-on-same-ID,
which exists (`inbox.ts:137` + AGENTS.md reconciliation rules). Conflict on
same-ID/different-payload defends against a client bug we never create.
- S3: widen `session.log` follow phase to interleave ephemeral session events,
**opt-in via `?ephemeral=true`** — default stays durable-only, so existing
log consumers (devtools) are untouched. The response union widens either way
(extra variants simply never appear unless requested); replay is durable-only
in both modes since ephemeral events aren't retained. Also add the typed
seq-unavailable error. Later: promote out of experimental.
- S4: filter param on `event.subscribe` (`/api/event` today sends everything
to everyone and fails slow consumers by contract). The ambient stream slims
to: non-session events + session-lifecycle events needed by the session
list for unopened sessions (created/deleted/renamed/moved, usage, execution
status). Exact param shape TBD.
## 5. Delivery: parallel build in this worktree, compare locally
Not a staged production migration — this branch carries a complete alternate
data layer next to the existing one, so both versions can be run and compared
before any adoption decision.
1. Server additions S1S4 (additive; existing clients unaffected).
2. Engine as a new module in `packages/client` (`solid/engine.ts` or similar)
beside `solid/data.ts` — same store shape and consumer-facing API, fed by
snapshot + session stream + outbox instead of fetch + bus + ledger.
`data.ts` is not modified.
3. TUI in this worktree wired to the engine layer.
4. Compare: run this worktree's full stack (`bun run dev`) against stock v2
side by side — submit latency, kill/reconnect behavior, streaming, revert,
forks. Port the six-laws + chaos tests as the regression net.
5. If it wins: adoption (swap the wiring, delete `data.ts` + PR #42807/#42808
guards) is its own decision with two working implementations to diff.
Deletion inventory at adoption: `data.ts` (~1,530 lines — the 64-case bus
switch becomes the pure fold; 4-fetch session open → one snapshot; all
refetch-on-reconnect machinery → nothing), PRs #42807/#42808 never merge
(stopgap ledger avoided entirely), TUI `util/single-flight.ts` + call
sites (submit is idempotent by minted ID), per-callsite await-then-toast
action plumbing → one generic outbox-rejection path. Rendering components
unchanged by design (same store shape).
### Commit-by-commit
Status: Lane A done (`0af27771` snapshot core, `d611b39f` endpoint,
`b21a3c7d` typed seq-unavailable, `74929dd7` ephemeral opt-in, `e6eac3a0`
simplify — S4 pending shape decision). Lane B done through validation +
simplify (`54a2ec54` fold, `0f8831b3` engine, `2c29a743` laws, `354ab6f9`
chaos, `c9ec1385` simplify). Wiring done: `5d70561f` engine-backed data
layer, `48e237d9` TUI wiring, `90760fc6`/`5f63f252` regression fixes (5 of 6
initial TUI failures were real engine-layer gaps — reconnect refresh, queued
lifecycle, pending refresh, synced-gated sync, family-index pollution — all
fixed; 1 obsolete assertion updated with justification). Suites: TUI 729/0,
client green except 3 pre-existing generated-client failures (repro on stock
v2). Remaining: live comparison pass (B7), S4 shape decision.
Two lanes, disjoint packages, parallelizable. Worker sessions execute; the
coordinating session reviews each commit and keeps this doc current. Every
commit typechecks and passes its package tests before landing.
**Lane A — server** (`packages/core`, `packages/protocol`, generated client):
- **Snapshot** (S1)
- `feat(core): Session.snapshot` — service method, one read transaction:
`{ session, children, inbox, messages: last(recent), seq }`. Core tests:
seq consistency under concurrent publish (a write landing between the
transaction's reads must be impossible), empty session, recent windowing.
- `feat(protocol): session.snapshot endpoint` — route + handler + `bun run
generate` from packages/client.
- **Session stream** (S3)
- `feat(core): typed seq-unavailable on session.log` — `after > head` (or
below retention, future) fails typed instead of silently serving. Test
the `after == head` boundary explicitly (Zero's fence-post bug).
- `feat(core): session.log follow includes ephemeral events` — opt-in
`?ephemeral=true` (default durable-only, existing consumers untouched);
widen union (`Durable | Synced` → session events + `Synced`), interleave
live ephemeral session events in publish order + generate. Test: delta
never precedes its Started on one stream; replay phase stays durable-only
in both modes; default mode carries no ephemeral events.
- **Ambient filter** (S4, can trail; nothing in Lane B blocks on it)
- `feat(protocol): event.subscribe filter` — ambient scope.
**Lane B — engine** (`packages/client/src/solid`, `packages/client/test`):
- **Fold**
- `feat(client): session fold` — pure module: durable session events →
session state (messages, inbox, markers). No transport, no store. The
~60-case switch in `data.ts` is the reference for event semantics.
- **Engine core**
- `feat(client): engine core` — outbox (intents, serial resend, ack-on-
fold-in-same-update), overlay (ephemeral fragments, superseded-and-
cleared by durable facts), view derivation, reconnect loop against a
transport interface.
- **Validation**
- `test(client): six laws` — idempotency, echo determinism, sync opacity,
ordering, convergence, failure atomicity — fake in-process transport,
TestClock, no server.
- `test(client): seeded chaos sim` — two clients, lost requests/responses,
rejections, cuts, latency shifts; convergence + no-flicker invariants.
- **Wiring** (waits on Snapshot's generated client)
- `feat(client): engine-backed data layer` — `data.ts`-compatible surface
over the engine, real transport.
- `feat(tui): wire TUI to engine layer` — this worktree only.
- Verification pass: termctrl live comparison vs stock v2; record demo.
Order: Lane A (Snapshot → Session stream → Ambient filter) and Lane B
(Fold → Engine core → Validation) run in parallel; Wiring waits on Snapshot,
then the verification pass waits on Session stream + Wiring.
## 6. Validation
- Port sync-proto's six laws + seeded chaos sim to run against the real engine
with a faked transport. TODO: where these tests live, CI story.
- Explicit boundary test at `seq == snapshot.seq` (empty tail vs purged log) —
Zero shipped a fence-post bug at exactly this boundary (rocicorp/mono#5589).
## 7. Open questions (to resolve together)
- Web app / desktop adoption order after TUI.
- S4 filter shape: type list vs a named "ambient" scope.
## 8. Non-goals
- No server storage migration; no new event types; no runner changes.
- No component/rendering changes; no changes to ambient (non-session) state.
+61 -51
View File
@@ -28,7 +28,6 @@
"semver": "^7.6.0",
"sst": "catalog:",
"turbo": "2.10.2",
"vitest": "4.1.10",
},
},
"packages/ai": {
@@ -63,7 +62,6 @@
"@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -121,6 +119,7 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "1.2.1",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -591,9 +590,9 @@
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.5.3",
"@opentui/keymap": ">=0.5.3",
"@opentui/solid": ">=0.5.3",
"@opentui/core": ">=0.5.4",
"@opentui/keymap": ">=0.5.4",
"@opentui/solid": ">=0.5.4",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
@@ -686,9 +685,8 @@
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "file:../app/vendor/opencode-ai-sdk-1.18.8-dev.tgz",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@pierre/diffs": "catalog:",
"@shikijs/stream": "catalog:",
"@solid-primitives/event-listener": "2.4.5",
@@ -1090,9 +1088,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.5.3",
"@opentui/keymap": "0.5.3",
"@opentui/solid": "0.5.3",
"@opentui/core": "0.5.4",
"@opentui/keymap": "0.5.4",
"@opentui/solid": "0.5.4",
"@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -2114,27 +2112,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@opentui/core": ["@opentui/core@0.5.3", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.3", "@opentui/core-darwin-x64": "0.5.3", "@opentui/core-linux-arm64": "0.5.3", "@opentui/core-linux-arm64-musl": "0.5.3", "@opentui/core-linux-x64": "0.5.3", "@opentui/core-linux-x64-musl": "0.5.3", "@opentui/core-win32-arm64": "0.5.3", "@opentui/core-win32-x64": "0.5.3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-K8EQu44cx0rhnn3v3baCQW18Bpci3GltZayOwVpGGsbiAGL1WUYqwQjuaWsmS0c4dCa9rQ5xCEoHB1C4936nDg=="],
"@opentui/core": ["@opentui/core@0.5.4", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.4", "@opentui/core-darwin-x64": "0.5.4", "@opentui/core-linux-arm64": "0.5.4", "@opentui/core-linux-arm64-musl": "0.5.4", "@opentui/core-linux-x64": "0.5.4", "@opentui/core-linux-x64-musl": "0.5.4", "@opentui/core-win32-arm64": "0.5.4", "@opentui/core-win32-x64": "0.5.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-czcJKQ72QhTWvu1eWfKg4EPN1GLLND6cP9MOhDyqYzCdIZgxQSJVWYzz6c4/CQGOu/qQLRyce2y1efVu4lgQ0w=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-R39YeUqaMb/rH1h6G4MkB4MLVKIrRaUaXLfVqorZM4xgU5BxnfPetRk1vWR9vuLCvDwskg+kQ589kULw0o6AWA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oETbn6tg/0g+mOvGXy3iot+1Zv7CGr65U1lRaPJ7kpsKGnOxftCpDD1qA0I6eQwfPJa9k7h9D/9yGB3IbweGcg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-1pmUas/chTVFGeiN19kaOx+5Xbte/DLhcgKyACwWO0M3+xE3z1v/6QGSyX6CoP5HBpmDroiX+JHv1ic/JlGd/g=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-TIeqCNfAV8xvNAv6oVBYsoGBz/p8CxcYm668OQIeBPUO+irqbQ72vJJa/SwFZrUEAHFmsDELaB6y80oHU5Jm6g=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nMo9Q9VIaQVdw2SNKlwIEWMmf3z+cI4jRdCkh36e2RU1FO7LrIBAEmV1ZuRp1CIFVGPkqCXIizCeckZHTr4yQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-UrOsX3D5BOO9TI30WvRwEK/lyPPhY75+LwSqeRRe8SV2ON+Ez5QqY4oryTyYC6+yNahTJufz34n0YgwnQaxTNA=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-QOYAxbbWrhYo27Cd6m0ATzpEx9YCAKAq82LgfUn0xu+VKXLNu+Q3hMNSVbG0SepUQQZil5rz228R9o9Cs8995w=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-8vFWd1dsPZj9fHQKlsGHue3ukp7MRjTSpmIrdneIruOcoK2etccHfd6bqIo4FcNr+HA0eI2FP4ZIL9xhE3r9/w=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-hdAYLriLpTj3lvpMyL25GPBzvM2w/n2KCSbIwTmgS2F/dPZYCKJxHEETj2lCvtStSp7KuY8tkg3Xl5RAq1v7gA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-1RXzSl6d347O7mUXviXFWlFFyILr7qsVt4jwD3k0UiKtENeEDNi+glM1bKHbXwCdQjfA835QgFA2mbILybK+aQ=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-BkVIiPQ1TOf5/FfmIpf7DQU5rT/FO6ASW5R/o/wonI5Pdul7XiDCu86gzGyk1x5k9Sbh6GLeq1fe8/tPmI7IaA=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-YYREqUB3v5K0qWij3A5YWzJkkVXp/EqIiuHZKsAjrUAY/0+Bp8Hn0baLvvvkuklpG9whW+GfwIeUsmp4fxqmCA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-AjObTyZPU0xsK3Yk8GmhkboK6OcMoHBbydqYAybeHD4+v6axScSuZ3OEI9J05JJ9T7H2nZNky75tsdnjsvZJmg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-F9sB6suPJmwkLF2MwKEC+OtwP6cn5QspIm4MCh2hmu3mVqSz9GDpYID1X3sAh9/V79EVocqiOSqI3KeCTRouKQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-e3nRlF2nSkLKCUPBF32OL9EDgtQDIh2pBo7tjhumpTyJ3qoNOa3us7DsM290Vw4xnakM6jpk9r9NzRf72CuMVg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-2/6dPPPJ9xL/bWz9jh+lZeV2g/tbxZ9N4FBIqwhnqSfIYy5jOnscsPZpcN4X6PstfJAo2yf0Ebk/tLTOzOS6TQ=="],
"@opentui/keymap": ["@opentui/keymap@0.5.3", "", { "dependencies": { "@opentui/core": "0.5.3" }, "peerDependencies": { "@opentui/react": "0.5.3", "@opentui/solid": "0.5.3", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-VdvUabUDWnmgolzp1qiO55wz1zv0mMu5Z6tv+UQk9JqZRfKt5Q8XTDi0uQzhBb6qeLsHvXtRFOsnn6EFAcLixA=="],
"@opentui/keymap": ["@opentui/keymap@0.5.4", "", { "dependencies": { "@opentui/core": "0.5.4" }, "peerDependencies": { "@opentui/react": "0.5.4", "@opentui/solid": "0.5.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-WAB6EH6C7SicmK/ycvx+mUu00sQ755gToZqBx8vgLhS7qZ07s/WLi88Ys0D6AA0i8lOiSIPA4uqei1jkV8UnBQ=="],
"@opentui/solid": ["@opentui/solid@0.5.3", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.3", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-IB6YC3ajucQeo06BMSNYRD7LHf8ndm6G5ETcYsS+51Mli5yW0ioyUr+6+qgqVtaQyOcJvaD1KWmK23pXLhsiwg=="],
"@opentui/solid": ["@opentui/solid@0.5.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-1injDvieTyYXD3RLC2EclaJK8XZ2+qyJ7wGeQPSzpcYEEcanb7G79KVWFlN7YUzqVAhTIictZDoQ6SRvIwL4Ww=="],
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
@@ -3232,7 +3230,7 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="],
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="],
@@ -3242,7 +3240,7 @@
"@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="],
"@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="],
"@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
"@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="],
@@ -3544,7 +3542,7 @@
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"chainsaw": ["chainsaw@0.1.0", "", { "dependencies": { "traverse": ">=0.3.0 <0.4" } }, "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ=="],
@@ -3948,7 +3946,7 @@
"es-get-iterator": ["es-get-iterator@1.1.3", "", { "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.3", "has-symbols": "^1.0.3", "is-arguments": "^1.1.1", "is-map": "^2.0.2", "is-set": "^2.0.2", "is-string": "^1.0.7", "isarray": "^2.0.5", "stop-iteration-iterator": "^1.0.0" } }, "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw=="],
"es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
@@ -5620,7 +5618,7 @@
"tinyclip": ["tinyclip@0.1.15", "", {}, "sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A=="],
"tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
@@ -6054,6 +6052,8 @@
"@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.42", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Q07lZsq4ir+xwAGVfSbY2WNGLrbfWINFaL2I/vTBFrkuXeP7VZh+UtQgLOKZS6Z/6flNFW22jDYdbINvwTV/PA=="],
"@antfu/install-pkg/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"@astrojs/cloudflare/vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
"@astrojs/cloudflare/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="],
@@ -6070,8 +6070,6 @@
"@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="],
"@astrojs/mdx/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"@astrojs/node/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.10.2", "", { "dependencies": { "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "js-yaml": "^4.3.0", "picomatch": "^4.0.4", "retext-smartypants": "^6.2.0", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "unified": "^11.0.5" } }, "sha512-yt7fMgPYqSM4Tmr+taTW6Per+hjJ8Pk6lA1PAcDyqzOt8HzJ6Kje5WzCxA2Sd+9wsUW7uhkLeoTMK0cXPwH9rQ=="],
@@ -6370,8 +6368,6 @@
"@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
"@opencode-ai/session-ui/@opencode-ai/sdk": ["@opencode-ai/sdk@../app/vendor/opencode-ai-sdk-1.18.8-dev.tgz", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-C2nfk4x0sPINwE5V6DPkFSuH3PkUmKPWHPzxpXC1j+3Ui5hslLCWJbkk8WcOG1Lyt3C0+yp4ea64v/kmtYCO4w=="],
"@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
"@opencode-ai/storybook/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="],
@@ -6470,8 +6466,6 @@
"@slack/web-api/p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
"@solidjs/start/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"@solidjs/start/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
"@solidjs/start/shiki": ["shiki@1.29.2", "", { "dependencies": { "@shikijs/core": "1.29.2", "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/langs": "1.29.2", "@shikijs/themes": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg=="],
@@ -6542,6 +6536,12 @@
"@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"@vitest/mocker/@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="],
"@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="],
"ai/@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.127", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.31", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SiD2kyR2J4fEY0FTXYpNznwWN90ohrwzAMi2laZCGkI/lL8DmyJ31zFzMYJvzRusp2IQYmvkYQWOpZpBl01xYw=="],
@@ -6580,14 +6580,10 @@
"astro/diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="],
"astro/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"astro/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
"astro/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="],
"astro/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
"astro/vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
@@ -6878,10 +6874,6 @@
"sst/jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="],
"storybook/@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"storybook/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
"storybook/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
@@ -6932,11 +6924,15 @@
"venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="],
"vite-plugin-dynamic-import/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
"vite-plugin-icons-spritesheet/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"vitest/@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="],
"vitest/@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="],
"vitest/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
"vitest/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"vitest/vite": ["vite@8.2.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="],
@@ -7028,6 +7024,8 @@
"@astrojs/node/astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"@astrojs/node/astro/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
"@astrojs/node/astro/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"@astrojs/node/astro/fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="],
@@ -7046,6 +7044,8 @@
"@astrojs/node/astro/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
"@astrojs/node/astro/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"@astrojs/node/astro/unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="],
"@astrojs/node/astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
@@ -7082,6 +7082,8 @@
"@astrojs/vercel/astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"@astrojs/vercel/astro/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
"@astrojs/vercel/astro/fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="],
"@astrojs/vercel/astro/get-tsconfig": ["get-tsconfig@5.0.0-beta.4", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ=="],
@@ -7098,6 +7100,8 @@
"@astrojs/vercel/astro/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
"@astrojs/vercel/astro/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"@astrojs/vercel/astro/unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="],
"@astrojs/vercel/astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
@@ -7456,6 +7460,8 @@
"@opencode-ai/www/astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"@opencode-ai/www/astro/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
"@opencode-ai/www/astro/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"@opencode-ai/www/astro/fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="],
@@ -7474,6 +7480,8 @@
"@opencode-ai/www/astro/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
"@opencode-ai/www/astro/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"@opencode-ai/www/astro/unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="],
"@opencode-ai/www/astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
@@ -7582,6 +7590,8 @@
"@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
@@ -7646,6 +7656,8 @@
"blume/@astrojs/mdx/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"blume/@astrojs/mdx/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
"blume/@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"blume/@clack/prompts/@clack/core": ["@clack/core@1.4.3", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ=="],
@@ -7676,6 +7688,8 @@
"blume/astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"blume/astro/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
"blume/astro/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"blume/astro/fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="],
@@ -7688,6 +7702,8 @@
"blume/astro/p-queue": ["p-queue@9.3.3", "", { "dependencies": { "eventemitter3": "^5.0.4", "p-timeout": "^7.0.0" } }, "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA=="],
"blume/astro/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"blume/astro/unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="],
"blume/astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
@@ -7824,12 +7840,6 @@
"rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"storybook/@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"storybook/@vitest/expect/chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"storybook/@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"storybook/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"storybook/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
@@ -7902,6 +7912,8 @@
"venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.42", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Q07lZsq4ir+xwAGVfSbY2WNGLrbfWINFaL2I/vTBFrkuXeP7VZh+UtQgLOKZS6Z/6flNFW22jDYdbINvwTV/PA=="],
"vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"vitest/vite/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"vitest/vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
@@ -8784,8 +8796,6 @@
"rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"storybook/@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"temp/rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"tw-to-css/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
+4 -5
View File
@@ -47,9 +47,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.3",
"@opentui/keymap": "0.5.3",
"@opentui/solid": "0.5.3",
"@opentui/core": "0.5.4",
"@opentui/keymap": "0.5.4",
"@opentui/solid": "0.5.4",
"@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0",
@@ -121,8 +121,7 @@
"prettier": "3.6.2",
"semver": "^7.6.0",
"sst": "catalog:",
"turbo": "2.10.2",
"vitest": "4.1.10"
"turbo": "2.10.2"
},
"dependencies": {
"@aws-sdk/client-s3": "3.933.0",
@@ -437,10 +437,19 @@ const lowerToolResultContent = Effect.fnUntraced(function* (part: ToolResultPart
return yield* Effect.forEach(content, lowerToolResultContentItem)
})
// Mid-conversation system messages are a native Claude API feature only for
// Opus 4.8. Other Anthropic models intentionally use the same visible wrapped-
// user fallback as non-Anthropic routes rather than sending a role they reject.
const supportsNativeSystemUpdates = (request: LLMRequest) => String(request.model.id) === "claude-opus-4-8"
// Mid-conversation system messages became available with Opus 4.8 and version
// 5 of the other supported Claude families. Treat later family versions as
// compatible without assuming that every Anthropic Messages model is Claude.
const supportsNativeSystemUpdates = (request: LLMRequest) => {
const match = /(?:^|[./])claude-(fable|haiku|mythos|opus|sonnet)-(\d+)(?:[.-](\d+))?/.exec(
String(request.model.id).toLowerCase(),
)
if (!match) return false
const major = Number(match[2])
if (match[1] !== "opus") return major >= 5
if (major !== 4) return major >= 5
return match[3] !== undefined && match[3].length <= 2 && Number(match[3]) >= 8
}
const endsInServerToolUse = (message: LLMRequest["messages"][number]) => {
const last = message.content.at(-1)
@@ -896,6 +905,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
if (delta?.type === "input_json_delta" && event.index !== undefined) {
if (!delta.partial_json) return [state, NO_EVENTS] satisfies StepResult
if (!state.tools[event.index]) return [state, NO_EVENTS] satisfies StepResult
const result = ToolStream.appendExisting(
ADAPTER,
state.tools,
@@ -957,9 +967,12 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult =
]
}
const onMessageStop = (state: ParserState): StepResult => {
const onMessageStop = Effect.fn("AnthropicMessages.onMessageStop")(function* (state: ParserState) {
const result = yield* ToolStream.finishAll(ADAPTER, state.tools)
const events: LLMEvent[] = []
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...result.events)
const finished = Lifecycle.finish(lifecycle, events, {
reason: state.pendingFinish?.reason ?? {
normalized: "unknown",
raw: undefined,
@@ -967,8 +980,8 @@ const onMessageStop = (state: ParserState): StepResult => {
usage: state.usage,
providerMetadata: state.pendingFinish?.providerMetadata,
})
return [{ ...state, lifecycle }, events]
}
return [{ ...state, lifecycle: finished, tools: result.tools }, events] satisfies StepResult
})
// Prefix `error.type` so overloads, rate limits, and quota errors are visible
// even when the provider message is generic or empty.
@@ -992,7 +1005,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
if (event.type === "message_stop") return Effect.succeed(onMessageStop(state))
if (event.type === "message_stop") return onMessageStop(state)
if (event.type === "error") return onError(event)
return Effect.succeed<StepResult>([state, NO_EVENTS])
}
@@ -136,6 +136,33 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("supports native chronological system updates on documented and later Claude family versions", () =>
Effect.gen(function* () {
const ids = [
"claude-opus-4-8",
"claude-opus-5-1",
"claude-sonnet-5",
"claude-haiku-5-1",
"claude-fable-6",
"anthropic/claude-mythos-7.2",
]
const prepared = yield* Effect.forEach(ids, (id) =>
compileRequest(
LLM.request({
model: AnthropicMessages.route
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
.model({ id }),
messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")],
cache: "none",
}),
),
)
expect(prepared.map((item) => item.body.messages[1]?.role)).toEqual(ids.map(() => "system"))
}),
)
it.effect("lowers chronological system updates to wrapped user text for unsupported Anthropic models", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -163,6 +190,34 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("does not infer native system update support for older or undocumented Claude families", () =>
Effect.gen(function* () {
const ids = [
"claude-opus-4-7",
"claude-opus-4-20250514",
"claude-sonnet-4-9",
"claude-haiku-4-9",
"custom-model-7",
]
const prepared = yield* Effect.forEach(ids, (id) =>
compileRequest(
LLM.request({
model: AnthropicMessages.route
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
.model({ id }),
messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")],
cache: "none",
}),
),
)
expect(prepared.map((item) => item.body.messages.some((message) => message.role === "system"))).toEqual(
ids.map(() => false),
)
}),
)
it.effect("rejects non-text chronological system update content before send", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
@@ -955,6 +1010,65 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("ignores tool input deltas without a matching tool start", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } },
{
type: "content_block_delta",
index: 1,
delta: { type: "input_json_delta", partial_json: '{"query":"orphaned"}' },
},
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
),
),
)
expect(response.text).toBe("Hello")
expect(response.toolCalls).toEqual([])
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
}),
)
it.effect("settles pending tool calls at message_stop", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "call_1", name: "lookup" },
},
{
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"query":"weather"}' },
},
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
),
),
)
expect(response.toolCalls).toMatchObject([
{ id: "call_1", name: "lookup", input: { query: "weather" } },
])
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "tool_use" })
}),
)
it.effect("assembles and persists multiple tool calls from one Anthropic response", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import type {
JsonValue,
OpenCodeEvent,
@@ -441,6 +441,15 @@ export function status(type: SessionStatus["type"], attempt = 1) {
})
}
export function stepStarted(message: SessionMessageAssistant) {
return makeEvent("session.step.started", {
sessionID,
assistantMessageID: message.id,
agent: message.agent,
model: message.model,
})
}
export function userMessage(
parts?: PartSeed<"user">[],
input: { id?: string; summary?: unknown; created?: number } = {},
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { Page } from "@playwright/test"
import { mockOpenCodeServer } from "../../utils/mock-server"
@@ -1,5 +1,5 @@
import type { Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { fixture, pageMessages } from "./session-timeline-stress.fixture"
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import { fixture } from "../timeline/session-timeline-stress.fixture"
import { stressSessionHref } from "../timeline/timeline-test-helpers"
@@ -1,5 +1,5 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import { currentSession } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
@@ -1,5 +1,5 @@
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import { expect, test, type Page, type Route } from "@playwright/test"
import { installSseTransport } from "../utils/sse-transport"
import { currentSession } from "../utils/mock-server"
@@ -1,5 +1,5 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import { currentSession } from "../utils/mock-server"
const serverA = "http://127.0.0.1:4096"
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import type { SessionMessageAssistant } from "@opencode-ai/client/promise"
import { expect, test, type Page } from "@playwright/test"
import {
@@ -9,6 +9,7 @@ import {
setupTimeline,
shell,
status,
stepStarted,
textPart,
userMessage,
} from "../performance/timeline-stability/fixture"
@@ -96,8 +97,10 @@ test("moves busy through retry and recovery to final idle content", async ({ pag
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
await timeline.send(status("retry"), 180)
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await timeline.send(status("busy", 2), 180)
await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await timeline.send(stepStarted(assistant), 180)
await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140)
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 100)
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
import { expect, test, type Page } from "@playwright/test"
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
@@ -1,5 +1,5 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import { currentSession } from "../utils/mock-server"
const server = "http://127.0.0.1:4096"
@@ -1,4 +1,4 @@
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { base64Encode, checksum } from "@opencode-ai/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import { fixture, pageMessages } from "./session-timeline.fixture"
import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
import { mockOpenCodeServer } from "../utils/mock-server"
+1 -1
View File
@@ -1,5 +1,5 @@
import { expect, type Locator, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
export const APP_READY_TIMEOUT = 30_000
-1
View File
@@ -56,7 +56,6 @@
"@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -1,4 +1,4 @@
import { getFilename } from "@opencode-ai/core/util/path"
import { getFilename } from "@opencode-ai/util/path"
import type { Project } from "@/types"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -1,4 +1,4 @@
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
@@ -277,8 +277,7 @@ function ProviderConnection(props: {
})
const provider = createMemo(() => ({
id: props.provider,
name:
providers.all().get(props.provider)?.name ?? controller.integration()?.name ?? props.provider,
name: providers.all().get(props.provider)?.name ?? controller.integration()?.name ?? props.provider,
}))
const methodLabel = (value?: { type?: string; label?: string }) => {
if (!value) return ""
+20 -11
View File
@@ -8,9 +8,8 @@ import { List } from "@opencode-ai/ui/list"
import { showToast } from "@/utils/toast"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { extractPromptFromParts } from "@/utils/prompt"
import { normalizeSessionMessages } from "@/utils/session-message"
import { base64Encode } from "@opencode-ai/util/encode"
import { extractPromptComments, extractPromptFromMessage } from "@/utils/prompt"
import { useWorkspaceLocation } from "@/context/location"
import { useServer } from "@/context/server"
import { sessionHref } from "@/utils/session-route"
@@ -61,13 +60,12 @@ export const DialogFork: Component = () => {
const sessionID = params.id
if (!sessionID) return
const restored = extractPromptFromParts(
normalizeSessionMessages(sessionID, data.session.message.list(sessionID)).parts.get(item.id) ?? [],
{
directory: location().directory,
attachmentName: language.t("common.attachment"),
},
)
const message = data.session.message.get(sessionID, item.id)
if (message?.type !== "user") return
const restored = extractPromptFromMessage(message, {
directory: location().directory,
attachmentName: language.t("common.attachment"),
})
const dir = base64Encode(location().directory)
serverSDK.api.session
@@ -75,7 +73,18 @@ export const DialogFork: Component = () => {
.then((forked) => {
data.session.remember(forked)
dialog.close()
prompt.set(restored, undefined, { dir, id: forked.id })
const target = prompt.capture({ dir, id: forked.id })
target.set(restored)
target.context.replaceComments(
extractPromptComments(message).map((comment) => ({
type: "file",
path: comment.path,
selection: comment.selection,
comment: comment.comment,
preview: comment.preview,
commentOrigin: comment.origin,
})),
)
navigate(sessionHref(server.key, forked.id))
})
.catch((err: unknown) => {
@@ -29,7 +29,7 @@ import {
} from "./directory-picker-domain"
import "./dialog-select-directory-v2.css"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { getFilename } from "@opencode-ai/core/util/path"
import { getFilename } from "@opencode-ai/util/path"
interface DialogSelectDirectoryV2Props {
title?: string
@@ -3,7 +3,7 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Keybind } from "@opencode-ai/ui/keybind"
import { List } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { createMemo, createSignal, lazy, Match, Show, Switch } from "solid-js"
import { formatKeybind } from "@/context/command"
import { useServerSDK } from "@/context/server-sdk"
@@ -50,7 +50,8 @@ export const DialogSelectMcp: Component = () => {
>
{(i) => {
const mcpStatus = () =>
data.location.mcp.server.list({ directory: sdk().directory })?.find((server) => server.name === i.name)?.status
data.location.mcp.server.list({ directory: sdk().directory })?.find((server) => server.name === i.name)
?.status
const status = () => mcpStatus()?.status
const statusLabel = () => {
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
@@ -82,7 +82,7 @@ function ServerForm(props: ServerFormProps) {
type="text"
label={language.t("dialog.server.add.name")}
placeholder={language.t("dialog.server.add.namePlaceholder")}
value={props.name}
defaultValue={props.name}
disabled={props.busy}
onChange={props.onNameChange}
onKeyDown={keyDown}
@@ -92,7 +92,7 @@ function ServerForm(props: ServerFormProps) {
type="text"
label={language.t("dialog.server.add.username")}
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
value={props.username}
defaultValue={props.username}
disabled={props.busy}
onChange={props.onUsernameChange}
onKeyDown={keyDown}
@@ -101,7 +101,7 @@ function ServerForm(props: ServerFormProps) {
type="password"
label={language.t("dialog.server.add.password")}
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
value={props.password}
defaultValue={props.password}
disabled={props.busy}
onChange={props.onPasswordChange}
onKeyDown={keyDown}
@@ -245,7 +245,7 @@ export function nativePickerPath(path: string) {
if (/^[A-Za-z]:\//.test(value) || value.startsWith("//")) return value.replaceAll("/", "\\")
return value
}
import { getFilename } from "@opencode-ai/core/util/path"
import { getFilename } from "@opencode-ai/util/path"
import fuzzysort from "fuzzysort"
import { ServerSDK } from "@/context/server-sdk"
+1 -1
View File
@@ -1,4 +1,4 @@
import { getFilename } from "@opencode-ai/core/util/path"
import { getFilename } from "@opencode-ai/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import { normalizeProjectInfo } from "@/context/global-sync/utils"
@@ -7,6 +7,7 @@ import { useMcpToggle } from "@/context/mcp"
import { useWorkspaceLocation } from "@/context/location"
import { useServerSDK } from "@/context/server-sdk"
import { useData } from "@/context/server"
import { pluginLabel } from "@/utils/plugin"
import { ExternalLink } from "./external-link"
type SkillItem = {
@@ -90,8 +91,8 @@ export const ProjectSettingsExtensions: Component = () => {
.sort()
})
const mcpEnabled = (name: string) =>
data.location.mcp.server.list({ directory: directorySDK().directory })?.find((server) => server.name === name)?.status
.status === "connected"
data.location.mcp.server.list({ directory: directorySDK().directory })?.find((server) => server.name === name)
?.status.status === "connected"
const [globalPluginList] = createResource(
() => serverSDK.connection.status() === "connected",
@@ -101,10 +102,10 @@ export const ProjectSettingsExtensions: Component = () => {
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
)
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map((item) => item.id))
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map(pluginLabel))
const projectPlugins = createMemo(() => {
const shared = new Set(globalPlugins())
return (projectPluginList.latest ?? []).map((item) => item.id).filter((name) => !shared.has(name))
return (projectPluginList.latest ?? []).map(pluginLabel).filter((name) => !shared.has(name))
})
const serverSkills = createMemo(() => data.location.skill.list() ?? [])
@@ -196,7 +197,6 @@ export const ProjectSettingsExtensions: Component = () => {
<SharedSection count={serverSkills().length}>{skillRows(serverSkills())}</SharedSection>
</div>
</TabsV2.Content>
</TabsV2>
</div>
)
@@ -1,4 +1,4 @@
import { getFilename } from "@opencode-ai/core/util/path"
import { getFilename } from "@opencode-ai/util/path"
import type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
@@ -173,7 +173,7 @@ beforeAll(async () => {
showToast: () => 0,
}))
mock.module("@opencode-ai/core/util/encode", () => ({
mock.module("@opencode-ai/util/encode", () => ({
base64Decode: (value: string) => value,
base64Encode: (value: string) => value,
checksum: (value: string) => value,
@@ -407,6 +407,12 @@ describe("prompt submit worktree selection", () => {
text: "ls",
files: [],
agents: [],
metadata: {
displayText: "ls",
comments: [],
agent: "agent",
model: { providerID: "provider", modelID: "model", variant: "high" },
},
})
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
})
@@ -1,6 +1,6 @@
import type { Data } from "@opencode-ai/client/solid"
import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { startTransition, type Accessor } from "solid-js"
import { useTabs } from "@/context/tabs"
@@ -12,7 +12,7 @@ import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt
import { useWorkspaceLocation } from "@/context/location"
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
import { Identifier } from "@/utils/id"
import { getDirectory } from "@opencode-ai/core/util/path"
import { getDirectory } from "@opencode-ai/util/path"
import { buildPromptRequest } from "./build-prompt-request"
import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors"
@@ -63,7 +63,10 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
const [head, ...tail] = text.split(" ")
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
if (cmd && input.data.location.command.list({ directory: input.draft.sessionDirectory })?.some((item) => item.name === cmd)) {
if (
cmd &&
input.data.location.command.list({ directory: input.draft.sessionDirectory })?.some((item) => item.name === cmd)
) {
setBusy()
try {
const messageID = Identifier.ascending("message")
@@ -135,6 +138,15 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
text: request.text,
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
agents: request.agents,
metadata: {
displayText: request.displayText,
comments: request.comments,
agent: input.draft.agent,
model: {
...input.draft.model,
...(input.draft.variant ? { variant: input.draft.variant } : {}),
},
},
})
return true
} catch (err) {
@@ -197,8 +209,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (!sessionID) return Promise.resolve()
input.onAbort?.()
return serverSDK.api.session.interrupt({ sessionID })
.catch(() => {})
return serverSDK.api.session.interrupt({ sessionID }).catch(() => {})
}
const restoreCommentItems = (
@@ -2,7 +2,7 @@ import { createMemo, createSignal, For, Show } from "solid-js"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { getFilename } from "@opencode-ai/core/util/path"
import { getFilename } from "@opencode-ai/util/path"
import { useLanguage } from "@/context/language"
import { sameDirectory } from "@/utils/workspace"
@@ -1,6 +1,6 @@
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { createStore } from "solid-js/store"
import { createSignal, For, Show, type ComponentProps, type JSX } from "solid-js"
import type { Project } from "@/types"
@@ -1,61 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part } from "@/types"
import { estimateSessionContextBreakdown } from "./session-context-breakdown"
const user = (id: string) => {
return {
id,
role: "user",
time: { created: 1 },
} as unknown as Message
}
const assistant = (id: string) => {
return {
id,
role: "assistant",
time: { created: 1 },
} as unknown as Message
}
describe("estimateSessionContextBreakdown", () => {
test("estimates tokens and keeps remaining tokens as other", () => {
const messages = [user("u1"), assistant("a1")]
const parts = {
u1: [{ type: "text", text: "hello world" }] as unknown as Part[],
a1: [{ type: "text", text: "assistant response" }] as unknown as Part[],
}
const output = estimateSessionContextBreakdown({
messages,
parts,
input: 20,
systemPrompt: "system prompt",
})
const map = Object.fromEntries(output.map((segment) => [segment.key, segment.tokens]))
expect(map.system).toBe(4)
expect(map.user).toBe(3)
expect(map.assistant).toBe(5)
expect(map.other).toBe(8)
})
test("scales segments when estimates exceed input", () => {
const messages = [user("u1"), assistant("a1")]
const parts = {
u1: [{ type: "text", text: "x".repeat(400) }] as unknown as Part[],
a1: [{ type: "text", text: "y".repeat(400) }] as unknown as Part[],
}
const output = estimateSessionContextBreakdown({
messages,
parts,
input: 10,
systemPrompt: "z".repeat(200),
})
const total = output.reduce((sum, segment) => sum + segment.tokens, 0)
expect(total).toBeLessThanOrEqual(10)
expect(output.every((segment) => segment.width <= 100)).toBeTrue()
})
})
@@ -1,132 +0,0 @@
import type { Message, Part } from "@/types"
export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other"
export type SessionContextBreakdownSegment = {
key: SessionContextBreakdownKey
tokens: number
width: number
percent: number
}
const estimateTokens = (chars: number) => Math.ceil(chars / 4)
const toPercent = (tokens: number, input: number) => (tokens / input) * 100
const toPercentLabel = (tokens: number, input: number) => Math.round(toPercent(tokens, input) * 10) / 10
const charsFromUserPart = (part: Part) => {
if (part.type === "text") return part.text.length
if (part.type === "file") return part.source?.text.value.length ?? 0
if (part.type === "agent") return part.source?.value.length ?? 0
return 0
}
const charsFromAssistantPart = (part: Part) => {
if (part.type === "text") return { assistant: part.text.length, tool: 0 }
if (part.type === "reasoning") return { assistant: part.text.length, tool: 0 }
if (part.type !== "tool") return { assistant: 0, tool: 0 }
const input = Object.keys(part.state.input).length * 16
if (part.state.status === "pending") return { assistant: 0, tool: input + part.state.raw.length }
if (part.state.status === "completed") return { assistant: 0, tool: input + part.state.output.length }
if (part.state.status === "error") return { assistant: 0, tool: input + part.state.error.length }
return { assistant: 0, tool: input }
}
const build = (
tokens: { system: number; user: number; assistant: number; tool: number; other: number },
input: number,
) => {
return [
{
key: "system",
tokens: tokens.system,
},
{
key: "user",
tokens: tokens.user,
},
{
key: "assistant",
tokens: tokens.assistant,
},
{
key: "tool",
tokens: tokens.tool,
},
{
key: "other",
tokens: tokens.other,
},
]
.filter((x) => x.tokens > 0)
.map((x) => ({
key: x.key,
tokens: x.tokens,
width: toPercent(x.tokens, input),
percent: toPercentLabel(x.tokens, input),
})) as SessionContextBreakdownSegment[]
}
export function estimateSessionContextBreakdown(args: {
messages: Message[]
parts: Record<string, Part[] | undefined>
input: number
systemPrompt?: string
}) {
if (!args.input) return []
const counts = args.messages.reduce(
(acc, msg) => {
const parts = args.parts[msg.id] ?? []
if (msg.role === "user") {
const user = parts.reduce((sum, part) => sum + charsFromUserPart(part), 0)
return { ...acc, user: acc.user + user }
}
if (msg.role !== "assistant") return acc
const assistant = parts.reduce(
(sum, part) => {
const next = charsFromAssistantPart(part)
return {
assistant: sum.assistant + next.assistant,
tool: sum.tool + next.tool,
}
},
{ assistant: 0, tool: 0 },
)
return {
...acc,
assistant: acc.assistant + assistant.assistant,
tool: acc.tool + assistant.tool,
}
},
{
system: args.systemPrompt?.length ?? 0,
user: 0,
assistant: 0,
tool: 0,
},
)
const tokens = {
system: estimateTokens(counts.system),
user: estimateTokens(counts.user),
assistant: estimateTokens(counts.assistant),
tool: estimateTokens(counts.tool),
}
const estimated = tokens.system + tokens.user + tokens.assistant + tokens.tool
if (estimated <= args.input) {
return build({ ...tokens, other: args.input - estimated }, args.input)
}
const scale = args.input / estimated
const scaled = {
system: Math.floor(tokens.system * scale),
user: Math.floor(tokens.user * scale),
assistant: Math.floor(tokens.assistant * scale),
tool: Math.floor(tokens.tool * scale),
}
const total = scaled.system + scaled.user + scaled.assistant + scaled.tool
return build({ ...scaled, other: Math.max(0, args.input - total) }, args.input)
}
@@ -1,99 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { Message } from "@/types"
import { getSessionContext } from "./session-context-metrics"
const assistant = (
id: string,
tokens: { input: number; output: number; reasoning: number; read: number; write: number },
cost: number,
providerID = "openai",
modelID = "gpt-4.1",
) => {
return {
id,
role: "assistant",
providerID,
modelID,
cost,
tokens: {
input: tokens.input,
output: tokens.output,
reasoning: tokens.reasoning,
cache: {
read: tokens.read,
write: tokens.write,
},
},
time: { created: 1 },
} as unknown as Message
}
const user = (id: string) => {
return {
id,
role: "user",
cost: 0,
time: { created: 1 },
} as unknown as Message
}
describe("getSessionContext", () => {
test("computes token totals and usage from latest assistant with tokens", () => {
const messages = [
user("u1"),
assistant("a1", { input: 600, output: 200, reasoning: 100, read: 50, write: 50 }, 0.5),
assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25),
]
const providers = [
{
id: "openai",
name: "OpenAI",
models: {
"gpt-4.1": {
name: "GPT-4.1",
limit: { context: 1000 },
},
},
},
]
const ctx = getSessionContext(messages, providers)
expect(ctx?.message.id).toBe("a2")
expect(ctx?.total).toBe(500)
expect(ctx?.input).toBe(300)
expect(ctx?.usage).toBe(50)
expect(ctx?.providerLabel).toBe("OpenAI")
expect(ctx?.modelLabel).toBe("GPT-4.1")
})
test("preserves fallback labels and null usage when model metadata is missing", () => {
const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")]
const providers = [{ id: "p-1", models: {} }]
const ctx = getSessionContext(messages, providers)
expect(ctx?.providerLabel).toBe("p-1")
expect(ctx?.modelLabel).toBe("m-1")
expect(ctx?.limit).toBeUndefined()
expect(ctx?.usage).toBeNull()
})
test("recomputes when message array is mutated in place", () => {
const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)]
const providers = [{ id: "openai", models: {} }]
const one = getSessionContext(messages, providers)
messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75))
const two = getSessionContext(messages, providers)
expect(one?.message.id).toBe("a1")
expect(two?.message.id).toBe("a2")
})
test("returns undefined when inputs are undefined", () => {
const ctx = getSessionContext(undefined, undefined)
expect(ctx).toBeUndefined()
})
})
@@ -1,65 +0,0 @@
import type { AssistantMessage, Message } from "@/types"
type Provider = {
id: string
name?: string
models: Record<string, Model | undefined>
}
type Model = {
name?: string
limit: {
context: number
}
}
type Context = {
message: AssistantMessage
provider?: Provider
model?: Model
providerLabel: string
modelLabel: string
limit: number | undefined
input: number
total: number
usage: number | null
}
const tokenTotal = (msg: AssistantMessage) => {
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
}
const lastAssistantWithTokens = (messages: Message[]) => {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.role !== "assistant") continue
if (tokenTotal(msg) <= 0) continue
return msg
}
}
const build = (messages: Message[] = [], providers: Provider[] = []): Context | undefined => {
const message = lastAssistantWithTokens(messages)
if (!message) return undefined
const provider = providers.find((item) => item.id === message.providerID)
const model = provider?.models[message.modelID]
const limit = model?.limit.context
const total = tokenTotal(message)
return {
message,
provider,
model,
providerLabel: provider?.name ?? message.providerID,
modelLabel: model?.name ?? message.modelID,
limit,
input: message.tokens.input,
total,
usage: limit ? Math.round((total / limit) * 100) : null,
}
}
export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) {
return build(messages, providers)
}
@@ -1,7 +1,7 @@
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
import type { JSX } from "solid-js"
import { useData } from "@/context/server"
import { checksum } from "@opencode-ai/core/util/encode"
import { checksum } from "@opencode-ai/util/encode"
import { same } from "@/utils/same"
import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button"
@@ -181,8 +181,7 @@ export function SessionContextTab() {
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.tokens.reasoning) },
{
label: "context.stats.cacheTokens",
value: () =>
`${formatter().number(ctx()?.tokens.cache.read)} / ${formatter().number(ctx()?.tokens.cache.write)}`,
value: () => `${formatter().number(ctx()?.tokens.cache.read)} / ${formatter().number(ctx()?.tokens.cache.write)}`,
},
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
@@ -305,9 +304,7 @@ export function SessionContextTab() {
</div>
<Accordion multiple>
<For each={messages()}>
{(message) => (
<RawMessage message={message} onRendered={restoreScroll} time={formatter().time} />
)}
{(message) => <RawMessage message={message} onRendered={restoreScroll} time={formatter().time} />}
</For>
</Accordion>
</div>
@@ -5,7 +5,7 @@ import { useLanguage } from "@/context/language"
import { useData } from "@/context/server"
import { Icon } from "@opencode-ai/ui/icon"
import { Mark } from "@opencode-ai/ui/logo"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
const MAIN_WORKTREE = "main"
const CREATE_WORKTREE = "create"
@@ -1,7 +1,7 @@
import { Show } from "solid-js"
import type { JSX } from "solid-js"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { getFilename } from "@opencode-ai/core/util/path"
import { getFilename } from "@opencode-ai/util/path"
export function FileVisual(props: { path: string; active?: boolean; temporary?: boolean }): JSX.Element {
return (
@@ -6,6 +6,7 @@ import { useLanguage } from "@/context/language"
import { useData } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { useMcpToggle } from "@/context/mcp"
import { pluginLabel } from "@/utils/plugin"
import { ExternalLink } from "../external-link"
import { InlineServerSelect } from "./parts/server-select"
import "./settings-v2.css"
@@ -44,7 +45,9 @@ export const SettingsExtensionsV2: Component = () => {
() => serverSdk.connection.status() === "connected",
() => serverSdk.api.plugin.list().then((result) => result.data),
)
const plugins = createMemo<PluginRowItem[]>(() => (pluginList.latest ?? []).map((item) => ({ name: item.id })))
const plugins = createMemo<PluginRowItem[]>(() =>
(pluginList.latest ?? []).map((item) => ({ name: pluginLabel(item) })),
)
createEffect(() => {
if (serverSdk.connection.status() !== "connected") return
@@ -10,7 +10,7 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { getFilename } from "@opencode-ai/core/util/path"
import { getFilename } from "@opencode-ai/util/path"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useData } from "@/context/server"
@@ -6,6 +6,7 @@ import { useMcpToggle } from "@/context/mcp"
import { useWorkspaceLocation } from "@/context/location"
import { useData } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { pluginLabel } from "@/utils/plugin"
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
@@ -27,17 +28,18 @@ export function StatusPopoverBody(props: { shown: boolean }) {
const toggleMcp = useMcpToggle(() => sdk().directory)
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory }) ?? []
const mcpNames = createMemo(() => mcp().map((server) => server.name).sort((a, b) => a.localeCompare(b)))
const mcpNames = createMemo(() =>
mcp()
.map((server) => server.name)
.sort((a, b) => a.localeCompare(b)),
)
const mcpStatus = (name: string) => mcp().find((server) => server.name === name)?.status.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
const [pluginList] = createResource(
() => (props.shown ? sdk().directory : undefined),
(directory) =>
serverSDK.api.plugin
.list({ location: { directory } })
.then((result) => result.data),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
)
const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id))
const plugins = createMemo(() => (pluginList.latest ?? []).map(pluginLabel))
const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
@@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command"
import { useTabs } from "@/context/tabs"
import { createTabPromptState } from "@/context/prompt"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import { showToast } from "@/utils/toast"
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order"
+1 -1
View File
@@ -2,7 +2,7 @@ import { batch, createMemo, createRoot, onCleanup } from "solid-js"
import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useParams } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope"
+12 -14
View File
@@ -3,8 +3,8 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { showToast } from "@/utils/toast"
import { useParams } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getFilename } from "@opencode-ai/core/util/path"
import { base64Encode } from "@opencode-ai/util/encode"
import { getFilename } from "@opencode-ai/util/path"
import { useWorkspaceLocation } from "./location"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
@@ -78,16 +78,14 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
scope,
normalizeDir: path.normalizeDir,
list: (dir) =>
serverSDK.api.file
.list({ path: dir, location: { directory: scope() } })
.then((x) =>
x.data.map((entry) => ({
...entry,
name: entry.path.split("/").at(-1) ?? entry.path,
absolute: `${scope()}/${entry.path}`,
ignored: false,
})),
),
serverSDK.api.file.list({ path: dir, location: { directory: scope() } }).then((x) =>
x.data.map((entry) => ({
...entry,
name: entry.path.split("/").at(-1) ?? entry.path,
absolute: `${scope()}/${entry.path}`,
ignored: false,
})),
),
onError: (message) => {
showToast({
variant: "error",
@@ -227,8 +225,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
},
)
const stop = sdk().event.listen((e) => {
invalidateFromWatcher(e.details, {
const stop = sdk().event.on("filesystem.changed", (event) => {
invalidateFromWatcher(event, {
normalize: path.normalize,
hasFile: (file) => Boolean(store.file[file]),
isOpen: (file) => tabs.all().some((tab) => path.pathFromTab(tab) === file),
+64 -114
View File
@@ -1,27 +1,28 @@
import { describe, expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { invalidateFromWatcher } from "./watcher"
type FilesystemEvent = Extract<OpenCodeEvent, { type: "filesystem.changed" }>
const filesystemEvent = (file: string, event: FilesystemEvent["data"]["event"]): FilesystemEvent => ({
id: `evt_${file}`,
created: 1,
type: "filesystem.changed",
data: { file, event },
})
describe("file watcher invalidation", () => {
test("reloads open files and refreshes loaded parent on add", () => {
const loads: string[] = []
const refresh: string[] = []
invalidateFromWatcher(
{
type: "filesystem.changed",
properties: {
file: "src/new.ts",
event: "add",
},
},
{
normalize: (input) => input,
hasFile: (path) => path === "src/new.ts",
loadFile: (path) => loads.push(path),
node: () => undefined,
isDirLoaded: (path) => path === "src",
refreshDir: (path) => refresh.push(path),
},
)
invalidateFromWatcher(filesystemEvent("src/new.ts", "add"), {
normalize: (input) => input,
hasFile: (path) => path === "src/new.ts",
loadFile: (path) => loads.push(path),
node: () => undefined,
isDirLoaded: (path) => path === "src",
refreshDir: (path) => refresh.push(path),
})
expect(loads).toEqual(["src/new.ts"])
expect(refresh).toEqual(["src"])
@@ -30,30 +31,21 @@ describe("file watcher invalidation", () => {
test("reloads files that are open in tabs", () => {
const loads: string[] = []
invalidateFromWatcher(
{
type: "filesystem.changed",
properties: {
file: "src/open.ts",
event: "change",
},
},
{
normalize: (input) => input,
hasFile: () => false,
isOpen: (path) => path === "src/open.ts",
loadFile: (path) => loads.push(path),
node: () => ({
path: "src/open.ts",
type: "file",
name: "open.ts",
absolute: "/repo/src/open.ts",
ignored: false,
}),
isDirLoaded: () => false,
refreshDir: () => {},
},
)
invalidateFromWatcher(filesystemEvent("src/open.ts", "change"), {
normalize: (input) => input,
hasFile: () => false,
isOpen: (path) => path === "src/open.ts",
loadFile: (path) => loads.push(path),
node: () => ({
path: "src/open.ts",
type: "file",
name: "open.ts",
absolute: "/repo/src/open.ts",
ignored: false,
}),
isDirLoaded: () => false,
refreshDir: () => {},
})
expect(loads).toEqual(["src/open.ts"])
})
@@ -61,47 +53,29 @@ describe("file watcher invalidation", () => {
test("refreshes only changed loaded directory nodes", () => {
const refresh: string[] = []
invalidateFromWatcher(
{
type: "filesystem.changed",
properties: {
file: "src",
event: "change",
},
},
{
normalize: (input) => input,
hasFile: () => false,
loadFile: () => {},
node: () => ({ path: "src", type: "directory", name: "src", absolute: "/repo/src", ignored: false }),
isDirLoaded: (path) => path === "src",
refreshDir: (path) => refresh.push(path),
},
)
invalidateFromWatcher(filesystemEvent("src", "change"), {
normalize: (input) => input,
hasFile: () => false,
loadFile: () => {},
node: () => ({ path: "src", type: "directory", name: "src", absolute: "/repo/src", ignored: false }),
isDirLoaded: (path) => path === "src",
refreshDir: (path) => refresh.push(path),
})
invalidateFromWatcher(
{
type: "filesystem.changed",
properties: {
file: "src/file.ts",
event: "change",
},
},
{
normalize: (input) => input,
hasFile: () => false,
loadFile: () => {},
node: () => ({
path: "src/file.ts",
type: "file",
name: "file.ts",
absolute: "/repo/src/file.ts",
ignored: false,
}),
isDirLoaded: () => true,
refreshDir: (path) => refresh.push(path),
},
)
invalidateFromWatcher(filesystemEvent("src/file.ts", "change"), {
normalize: (input) => input,
hasFile: () => false,
loadFile: () => {},
node: () => ({
path: "src/file.ts",
type: "file",
name: "file.ts",
absolute: "/repo/src/file.ts",
ignored: false,
}),
isDirLoaded: () => true,
refreshDir: (path) => refresh.push(path),
})
expect(refresh).toEqual(["src"])
})
@@ -109,40 +83,16 @@ describe("file watcher invalidation", () => {
test("ignores invalid or git watcher updates", () => {
const refresh: string[] = []
invalidateFromWatcher(
{
type: "filesystem.changed",
properties: {
file: ".git/index.lock",
event: "change",
},
invalidateFromWatcher(filesystemEvent(".git/index.lock", "change"), {
normalize: (input) => input,
hasFile: () => true,
loadFile: () => {
throw new Error("should not load")
},
{
normalize: (input) => input,
hasFile: () => true,
loadFile: () => {
throw new Error("should not load")
},
node: () => undefined,
isDirLoaded: () => true,
refreshDir: (path) => refresh.push(path),
},
)
invalidateFromWatcher(
{
type: "project.updated",
properties: {},
},
{
normalize: (input) => input,
hasFile: () => false,
loadFile: () => {},
node: () => undefined,
isDirLoaded: () => true,
refreshDir: (path) => refresh.push(path),
},
)
node: () => undefined,
isDirLoaded: () => true,
refreshDir: (path) => refresh.push(path),
})
expect(refresh).toEqual([])
})
+7 -25
View File
@@ -1,9 +1,7 @@
import type { FileNode } from "@/types"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
type WatcherEvent = {
type: string
properties: unknown
}
type WatcherEvent = Extract<OpenCodeEvent, { type: "filesystem.changed" }>
type WatcherOps = {
normalize: (input: string) => string
@@ -16,15 +14,7 @@ type WatcherOps = {
}
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
if (event.type !== "filesystem.changed") return
const props =
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
const rawPath = typeof props?.file === "string" ? props.file : undefined
const kind = typeof props?.event === "string" ? props.event : undefined
if (!rawPath) return
if (!kind) return
const path = ops.normalize(rawPath)
const path = ops.normalize(event.data.file)
if (!path) return
if (path.startsWith(".git/")) return
@@ -32,20 +22,12 @@ export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
ops.loadFile(path)
}
if (kind === "change") {
const dir = (() => {
if (path === "") return ""
const node = ops.node(path)
if (node?.type !== "directory") return
return path
})()
if (dir === undefined) return
if (!ops.isDirLoaded(dir)) return
ops.refreshDir(dir)
if (event.data.event === "change") {
if (ops.node(path)?.type !== "directory") return
if (!ops.isDirLoaded(path)) return
ops.refreshDir(path)
return
}
if (kind !== "add" && kind !== "unlink") return
const parent = path.split("/").slice(0, -1).join("/")
if (!ops.isDirLoaded(parent)) return
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test"
import { QueryClient } from "@tanstack/solid-query"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { loadPathQuery, loadProjectsQuery } from "./bootstrap"
import { ServerScope } from "@/utils/server-scope"
import type { ServerApi } from "@/utils/server"
@@ -86,5 +85,4 @@ describe("query keys", () => {
{ id: "b", sandboxes: [] },
])
})
})
@@ -7,8 +7,8 @@ import type {
ProjectListOutput,
} from "@opencode-ai/client/promise"
import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path"
import { retry } from "@opencode-ai/core/util/retry"
import { getFilename } from "@opencode-ai/util/path"
import { retry } from "@opencode-ai/util/retry"
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { State } from "./types"
import { cmp, normalizeProjectInfo } from "./utils"
@@ -233,7 +233,10 @@ export function createChildStoreManager(input: {
},
get mcp() {
return Object.fromEntries(
(input.data.location.mcp.server.list({ directory }) ?? []).map((server) => [server.name, server.status]),
(input.data.location.mcp.server.list({ directory }) ?? []).map((server) => [
server.name,
server.status,
]),
)
},
get mcp_resource() {
@@ -1,61 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createStore } from "solid-js/store"
import type { Project } from "@/types"
import type { State } from "./types"
import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer"
describe("applyGlobalEvent", () => {
test("upserts project.updated in sorted position", () => {
const projects = [{ id: "b", worktree: "/b" }] as Project[]
let next = projects
applyGlobalEvent({
event: { type: "project.updated", properties: { id: "a", worktree: "/a" } },
project: projects,
setGlobalProject: (value) => {
next = typeof value === "function" ? value(next) : value
},
refresh() {},
})
expect(next.map((project) => project.id)).toEqual(["a", "b"])
})
test("refreshes on global disposal", () => {
let refreshed = false
applyGlobalEvent({
event: { type: "global.disposed" },
project: [],
setGlobalProject() {},
refresh: () => (refreshed = true),
})
expect(refreshed).toBe(true)
})
})
describe("applyDirectoryEvent", () => {
test("updates vcs and routes refresh events", () => {
const [store, setStore] = createStore({ vcs: { branch: "old" } } as State)
const pushed: string[] = []
let lsp = 0
let references = 0
const apply = (type: string, properties?: unknown) =>
applyDirectoryEvent({
event: { type, properties },
store,
setStore,
directory: "/repo",
push: (directory) => pushed.push(directory),
loadLsp: () => lsp++,
loadReferences: () => references++,
})
apply("vcs.branch.updated", { branch: "main" })
apply("server.instance.disposed")
apply("lsp.updated")
apply("reference.updated")
expect(store.vcs?.branch).toBe("main")
expect(pushed).toEqual(["/repo"])
expect(lsp).toBe(1)
expect(references).toBe(1)
})
})
@@ -1,63 +0,0 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { produce, type SetStoreFunction, type Store } from "solid-js/store"
import type { Project } from "@/types"
import type { State, VcsCache } from "./types"
export function applyGlobalEvent(input: {
event: { type: string; properties?: unknown }
project: Project[]
setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void
refresh: () => void
}) {
if (input.event.type === "global.disposed") {
input.refresh()
return
}
if (input.event.type !== "project.updated") return
const properties = input.event.properties as Project
const result = Binary.search(input.project, properties.id, (project) => project.id)
if (result.found) {
input.setGlobalProject(
produce((draft) => {
draft[result.index] = { ...draft[result.index], ...properties }
}),
)
return
}
input.setGlobalProject(
produce((draft) => {
draft.splice(result.index, 0, properties)
}),
)
}
export function applyDirectoryEvent(input: {
event: { type: string; properties?: unknown }
store: Store<State>
setStore: SetStoreFunction<State>
push: (directory: string) => void
directory: string
loadLsp: () => void
loadReferences?: () => void
vcsCache?: VcsCache
}) {
switch (input.event.type) {
case "server.instance.disposed":
input.push(input.directory)
break
case "vcs.branch.updated": {
const properties = input.event.properties as { branch?: string }
if (input.store.vcs?.branch === properties.branch) break
const next = { ...input.store.vcs, branch: properties.branch }
input.setStore("vcs", next)
input.vcsCache?.setStore("value", next)
break
}
case "lsp.updated":
input.loadLsp()
break
case "reference.updated":
input.loadReferences?.()
break
}
}
@@ -4,8 +4,6 @@ import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const
export async function loadHomeSessionIndex(
list: (
input: { limit: number; order: "desc"; cursor?: string },
@@ -37,6 +35,12 @@ export function parseHomeSessionIndex(sessions: SessionInfo[]) {
return sessions.filter((session) => !session.parentID && typeof session.time.archived !== "number")
}
export function mergeHomeSessionIndex(fetched: SessionInfo[], known: SessionInfo[]) {
return parseHomeSessionIndex([
...new Map([...fetched, ...known].map((session) => [session.id, session] as const)).values(),
])
}
export function retainHomeSessions(sessions: SessionInfo[], limit: number, now: number) {
return [...Map.groupBy(sessions, (session) => pathKey(session.location.directory)).values()].flatMap((items) => {
const sorted = items.toSorted((a, b) => {
@@ -1,6 +1,5 @@
import type { Agent, Config, LspStatus, Path, VcsInfo } from "@/types"
import type { Agent, Config, LspStatus, Path, ProviderListResponse, VcsInfo } from "@/types"
import type { ReferenceInfo } from "@opencode-ai/client/promise"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { CommandInfo, McpResource, McpServer } from "@opencode-ai/client/promise"
import type { Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store"
@@ -25,7 +24,7 @@ export type State = {
projectMeta: ProjectMeta | undefined
icon: string | undefined
provider_ready: boolean
provider: NormalizedProviderListResponse
provider: ProviderListResponse
config: Config
path: Path
mcp_ready: boolean
@@ -1,9 +1,5 @@
import { describe, expect, test } from "bun:test"
import type {
AgentListOutput,
ModelListOutput,
ProviderListOutput,
} from "@opencode-ai/client/promise"
import type { AgentListOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise"
import { directoryKey, normalizeAgentList, normalizeProviderList } from "./utils"
describe("normalizeAgentList", () => {
@@ -1,11 +1,6 @@
import type {
AgentListOutput,
ModelListOutput,
ProviderListOutput,
} from "@opencode-ai/client/promise"
import type { AgentListOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise"
import type { Agent, Project, Provider, ProviderListResponse } from "@/types"
import type { Project as CurrentProject } from "@opencode-ai/client/promise"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
@@ -37,7 +32,7 @@ export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Ag
export function normalizeProviderList(
providers: ProviderListOutput["data"] | ProviderListResponse,
models?: ModelListOutput["data"],
): NormalizedProviderListResponse {
): ProviderListResponse {
if (!Array.isArray(providers)) {
return providers
}
+4 -1
View File
@@ -102,7 +102,10 @@ function createServerController(
const sdk = createServerSdkContext(conn, scope)
const data = createData({
api: () => sdk.api,
event: sdk.event,
event: {
on: sdk.event.on,
listen: (handler) => sdk.event.listen((event) => handler({ name: event.type, details: event })),
},
connection: sdk.connection,
directory: "",
})
+1 -1
View File
@@ -1,5 +1,5 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, startTransition } from "solid-js"
import { createStore } from "solid-js/store"
+2 -7
View File
@@ -13,10 +13,7 @@ export type WorkspaceLocation = LocationContext & {
const context = createSimpleContext({
name: "Location",
init: (props: {
directory: string | Accessor<string>
workspaceID?: string | Accessor<string | undefined>
}) => {
init: (props: { directory: string | Accessor<string>; workspaceID?: string | Accessor<string | undefined> }) => {
const serverSDK = useServerSDK()
const data = useData()
const ref = createMemo(() => ({
@@ -44,9 +41,7 @@ const context = createSimpleContext({
})
})
const location = createMemo(() =>
serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory),
)
const location = createMemo(() => serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory))
return createMemo<WorkspaceLocation>(() => ({
...location(),
ref: ref(),
+2 -1
View File
@@ -26,7 +26,8 @@ export function useMcpToggle(directory?: Accessor<string | undefined>, onSuccess
} else if (server.status.status === "needs_auth" && server.integrationID) {
const integration = await serverSDK.api.integration.get({ integrationID: server.integrationID, location: ref })
const method = integration.data?.methods.find((item) => item.type === "oauth" && !item.form?.length)
if (!method || method.type !== "oauth") throw new Error(`MCP server ${name} requires an interactive authentication form`)
if (!method || method.type !== "oauth")
throw new Error(`MCP server ${name} requires an interactive authentication form`)
const attempt = await serverSDK.api.integration.oauth.connect({
integrationID: server.integrationID,
methodID: method.id,
+11 -15
View File
@@ -3,12 +3,11 @@ import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, o
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { ServerSDK } from "./server-sdk"
import type { Data } from "@opencode-ai/client/solid"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { decode64 } from "@/utils/base64"
import type { EventSessionError } from "@/types"
import { Persist, persisted } from "@/utils/persist"
import { playSoundById } from "@/utils/sound"
import { useGlobal } from "./global"
@@ -32,7 +31,7 @@ type TurnCompleteNotification = NotificationBase & {
type ErrorNotification = NotificationBase & {
type: "error"
error: EventSessionError["properties"]["error"]
error: Extract<OpenCodeEvent, { type: "session.execution.failed" }>["data"]["error"]
}
export type Notification = TurnCompleteNotification | ErrorNotification
@@ -216,8 +215,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
dispatchEvent(new PopStateEvent("popstate"))
}
const handleSessionIdle = (directory: string, event: { properties: { sessionID: string } }, time: number) => {
const sessionID = event.properties.sessionID
const handleSessionIdle = (directory: string, sessionID: string, time: number) => {
void lookup(sessionID).then((session) => {
if (meta.disposed) return
if (!session) return
@@ -246,10 +244,10 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
const handleSessionError = (
directory: string,
event: { properties: EventSessionError["properties"] },
sessionID: string,
error: ErrorNotification["error"],
time: number,
) => {
const sessionID = event.properties.sessionID
void lookup(sessionID).then((session) => {
if (meta.disposed) return
if (session?.parentID) return
@@ -258,27 +256,25 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
void playSoundById(settings.sounds.errors())
}
const error = event.properties.error
append({
directory,
time,
viewed: viewedInCurrentSession(sessionID),
type: "error",
session: sessionID ?? "global",
session: sessionID,
error,
})
const description =
session?.title ??
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
const href = sessionHref(input.key, sessionID ?? "global")
const href = sessionHref(input.key, sessionID)
if (settings.notifications.errors()) {
void platform.notify(language.t("notification.session.error.title"), description, () => navigate(href))
}
})
}
const unsub = input.sdk.eventByDir.listen((e) => {
const event = e.details
const unsub = input.sdk.event.listen((event) => {
if (
event.type !== "session.execution.succeeded" &&
event.type !== "session.execution.interrupted" &&
@@ -286,14 +282,14 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
)
return
const directory = event.current?.location?.directory
const directory = event.location?.directory
if (!directory) return
const time = Date.now()
if (event.type === "session.execution.failed") {
handleSessionError(directory, event, time)
handleSessionError(directory, event.data.sessionID, event.data.error, time)
return
}
handleSessionIdle(directory, event, time)
handleSessionIdle(directory, event.data.sessionID, time)
})
onCleanup(() => {
meta.disposed = true
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import {
autoRespondsPermission,
isDirectoryAutoAccepting,
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
export function acceptKey(sessionID: string, directory?: string) {
if (!directory) return sessionID
+3 -11
View File
@@ -54,8 +54,6 @@ function hasPermissionPromptRules(permission: unknown) {
return Object.values(config).some(isNonAllowRule)
}
type PermissionEvent = Parameters<Parameters<ServerSDK["eventByDir"]["listen"]>[0]>[0]
export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync; data: Data }) {
const [store, setStore, _, ready] = persisted(
{
@@ -204,20 +202,14 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
return next
}
const handlePermission = (e: PermissionEvent) => {
const event = e.details
if (event?.type !== "permission.asked") return
void respondPending(event.properties, event.current?.location?.directory)
}
const unsubscribe = input.sdk.eventByDir.listen((event) => {
const unsubscribe = input.sdk.event.on("permission.asked", (event) => {
if (ready()) {
handlePermission(event)
void respondPending(event.data, event.location?.directory)
return
}
void ready.promise?.then(() => {
if (meta.disposed) return
handlePermission(event)
void respondPending(event.data, event.location?.directory)
})
})
onCleanup(() => {
+14 -2
View File
@@ -1,5 +1,4 @@
import { checksum } from "@opencode-ai/core/util/encode"
import type { FilePartSource } from "@/types"
import { checksum } from "@opencode-ai/util/encode"
import { batch, createMemo, type Accessor } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file"
@@ -14,6 +13,19 @@ interface PartBase {
end: number
}
type FilePartSourceText = { value: string; start: number; end: number }
type FilePartSource =
| { text: FilePartSourceText; type: "file"; path: string }
| {
text: FilePartSourceText
type: "symbol"
path: string
range: { start: { line: number; character: number }; end: { line: number; character: number } }
name: string
kind: number
}
| { text: FilePartSourceText; type: "resource"; clientName: string; uri: string }
export interface TextPart extends PartBase {
type: "text"
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useParams, useSearchParams } from "@solidjs/router"
import { createMemo, createResource, createRoot, getOwner, onCleanup } from "solid-js"
+79 -26
View File
@@ -1,33 +1,86 @@
import { describe, expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { adaptServerEvent } from "./server-sdk"
import { createRoot } from "solid-js"
import { createOpenCodeEventSource } from "./server-sdk"
describe("adaptServerEvent", () => {
test("preserves current permission requests", () => {
const current = {
id: "evt_1",
created: 1,
type: "permission.asked",
data: {
id: "perm_1",
sessionID: "ses_1",
action: "read",
resources: ["src/**"],
source: { type: "tool", messageID: "msg_1", id: "call_1" },
},
} as OpenCodeEvent
const permission = {
id: "evt_permission",
created: 1,
type: "permission.asked",
location: { directory: "/repo", workspaceID: "workspace_1" },
data: {
id: "perm_1",
sessionID: "ses_1",
action: "read",
resources: ["src/**"],
source: { type: "tool", messageID: "msg_1", id: "call_1" },
},
} satisfies Extract<OpenCodeEvent, { type: "permission.asked" }>
expect(adaptServerEvent(current)).toMatchObject({
id: "evt_1",
type: "permission.asked",
properties: {
id: "perm_1",
sessionID: "ses_1",
action: "read",
resources: ["src/**"],
source: { type: "tool", messageID: "msg_1", id: "call_1" },
},
current,
function setup() {
return createRoot((dispose) => ({ ...createOpenCodeEventSource(), dispose }))
}
describe("server event stream", () => {
test("publishes the original current event with exact data", () => {
const server = setup()
const received: OpenCodeEvent[] = []
let requestID: string | undefined
server.event.on("permission.asked", (event) => {
requestID = event.data.id
})
server.event.listen((event) => received.push(event))
server.publish(permission)
expect(requestID).toBe("perm_1")
expect(received).toEqual([permission])
expect(received[0]).toBe(permission)
server.dispose()
})
test("filters locations without changing workspace identity", () => {
const server = setup()
const repo: OpenCodeEvent[] = []
const other: OpenCodeEvent[] = []
const all: OpenCodeEvent[] = []
let workspaceID: string | undefined
const global = {
id: "evt_connected",
type: "server.connected",
data: {},
} satisfies Extract<OpenCodeEvent, { type: "server.connected" }>
const repoEvents = server.event.location("/repo")
repoEvents.on("permission.asked", (event) => {
workspaceID = event.location?.workspaceID
})
repoEvents.listen((event) => repo.push(event))
server.event.location("/other").listen((event) => other.push(event))
server.event.listen((event) => all.push(event))
server.publish(permission)
server.publish(global)
expect(repo).toEqual([permission])
expect(workspaceID).toBe("workspace_1")
expect(other).toEqual([])
expect(all).toEqual([permission, global])
server.dispose()
})
test("isolates servers and clears subscriptions with their owner", () => {
const first = setup()
const second = setup()
const received = { first: 0, second: 0 }
first.event.listen(() => received.first++)
second.event.listen(() => received.second++)
first.publish(permission)
first.dispose()
first.publish(permission)
second.publish(permission)
expect(received).toEqual({ first: 1, second: 1 })
second.dispose()
})
})
+49 -45
View File
@@ -1,6 +1,5 @@
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { createClientConnection, type ClientConnectionStatus } from "@opencode-ai/client/solid"
import type { Event } from "@/types"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { type Accessor, onCleanup } from "solid-js"
import { createApiForServer, type ServerApi } from "@/utils/server"
@@ -10,15 +9,52 @@ import { createRefCountMap } from "@/utils/refcount"
import { ServerScope } from "@/utils/server-scope"
import { useServer } from "./server"
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
type OpenCodeEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
export type OpenCodeEventStream = {
on<Type extends OpenCodeEvent["type"]>(type: Type, handler: (event: OpenCodeEventMap[Type]) => void): VoidFunction
listen(handler: (event: OpenCodeEvent) => void): VoidFunction
}
type OpenCodeEventSource = OpenCodeEventStream & {
location(directory: string): OpenCodeEventStream
}
export function createOpenCodeEventSource() {
const emitter = createGlobalEmitter<OpenCodeEventMap>()
function stream(directory?: string): OpenCodeEventStream {
return {
on(type, handler) {
return emitter.on(type, (event) => {
if (directory !== undefined && event.location?.directory !== directory) return
handler(event)
})
},
listen(handler) {
return emitter.listen((event) => {
if (directory !== undefined && event.details.location?.directory !== directory) return
handler(event.details)
})
},
}
}
const event: OpenCodeEventSource = {
...stream(),
location: (directory) => stream(directory),
}
onCleanup(() => emitter.clear())
return {
event,
publish(event: OpenCodeEvent) {
emitter.emit(event.type, event)
},
}
}
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
type CurrentEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
type CurrentEventEmitter = ReturnType<typeof createGlobalEmitter<CurrentEventMap>>
export type ServerConnectionStatus = ClientConnectionStatus
type ServerSDKBase = {
server: ServerConnection.Any
@@ -30,28 +66,19 @@ type ServerSDKBase = {
attempt: Accessor<number>
error: Accessor<string | undefined>
}
eventByDir: {
on: ServerEventEmitter["on"]
listen: ServerEventEmitter["listen"]
}
event: {
on: CurrentEventEmitter["on"]
listen: CurrentEventEmitter["listen"]
}
event: OpenCodeEventSource
}
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
const platform = usePlatform()
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
const dirEmitter = createGlobalEmitter<{ [key: string]: ServerEvent }>()
const emitter = createGlobalEmitter<CurrentEventMap>()
const events = createOpenCodeEventSource()
const connection = createClientConnection(api, {
flushInterval: 16,
pageLifecycle: true,
onEvent(event) {
emitter.emit(event.type, event)
dirEmitter.emit(event.location?.directory ?? "global", adaptServerEvent(event))
events.publish(event)
},
log: {
info(message, data) {
@@ -61,25 +88,13 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
},
})
onCleanup(() => {
dirEmitter.clear()
emitter.clear()
})
return {
server,
scope,
url: server.http.url,
api,
connection,
eventByDir: {
on: dirEmitter.on.bind(dirEmitter),
listen: dirEmitter.listen.bind(dirEmitter),
},
event: {
on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter),
},
event: events.event,
}
}
@@ -99,25 +114,14 @@ export const useServerSDK = () => {
return server.ctx.sdk
}
type SDKEventMap = {
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
}
export type LocationContext = {
directory: string
event: ReturnType<typeof createGlobalEmitter<SDKEventMap>>
event: OpenCodeEventStream
}
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): LocationContext {
const emitter = createGlobalEmitter<SDKEventMap>()
const unsub = serverSDK.eventByDir.on(directory, (event) => {
emitter.emit(event.type, event)
})
onCleanup(unsub)
return {
directory,
event: emitter,
event: serverSDK.event.location(directory),
}
}
+13 -51
View File
@@ -1,18 +1,12 @@
import type { Config, Path, Project, ProviderAuthResponse } from "@/types"
import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path"
import { getFilename } from "@opencode-ai/util/path"
import { getOwner, onCleanup, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { type ServerSDK } from "./server-sdk"
import {
bootstrapDirectory,
bootstrapGlobal,
loadGlobalConfigQuery,
loadPathQuery,
} from "./global-sync/bootstrap"
import { bootstrapDirectory, bootstrapGlobal, loadGlobalConfigQuery, loadPathQuery } from "./global-sync/bootstrap"
import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import type { ProjectMeta } from "./global-sync/types"
import { formatServerError } from "@/utils/server-errors"
import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
@@ -224,55 +218,23 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
return promise
}
const unsub = serverSDK.eventByDir.listen((e) => {
const directory = e.name
const key = directoryKey(directory)
const event = e.details
const eventType: string = event.type
connection.handleEvent({ type: eventType })
const unsub = serverSDK.event.listen((event) => {
connection.handleEvent({ type: event.type })
if (directory === "global") {
applyGlobalEvent({
event,
project: globalStore.project,
refresh: () => void bootstrap.refetch(),
setGlobalProject: setProjects,
})
if (eventType === "config.updated" || eventType === "agent.updated" || eventType === "worktree.updated")
if (!event.location) {
if (event.type === "config.updated" || event.type === "agent.updated" || event.type === "worktree.updated")
bootstrap.refetch()
if (eventType === "global.disposed") Object.keys(children.children).filter(children.active).forEach(queue.push)
return
}
const existing = children.children[key]
if (!existing) return
const directory = event.location.directory
const key = directoryKey(directory)
if (!children.children[key]) return
children.mark(key)
if (
eventType === "config.updated" ||
eventType === "agent.updated"
)
queue.push(key)
const [store, setStore] = existing
if (eventType === "worktree.updated") void bootstrap.refetch()
if (eventType !== "vcs.branch.updated")
applyDirectoryEvent({
event,
directory,
store,
setStore,
push: (directory) => {
if (children.active(directory)) queue.push(directory)
},
vcsCache: children.vcsCache.get(key),
loadLsp: () => {
if (!children.active(key)) return
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
},
loadReferences: () => {
if (!children.active(key)) return
void data.location.reference.sync({ directory: key }).catch(() => undefined)
},
})
if (event.type === "config.updated" || event.type === "agent.updated") queue.push(key)
if (event.type === "worktree.updated") void bootstrap.refetch()
if (event.type === "reference.updated" && children.active(key))
void data.location.reference.sync({ directory: key }).catch(() => undefined)
})
onCleanup(unsub)
-108
View File
@@ -1,108 +0,0 @@
import { Binary } from "@opencode-ai/core/util/binary"
import type { Message, Part } from "@/types"
import { messageKey } from "@/utils/session-message"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
function sortParts(parts: Part[]) {
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
}
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
type OptimisticStore = {
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
}
type OptimisticAddInput = {
sessionID: string
message: Message
parts: Part[]
}
type OptimisticRemoveInput = {
sessionID: string
messageID: string
}
type OptimisticItem = {
message: Message
parts: Part[]
}
type MessagePage = {
session: Message[]
part: { id: string; part: Part[] }[]
cursor?: string
complete: boolean
}
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return want.length === 0
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
}
const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return sortParts(want)
const next = [...parts]
let changed = false
for (const part of want) {
const result = Binary.search(next, part.id, (item) => item.id)
if (result.found) continue
next.splice(result.index, 0, part)
changed = true
}
if (!changed) return parts
return next
}
export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
if (items.length === 0) return { ...page, confirmed: [] as string[] }
const session = [...page.session]
const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)]))
const confirmed: string[] = []
for (const item of items) {
const result = Binary.search(session, messageKey(item.message), messageKey)
const found = result.found
if (!found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id)
if (found && hasParts(current, item.parts)) {
confirmed.push(item.message.id)
continue
}
part.set(item.message.id, mergeParts(current, item.parts))
}
return {
cursor: page.cursor,
complete: page.complete,
session,
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })),
confirmed,
}
}
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const result = Binary.search(messages, messageKey(input.message), messageKey)
messages.splice(result.index, 0, input.message)
} else {
draft.message[input.sessionID] = [input.message]
}
draft.part[input.message.id] = sortParts(input.parts)
}
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const index = messages.findIndex((message) => message.id === input.messageID)
if (index >= 0) messages.splice(index, 1)
}
delete draft.part[input.messageID]
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { beforeAll, describe, expect, mock, test } from "bun:test"
import { ServerScope } from "@/utils/server-scope"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import { Persist } from "@/utils/persist"
import type { Platform } from "./platform"
+3 -3
View File
@@ -4,7 +4,7 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli
import { useWorkspaceLocation, type LocationContext } from "./location"
import type { Platform } from "./platform"
import { useServerSDK } from "./server-sdk"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import { defaultTitle, titleNumber } from "./terminal-title"
import { Persist, persisted, removePersisted } from "@/utils/persist"
import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope"
@@ -215,8 +215,8 @@ function createWorkspaceTerminalSession(
})
}
const unsub = sdk.event.on("pty.exited", (event: { properties: { id: string } }) => {
removeExited(event.properties.id)
const unsub = sdk.event.on("pty.exited", (event) => {
removeExited(event.data.id)
})
onCleanup(unsub)
@@ -1,8 +1,8 @@
import { expect, test } from "bun:test"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { ProviderListResponse } from "@/types"
import { selectProviderCatalog } from "./provider-catalog"
const catalog = (id: string): NormalizedProviderListResponse => ({
const catalog = (id: string): ProviderListResponse => ({
all: new Map([[id, { id, name: id, source: "api", env: [], options: {}, models: {} }]]),
connected: [id],
default: { [id]: `${id}-model` },
+4 -4
View File
@@ -1,10 +1,10 @@
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { ProviderListResponse } from "@/types"
export const emptyProviderCatalog: NormalizedProviderListResponse = { all: new Map(), connected: [], default: {} }
export const emptyProviderCatalog: ProviderListResponse = { all: new Map(), connected: [], default: {} }
type DirectoryCatalog = {
ready: boolean
providers: NormalizedProviderListResponse
providers: ProviderListResponse
}
type ProviderCatalogInput =
@@ -17,7 +17,7 @@ type ProviderCatalogInput =
explicit: false
directory?: string
catalog?: DirectoryCatalog
global: NormalizedProviderListResponse
global: ProviderListResponse
}
export function selectProviderCatalog(input: ProviderCatalogInput) {
+2 -4
View File
@@ -21,7 +21,7 @@ export function SessionUIProvider(
await data.session.sync(sessionID).catch(() => undefined)
navigate(href(sessionID))
}
const legacyData = createMemo(() => ({
const sessionUIData = createMemo(() => ({
session: data.session.list(),
session_status: Object.fromEntries(
data.session
@@ -32,13 +32,11 @@ export function SessionUIProvider(
]),
),
session_diff: {},
message: {},
part: {},
}))
return (
<DataProvider
data={legacyData()}
data={sessionUIData()}
directory={directory()}
sessionID={params.id}
onNavigateToSession={navigateToSession}
@@ -5,9 +5,8 @@ import { DateTime } from "luxon"
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
import { useCommand } from "@/context/command"
import {
homeSessionIndexKey,
loadHomeSessionIndex,
parseHomeSessionIndex,
mergeHomeSessionIndex,
retainHomeSessions,
} from "@/context/global-sync/home-session-index"
import type { LocalProject } from "@/context/layout"
@@ -53,21 +52,11 @@ export function createHomeSessionsController(home: HomeController) {
const ctx = home.server.focusedContext()
const conn = home.server.focused()
return {
queryKey: conn
? homeSessionIndexKey(ServerConnection.key(conn))
: (["home", "session-index", "unselected"] as const),
queryKey: ["home-sessions", conn] as const,
enabled: !!ctx && ctx.sdk.connection.status() === "connected",
queryFn:
ctx
? async ({ signal }) => {
const index = await loadHomeSessionIndex(
(input, options) => ctx.sdk.api.session.list(input, options),
signal,
)
index.forEach(ctx.data.session.remember)
return Date.now()
}
: skipToken,
queryFn: ctx
? ({ signal }) => loadHomeSessionIndex((input, options) => ctx.sdk.api.session.list(input, options), signal)
: skipToken,
retry: false,
staleTime: 30_000,
refetchOnMount: true,
@@ -77,7 +66,11 @@ export function createHomeSessionsController(home: HomeController) {
const indexedSessions = createMemo(() => {
const ctx = home.server.focusedContext()
if (!ctx) return []
return retainHomeSessions(parseHomeSessionIndex(ctx.data.session.list()), HOME_SESSION_LIMIT, Date.now())
return retainHomeSessions(
mergeHomeSessionIndex(sessionLoad.data ?? [], ctx.data.session.list()),
HOME_SESSION_LIMIT,
Date.now(),
)
})
const allRecords = createMemo(() =>
buildHomeSessionRecords({
+1 -1
View File
@@ -1,4 +1,4 @@
import { getFilename } from "@opencode-ai/core/util/path"
import { getFilename } from "@opencode-ai/util/path"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { pathKey } from "@/utils/path-key"
import type { ServerConnection } from "@/context/servers"
@@ -15,9 +15,14 @@ export function useSessionTabAvatarState(
const ctx = serverCtx()
if (!ctx) return false
const permission = ctx.permission
return !!sessionPermissionRequest(ctx.data.session.list(), ctx.data.session.permission.list, sessionId(), (item) => {
return !permission.autoResponds(item, directory())
})
return !!sessionPermissionRequest(
ctx.data.session.list(),
ctx.data.session.permission.list,
sessionId(),
(item) => {
return !permission.autoResponds(item, directory())
},
)
})
const hasQuestions = createMemo(() => {
const data = serverCtx()?.data
@@ -88,7 +88,9 @@ export function createNewSessionWorkspaceController(input: {
)
const projectRoot = createMemo(() => currentProject()?.worktree ?? sdk().directory)
createEffect(() => {
void Promise.all([data.location.syncInfo({ directory: sdk().directory }), data.project.sync()]).catch(() => undefined)
void Promise.all([data.location.syncInfo({ directory: sdk().directory }), data.project.sync()]).catch(
() => undefined,
)
const project = currentProject()
const directories = project ? [project.worktree, ...workspaceDirectories(project)] : [sdk().directory]
directories.forEach((directory) => void data.location.vcs.sync({ directory }).catch(() => undefined))
+30 -15
View File
@@ -1,6 +1,6 @@
import type { FilePart, UserMessage } from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { getFilename } from "@opencode-ai/core/util/path"
import type { FilePart } from "@/types"
import type { FileDiffInfo, SessionMessageUser } from "@opencode-ai/client/promise"
import { getFilename } from "@opencode-ai/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
import {
@@ -37,7 +37,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
import { showToast } from "@/utils/toast"
import { checksum } from "@opencode-ai/core/util/encode"
import { checksum } from "@opencode-ai/util/encode"
import { containsDirectory, isWorkspaceDirectory } from "@/utils/workspace"
import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session"
@@ -92,7 +92,6 @@ import { useComposerCommands } from "@/pages/session/use-composer-commands"
import { useSessionCommands } from "@/pages/session/use-session-commands"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { Identifier } from "@/utils/id"
import { diffs as list } from "@/utils/diffs"
import { Persist, persisted } from "@/utils/persist"
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
import { requireServerKey, sessionHref } from "@/utils/session-route"
@@ -439,11 +438,29 @@ export default function Page() {
createEffect(
on(
() => lastUserMessage()?.id,
() => [lastUserMessage(), controller.data.info()] as const,
() => {
const msg = lastUserMessage()
if (!msg) return
syncSessionModel(local, msg)
const message = lastUserMessage()
const info = controller.data.info()
const metadata = message?.metadata
const agent = typeof metadata?.agent === "string" ? metadata.agent : info?.agent
const model = metadata?.model
const selected =
model &&
typeof model === "object" &&
!Array.isArray(model) &&
typeof model.providerID === "string" &&
typeof model.modelID === "string"
? {
providerID: model.providerID,
modelID: model.modelID,
variant: typeof model.variant === "string" ? model.variant : undefined,
}
: info?.model
? { providerID: info.model.providerID, modelID: info.model.id, variant: info.model.variant }
: undefined
if (!info || !agent || !selected) return
syncSessionModel(local, { sessionID: info.id, agent, model: selected })
},
),
)
@@ -520,8 +537,6 @@ export default function Page() {
return open
}, desktopReviewOpen())
// TODO: Restore turn diffs when current transcript projections expose message summaries.
const turnDiffs = createMemo(() => list(undefined))
const nogit = createMemo(() => {
const current = project()
return !!current && current.vcs !== "git"
@@ -539,7 +554,6 @@ export default function Page() {
) {
list.push("branch")
}
list.push("turn")
return list
})
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
@@ -602,7 +616,7 @@ export default function Page() {
}, 100)
onCleanup(
sdk().event.listen((event) => {
if (event.details.type === "filesystem.changed") refreshVcs()
if (event.type === "filesystem.changed") refreshVcs()
}),
)
createEffect(
@@ -619,7 +633,8 @@ export default function Page() {
if (reviewMode() === "git" || reviewMode() === "branch")
// avoids suspense
return vcsQuery.isFetched ? (vcsQuery.data ?? []) : []
return turnDiffs()
// TODO: Restore turn diffs when the V2 transcript exposes snapshot diffs.
return []
}
const activeReviewFile = () => {
const diffs = reviewDiffs()
@@ -685,7 +700,7 @@ export default function Page() {
return "main"
})
const setActiveMessage = (message: UserMessage | undefined) => {
const setActiveMessage = (message: SessionMessageUser | undefined) => {
messageMark = scrollMark
setStore("messageId", message?.id)
}

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