mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-18 13:49:25 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96902d1a10 |
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,52 @@
|
||||
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
|
||||
@@ -1,204 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -277,7 +277,8 @@ 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 ""
|
||||
|
||||
@@ -50,8 +50,7 @@ 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
|
||||
|
||||
@@ -90,8 +90,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",
|
||||
@@ -196,6 +196,7 @@ export const ProjectSettingsExtensions: Component = () => {
|
||||
<SharedSection count={serverSkills().length}>{skillRows(serverSkills())}</SharedSection>
|
||||
</div>
|
||||
</TabsV2.Content>
|
||||
|
||||
</TabsV2>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -63,10 +63,7 @@ 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")
|
||||
@@ -200,7 +197,8 @@ 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 = (
|
||||
|
||||
@@ -181,7 +181,8 @@ 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()) },
|
||||
@@ -304,7 +305,9 @@ 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>
|
||||
|
||||
@@ -27,16 +27,15 @@ 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 pluginCount = createMemo(() => plugins().length)
|
||||
|
||||
@@ -78,14 +78,16 @@ 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",
|
||||
|
||||
@@ -86,4 +86,5 @@ describe("query keys", () => {
|
||||
{ id: "b", sandboxes: [] },
|
||||
])
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -233,10 +233,7 @@ 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,5 +1,9 @@
|
||||
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,4 +1,8 @@
|
||||
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"
|
||||
|
||||
@@ -13,7 +13,10 @@ 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(() => ({
|
||||
@@ -41,7 +44,9 @@ 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(),
|
||||
|
||||
@@ -26,8 +26,7 @@ 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,
|
||||
|
||||
@@ -5,7 +5,12 @@ 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"
|
||||
@@ -242,7 +247,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
const existing = children.children[key]
|
||||
if (!existing) return
|
||||
children.mark(key)
|
||||
if (eventType === "config.updated" || eventType === "agent.updated") queue.push(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")
|
||||
|
||||
@@ -57,16 +57,17 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
? homeSessionIndexKey(ServerConnection.key(conn))
|
||||
: (["home", "session-index", "unselected"] 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
|
||||
? 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,
|
||||
retry: false,
|
||||
staleTime: 30_000,
|
||||
refetchOnMount: true,
|
||||
|
||||
@@ -15,14 +15,9 @@ 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,9 +88,7 @@ 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))
|
||||
|
||||
@@ -40,9 +40,7 @@ export function createPromptInputController(input: {
|
||||
model: {
|
||||
selection: input.model ?? local.model,
|
||||
paid: providers.paid().length > 0,
|
||||
loading:
|
||||
(local.agent.visible() && data.location.agent.list({ directory: sdk().directory }) === undefined) ||
|
||||
!providers.ready(),
|
||||
loading: (local.agent.visible() && data.location.agent.list({ directory: sdk().directory }) === undefined) || !providers.ready(),
|
||||
},
|
||||
session: {
|
||||
id: input.sessionID(),
|
||||
|
||||
@@ -72,7 +72,9 @@ export function SessionComposerRegion(props: {
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
|
||||
<div
|
||||
class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none"
|
||||
>
|
||||
{controller.handoffPrompt() || language.t("prompt.loading")}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -53,9 +53,9 @@ export function createSessionComposerController() {
|
||||
if (!primary()) return []
|
||||
const id = params.id
|
||||
if (!id) return []
|
||||
const assistant = data.session.message
|
||||
.list(id)
|
||||
.findLast((message) => message.type === "assistant" && message.time.completed === undefined)
|
||||
const assistant = data.session.message.list(id).findLast(
|
||||
(message) => message.type === "assistant" && message.time.completed === undefined,
|
||||
)
|
||||
if (assistant?.type !== "assistant") return []
|
||||
return assistant.content.flatMap((part) => {
|
||||
if (part.type !== "tool" || part.state.status !== "running") return []
|
||||
@@ -149,12 +149,14 @@ export function createSessionComposerController() {
|
||||
if (!primary()) return
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
await serverSDK.api.session.background({ sessionID }).catch((error) => {
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
await serverSDK.api.session
|
||||
.background({ sessionID })
|
||||
.catch((error) => {
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
|
||||
@@ -44,6 +44,7 @@ export function createTimelineModel(input: { session: Pick<SessionController, "i
|
||||
userMessages: input.session.history.userMessages,
|
||||
visibleUserMessages: input.session.history.visibleUserMessages,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export function isTimelineReady(messages: Message[] | undefined, loading: boolean) {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { $ } from "bun"
|
||||
import pkg from "../package.json"
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { fileURLToPath } from "url"
|
||||
import { existsSync } from "fs"
|
||||
import { UpdateArtifact } from "../../../script/update-artifact"
|
||||
|
||||
const dir = fileURLToPath(new URL("..", import.meta.url))
|
||||
@@ -80,14 +79,12 @@ await publishDistribution({
|
||||
binary: "opencode2",
|
||||
packagePrefix: "@opencode-ai/cli-",
|
||||
})
|
||||
if (existsSync("./dist/node")) {
|
||||
await publishDistribution({
|
||||
root: "./dist/node",
|
||||
name: "opencode-node",
|
||||
binary: "opencode2-node",
|
||||
packagePrefix: "@opencode-ai/cli-node-",
|
||||
})
|
||||
}
|
||||
await publishDistribution({
|
||||
root: "./dist/node",
|
||||
name: "opencode-node",
|
||||
binary: "opencode2-node",
|
||||
packagePrefix: "@opencode-ai/cli-node-",
|
||||
})
|
||||
await UpdateArtifact.publish({
|
||||
channel: Script.channel,
|
||||
name: "cli",
|
||||
|
||||
@@ -89,6 +89,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
|
||||
? "opencode.db"
|
||||
: `opencode-${OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`),
|
||||
wal: process.env.OPENCODE_DB_WAL === undefined ? undefined : truthy(process.env.OPENCODE_DB_WAL),
|
||||
},
|
||||
models: {
|
||||
url: process.env.OPENCODE_MODELS_URL,
|
||||
|
||||
@@ -5,7 +5,6 @@ export type CommandApi = Client["command"]
|
||||
export type ConfigApi = Client["config"]
|
||||
export type EventApi = Client["event"]
|
||||
export type IntegrationApi = Client["integration"]
|
||||
export type McpApi = Client["mcp"]
|
||||
export type ModelApi = Client["model"]
|
||||
export type PluginApi = Client["plugin"]
|
||||
export type ProviderApi = Client["provider"]
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
export * as ConfigMCPPlugin from "./mcp.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { MCP } from "../../mcp/index.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.mcp",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* register(ctx.event.subscribe())
|
||||
}),
|
||||
})
|
||||
|
||||
export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
|
||||
events: Stream.Stream<{ readonly type: string }, unknown>,
|
||||
) {
|
||||
const config = yield* Config.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const loaded = { entries: [] as Entry[] }
|
||||
|
||||
yield* events.pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() =>
|
||||
config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
Effect.andThen(mcp.reload()),
|
||||
Effect.catchCause((cause) => Effect.logError("failed to reload MCP config", { cause })),
|
||||
),
|
||||
),
|
||||
Effect.ignore,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
// Subscribe before the initial load so updates racing it trigger a rebuild.
|
||||
loaded.entries = yield* config.entries()
|
||||
yield* mcp.transform((draft) => {
|
||||
const documents = loaded.entries.filter((entry): entry is Document => entry.type === "document")
|
||||
// Global timeout defaults merge in config order; each server can override them.
|
||||
const timeout = Object.assign(
|
||||
{},
|
||||
...documents.flatMap((entry) => (entry.info.mcp?.timeout ? [entry.info.mcp.timeout] : [])),
|
||||
)
|
||||
const servers = new Map<string, Mcp.ServerConfig>()
|
||||
for (const document of documents) {
|
||||
for (const [name, server] of Object.entries(document.info.mcp?.servers ?? {})) {
|
||||
servers.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
|
||||
}
|
||||
}
|
||||
for (const [name, server] of servers) {
|
||||
if (draft.get(name)) continue
|
||||
draft.set(name, server)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -18,6 +18,7 @@ export interface Interface {
|
||||
|
||||
export const Options = Schema.Struct({
|
||||
path: Schema.optional(Schema.String),
|
||||
wal: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
export type Options = typeof Options.Type
|
||||
|
||||
@@ -29,11 +30,9 @@ const databaseLayer = Layer.effect(
|
||||
const db = yield* makeDatabase
|
||||
|
||||
if (supportsTuningPragmas) {
|
||||
yield* db.run("PRAGMA journal_mode = WAL")
|
||||
yield* db.run("PRAGMA synchronous = NORMAL")
|
||||
yield* db.run("PRAGMA busy_timeout = 5000")
|
||||
yield* db.run("PRAGMA cache_size = -64000")
|
||||
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
}
|
||||
// Durable Object SQLite always enforces foreign keys and rejects the pragma.
|
||||
if (supportsForeignKeyToggle) yield* db.run("PRAGMA foreign_keys = ON")
|
||||
@@ -46,7 +45,8 @@ const databaseLayer = Layer.effect(
|
||||
export function layer(options: Options = { path: ":memory:" }) {
|
||||
return Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const provide = (filename: string) => layerFromClient.pipe(Layer.provide(sqliteLayer({ filename })))
|
||||
const provide = (filename: string) =>
|
||||
layerFromClient.pipe(Layer.provide(sqliteLayer({ filename, wal: options.wal })))
|
||||
const filename = options.path ?? ":memory:"
|
||||
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
|
||||
const global = yield* Global.Service
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { statfsSync } from "node:fs"
|
||||
|
||||
const NETWORK_FILESYSTEM_TYPES = new Set([
|
||||
0x0000517b, // SMB
|
||||
0x01021997, // 9P
|
||||
0x65735546, // FUSE (including VirtioFS and SSHFS)
|
||||
0x00006969, // NFS
|
||||
0xff534d42, // CIFS
|
||||
])
|
||||
|
||||
export function isNetworkFilesystemType(type: number) {
|
||||
return NETWORK_FILESYSTEM_TYPES.has(type >>> 0)
|
||||
}
|
||||
|
||||
export function isNetworkFilesystem(filename: string) {
|
||||
return isNetworkFilesystemType(statfsSync(filename).type)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Context, Effect, Layer } from "effect"
|
||||
import { Reactivity } from "effect/unstable/reactivity"
|
||||
import { SqlClient } from "effect/unstable/sql"
|
||||
import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"
|
||||
import { isNetworkFilesystem } from "./network-filesystem.js"
|
||||
import { Sqlite } from "./sqlite.js"
|
||||
|
||||
const TypeId = "~@opencode-ai/core/database/SqliteBun" as const
|
||||
@@ -17,7 +18,7 @@ interface Config extends Sqlite.ClientConfig {
|
||||
readonly readonly?: boolean
|
||||
readonly create?: boolean
|
||||
readonly readwrite?: boolean
|
||||
readonly disableWAL?: boolean
|
||||
readonly wal?: boolean
|
||||
}
|
||||
|
||||
const make = (options: Config) =>
|
||||
@@ -90,7 +91,12 @@ const nativeLayer = (config: Config) =>
|
||||
create: config.create ?? true,
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => native.close()))
|
||||
if (config.disableWAL !== true) native.run("PRAGMA journal_mode = WAL;")
|
||||
const wal = config.filename !== ":memory:" && (config.wal ?? !isNetworkFilesystem(config.filename))
|
||||
if (wal) {
|
||||
native.run("PRAGMA journal_mode = WAL;")
|
||||
native.run("PRAGMA wal_checkpoint(PASSIVE);")
|
||||
}
|
||||
if (!wal && config.filename !== ":memory:") native.run("PRAGMA journal_mode = DELETE;")
|
||||
return native
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Context, Effect, Layer } from "effect"
|
||||
import { Reactivity } from "effect/unstable/reactivity"
|
||||
import { SqlClient } from "effect/unstable/sql"
|
||||
import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"
|
||||
import { isNetworkFilesystem } from "./network-filesystem.js"
|
||||
import { Sqlite } from "./sqlite.js"
|
||||
|
||||
const TypeId = "~@opencode-ai/core/database/SqliteNode" as const
|
||||
@@ -17,7 +18,7 @@ interface Config extends Sqlite.ClientConfig {
|
||||
readonly readonly?: boolean
|
||||
readonly create?: boolean
|
||||
readonly readwrite?: boolean
|
||||
readonly disableWAL?: boolean
|
||||
readonly wal?: boolean
|
||||
readonly timeout?: number
|
||||
readonly allowExtension?: boolean
|
||||
}
|
||||
@@ -87,7 +88,13 @@ const nativeLayer = (config: Config) =>
|
||||
open: true,
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => native.close()))
|
||||
if (config.disableWAL !== true && config.readonly !== true) native.exec("PRAGMA journal_mode = WAL;")
|
||||
const wal = config.filename !== ":memory:" && (config.wal ?? !isNetworkFilesystem(config.filename))
|
||||
if (wal && config.readonly !== true) {
|
||||
native.exec("PRAGMA journal_mode = WAL;")
|
||||
native.exec("PRAGMA wal_checkpoint(PASSIVE);")
|
||||
}
|
||||
if (!wal && config.filename !== ":memory:" && config.readonly !== true)
|
||||
native.exec("PRAGMA journal_mode = DELETE;")
|
||||
return native
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -114,23 +114,6 @@ type NextProject = {
|
||||
readonly commands: string | null
|
||||
}
|
||||
|
||||
type NextColumns<A> = Record<keyof A, "required" | "nullable" | { readonly fallback: keyof A & string }>
|
||||
|
||||
const NEXT_PROJECT_COLUMNS = {
|
||||
id: "required",
|
||||
worktree: "required",
|
||||
vcs: "nullable",
|
||||
name: "nullable",
|
||||
icon_url: "nullable",
|
||||
icon_url_override: { fallback: "icon_url" },
|
||||
icon_color: "nullable",
|
||||
time_created: "required",
|
||||
time_updated: "required",
|
||||
time_initialized: "nullable",
|
||||
sandboxes: "required",
|
||||
commands: "nullable",
|
||||
} satisfies NextColumns<NextProject>
|
||||
|
||||
type NextSession = {
|
||||
readonly id: string
|
||||
readonly project_id: string
|
||||
@@ -166,41 +149,6 @@ type NextSession = {
|
||||
readonly time_suspended: number | null
|
||||
}
|
||||
|
||||
const NEXT_SESSION_COLUMNS = {
|
||||
id: "required",
|
||||
project_id: "required",
|
||||
workspace_id: "nullable",
|
||||
parent_id: "nullable",
|
||||
fork_session_id: "nullable",
|
||||
fork_boundary: "nullable",
|
||||
slug: "required",
|
||||
directory: "required",
|
||||
path: "nullable",
|
||||
title: "nullable",
|
||||
version: "required",
|
||||
share_url: "nullable",
|
||||
summary_additions: "nullable",
|
||||
summary_deletions: "nullable",
|
||||
summary_files: "nullable",
|
||||
summary_diffs: "nullable",
|
||||
metadata: "nullable",
|
||||
cost: "required",
|
||||
tokens_input: "required",
|
||||
tokens_output: "required",
|
||||
tokens_reasoning: "required",
|
||||
tokens_cache_read: "required",
|
||||
tokens_cache_write: "required",
|
||||
revert: "nullable",
|
||||
permission: "nullable",
|
||||
agent: "nullable",
|
||||
model: "nullable",
|
||||
time_created: "required",
|
||||
time_updated: "required",
|
||||
time_compacting: "nullable",
|
||||
time_archived: "nullable",
|
||||
time_suspended: "nullable",
|
||||
} satisfies NextColumns<NextSession>
|
||||
|
||||
type NextMessage = {
|
||||
readonly id: string
|
||||
readonly session_id: string
|
||||
@@ -738,9 +686,12 @@ function importNextDatabase(
|
||||
}),
|
||||
)
|
||||
const projects = new Map(
|
||||
selectNextRows<NextProject>(source, "project", NEXT_PROJECT_COLUMNS).map((project) => [project.id, project]),
|
||||
source
|
||||
.query<NextProject, []>("SELECT * FROM project")
|
||||
.all()
|
||||
.map((project) => [project.id, project]),
|
||||
)
|
||||
const sessions = selectNextRows<NextSession>(source, "session", NEXT_SESSION_COLUMNS)
|
||||
const sessions = source.query<NextSession, []>("SELECT * FROM session ORDER BY id DESC").all()
|
||||
for (const [index, session] of sessions.entries()) {
|
||||
const project = projects.get(session.project_id)
|
||||
const projectID = project ? session.project_id : Project.ID.global
|
||||
@@ -838,35 +789,6 @@ function isNextDatabase(source: SQLiteDatabase) {
|
||||
return tables.has("project") && tables.has("session") && tables.has("session_message")
|
||||
}
|
||||
|
||||
function selectNextRows<A>(source: SQLiteDatabase, table: "project" | "session", definition: NextColumns<A>) {
|
||||
const columns = new Set(
|
||||
source
|
||||
.query<{ name: string }, [string]>("SELECT name FROM pragma_table_info(?)")
|
||||
.all(table)
|
||||
.map((column) => column.name),
|
||||
)
|
||||
const missing = Object.entries(definition)
|
||||
.filter(([column, strategy]) => strategy === "required" && !columns.has(column))
|
||||
.map(([column]) => column)
|
||||
if (missing.length)
|
||||
throw new Error(`Incompatible opencode-next.db: ${table} is missing required columns: ${missing.join(", ")}`)
|
||||
const projection = Object.entries(definition).map(([column, strategy]) => {
|
||||
if (columns.has(column)) return `"${column}"`
|
||||
if (
|
||||
typeof strategy === "object" &&
|
||||
strategy !== null &&
|
||||
"fallback" in strategy &&
|
||||
typeof strategy.fallback === "string" &&
|
||||
columns.has(strategy.fallback)
|
||||
)
|
||||
return `"${strategy.fallback}" AS "${column}"`
|
||||
return `NULL AS "${column}"`
|
||||
})
|
||||
return source
|
||||
.query<A, []>(`SELECT ${projection.join(", ")} FROM "${table}"${table === "session" ? ' ORDER BY "id" DESC' : ""}`)
|
||||
.all()
|
||||
}
|
||||
|
||||
function row(
|
||||
source: SourceMessage,
|
||||
message: {
|
||||
|
||||
+98
-114
@@ -3,10 +3,13 @@ export * as MCP from "./index.js"
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { Command } from "@opencode-ai/schema/command"
|
||||
import { Document, Event, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
import { createHash } from "node:crypto"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Stream, Types } from "effect"
|
||||
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Semaphore, Stream } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Config } from "../config.js"
|
||||
import { Credential } from "../credential.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Environment } from "../environment/index.js"
|
||||
@@ -109,7 +112,7 @@ export class ToolCallError extends Schema.TaggedError<ToolCallError>()("MCP.Tool
|
||||
}) {}
|
||||
|
||||
type ServerEntry = {
|
||||
readonly config: Mcp.ServerConfig
|
||||
readonly config: typeof ConfigMCP.Server.Type
|
||||
status: Status
|
||||
readonly startup: Deferred.Deferred<void>
|
||||
scope?: Scope.Closeable
|
||||
@@ -126,25 +129,9 @@ type ServerEntry = {
|
||||
const GLOBAL_ELICITATION_SESSION_ID = "global"
|
||||
const URL_ELICITATION_FIELD_KEY = "elicitation"
|
||||
|
||||
type Data = {
|
||||
servers: Map<ServerName, Types.DeepMutable<Mcp.ServerConfig>>
|
||||
removed: Set<ServerName>
|
||||
}
|
||||
|
||||
export type Draft = {
|
||||
list: () => readonly [ServerName, Types.DeepMutable<Mcp.ServerConfig>][]
|
||||
get: (server: ServerName | string) => Types.DeepMutable<Mcp.ServerConfig> | undefined
|
||||
set: (server: ServerName | string, config: Mcp.ServerConfig) => void
|
||||
update: (server: ServerName | string, update: (config: Types.DeepMutable<Mcp.ServerConfig>) => void) => void
|
||||
remove: (server: ServerName | string) => void
|
||||
}
|
||||
|
||||
const cloneConfig = (config: Mcp.ServerConfig) =>
|
||||
structuredClone(config) as Types.DeepMutable<Mcp.ServerConfig>
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
export interface Interface {
|
||||
readonly servers: () => Effect.Effect<ServerInfo[]>
|
||||
readonly add: (server: ServerName | string, config: Mcp.ServerConfig) => Effect.Effect<void>
|
||||
readonly add: (server: ServerName | string, config: typeof ConfigMCP.Server.Type) => Effect.Effect<void>
|
||||
readonly connect: (server: ServerName | string) => Effect.Effect<void, NotFoundError>
|
||||
readonly disconnect: (server: ServerName | string) => Effect.Effect<void, NotFoundError>
|
||||
readonly remove: (server: ServerName | string) => Effect.Effect<void, NotFoundError>
|
||||
@@ -184,6 +171,7 @@ export const layer = (options?: Options) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const location = yield* Location.Service
|
||||
const environment = yield* Environment.Service
|
||||
const bus = yield* Bus.Service
|
||||
@@ -193,13 +181,37 @@ export const layer = (options?: Options) =>
|
||||
const root = yield* Effect.scope
|
||||
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
|
||||
// Materialized definitions and live connections are kept separate so operational additions
|
||||
// survive unrelated definition reloads.
|
||||
const entries = new Map<ServerName, ServerEntry>()
|
||||
const loadConfig = (entries: readonly Entry[]) => {
|
||||
const documents = entries.filter((entry): entry is Document => entry.type === "document")
|
||||
// Global MCP timeout defaults, later config files overriding earlier ones.
|
||||
const timeout = Object.assign(
|
||||
{},
|
||||
...documents.flatMap((entry) => (entry.info.mcp?.timeout ? [entry.info.mcp.timeout] : [])),
|
||||
)
|
||||
const servers = new Map<ServerName, typeof ConfigMCP.Server.Type>()
|
||||
for (const entry of documents) {
|
||||
for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) {
|
||||
servers.set(ServerName.make(name), { ...server, timeout: { ...timeout, ...server.timeout } })
|
||||
}
|
||||
}
|
||||
return { timeout, servers }
|
||||
}
|
||||
const initial = loadConfig(yield* config.entries())
|
||||
const configState = { servers: initial.servers, timeout: initial.timeout }
|
||||
// Later config files win for duplicate server names; per-server timeout overrides globals.
|
||||
const runtime = new Map<ServerName, ServerEntry>()
|
||||
// Serializes lifecycle operations per server. Anything taking this lock from a connection
|
||||
// callback must stay forked: lifecycle operations close scopes while holding it, firing onClose.
|
||||
const locks = KeyedMutex.makeUnsafe<ServerName>()
|
||||
const reloadLock = Semaphore.makeUnsafe(1)
|
||||
const urlElicitations = new Map<string, Form.ID>()
|
||||
for (const [name, server] of initial.servers) {
|
||||
runtime.set(name, {
|
||||
config: server,
|
||||
status: { status: "pending" },
|
||||
startup: Deferred.makeUnsafe<void>(),
|
||||
})
|
||||
}
|
||||
|
||||
// Register every remote server as an OAuth integration so credentials live in the global store
|
||||
// rather than in committed config. Servers that connect anonymously simply never use the method.
|
||||
@@ -241,9 +253,11 @@ export const layer = (options?: Options) =>
|
||||
})
|
||||
.pipe(Scope.provide(scope))
|
||||
})
|
||||
yield* Effect.forEach(runtime, ([name, entry]) => register(name, entry), { discard: true })
|
||||
|
||||
const requireServer = Effect.fnUntraced(function* (server: ServerName | string) {
|
||||
const name = ServerName.make(server)
|
||||
const entry = entries.get(name)
|
||||
const entry = runtime.get(name)
|
||||
if (!entry) return yield* new NotFoundError({ server: name })
|
||||
return { name, entry }
|
||||
})
|
||||
@@ -424,11 +438,13 @@ export const layer = (options?: Options) =>
|
||||
|
||||
const refreshPrompts = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
|
||||
connection.prompts().pipe(
|
||||
Effect.catch(() => Effect.succeed([])),
|
||||
Effect.map((defs) => {
|
||||
entry.prompts = defs.map((def) => toPrompt(name, def))
|
||||
}),
|
||||
Effect.andThen(bus.publish(Command.Event.Updated, {})),
|
||||
Effect.catch(() =>
|
||||
Effect.sync(() => (entry.prompts = [])).pipe(Effect.andThen(bus.publish(Command.Event.Updated, {}))),
|
||||
),
|
||||
)
|
||||
|
||||
// Runs a connection callback under the server lock, dropping it if the connection is no longer
|
||||
@@ -556,15 +572,15 @@ export const layer = (options?: Options) =>
|
||||
if (entry.registration) yield* entry.registration.dispose
|
||||
})
|
||||
|
||||
const replaceServer = Effect.fnUntraced(function* (name: ServerName, serverConfig: Mcp.ServerConfig) {
|
||||
const previous = entries.get(name)
|
||||
const replaceServer = Effect.fnUntraced(function* (name: ServerName, serverConfig: typeof ConfigMCP.Server.Type) {
|
||||
const previous = runtime.get(name)
|
||||
if (previous) yield* disposeServer(name, previous)
|
||||
const entry: ServerEntry = {
|
||||
config: serverConfig,
|
||||
status: { status: "pending" },
|
||||
startup: Deferred.makeUnsafe<void>(),
|
||||
}
|
||||
entries.set(name, entry)
|
||||
runtime.set(name, entry)
|
||||
yield* Effect.gen(function* () {
|
||||
yield* register(name, entry)
|
||||
if (serverConfig.disabled) {
|
||||
@@ -580,66 +596,55 @@ export const layer = (options?: Options) =>
|
||||
})
|
||||
|
||||
const removeServer = Effect.fnUntraced(function* (name: ServerName) {
|
||||
const entry = entries.get(name)
|
||||
const entry = runtime.get(name)
|
||||
if (!entry) return
|
||||
yield* disposeServer(name, entry)
|
||||
// Credentials are keyed by name + URL and intentionally survive removal for a later re-add.
|
||||
entries.delete(name)
|
||||
runtime.delete(name)
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
let applied: Map<ServerName, Mcp.ServerConfig> | undefined
|
||||
const overrides = new Map<ServerName, Mcp.ServerConfig | false>()
|
||||
const reconcile = Effect.fnUntraced(function* (next: Draft) {
|
||||
const servers = new Map(next.list())
|
||||
if (!applied && entries.size === 0) {
|
||||
for (const [name, server] of servers) {
|
||||
entries.set(name, {
|
||||
config: server,
|
||||
status: { status: "pending" },
|
||||
startup: Deferred.makeUnsafe<void>(),
|
||||
})
|
||||
}
|
||||
yield* Effect.forEach(entries, ([name, entry]) => register(name, entry), { discard: true })
|
||||
applied = servers
|
||||
|
||||
// Initial connections stay asynchronous so one slow server does not block Location startup.
|
||||
for (const [name, entry] of entries) {
|
||||
if (entry.config.disabled) {
|
||||
entry.status = { status: "disabled" }
|
||||
Deferred.doneUnsafe(entry.startup, Exit.void)
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
continue
|
||||
const reloadConfig = Effect.fnUntraced(function* () {
|
||||
yield* reloadLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const next = loadConfig(yield* config.entries())
|
||||
const names = new Set([...configState.servers.keys(), ...next.servers.keys()])
|
||||
for (const name of names) {
|
||||
const previous = configState.servers.get(name)
|
||||
const updated = next.servers.get(name)
|
||||
if (isDeepStrictEqual(previous, updated)) continue
|
||||
if (!updated) {
|
||||
yield* removeServer(name).pipe(locks.withLock(name))
|
||||
continue
|
||||
}
|
||||
yield* replaceServer(name, updated).pipe(locks.withLock(name))
|
||||
}
|
||||
fork(startServer(name, entry).pipe(locks.withLock(name)))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const names = new Set([...(applied?.keys() ?? []), ...servers.keys()])
|
||||
for (const name of names) {
|
||||
const previous = applied?.get(name)
|
||||
const updated = servers.get(name)
|
||||
if (isDeepStrictEqual(previous, updated)) continue
|
||||
if (!updated) {
|
||||
yield* removeServer(name).pipe(locks.withLock(name))
|
||||
continue
|
||||
}
|
||||
yield* replaceServer(name, updated).pipe(locks.withLock(name))
|
||||
}
|
||||
applied = servers
|
||||
configState.servers = next.servers
|
||||
configState.timeout = next.timeout
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// Disabled servers settle their startup immediately so queries never block on them.
|
||||
for (const [name, entry] of runtime) {
|
||||
if (entry.config.disabled) {
|
||||
entry.status = { status: "disabled" }
|
||||
Deferred.doneUnsafe(entry.startup, Exit.void)
|
||||
continue
|
||||
}
|
||||
fork(startServer(name, entry).pipe(locks.withLock(name)))
|
||||
}
|
||||
|
||||
// Bring a server online (or back to needs_auth) when its integration's credential changes, so an
|
||||
// OAuth login takes effect without a restart. Only fires for the integrations we registered.
|
||||
const reconnect = (integrationID: Integration.ID) =>
|
||||
Effect.gen(function* () {
|
||||
const match = Array.from(entries).find(([, entry]) => entry.integrationID === integrationID)
|
||||
const match = Array.from(runtime).find(([, entry]) => entry.integrationID === integrationID)
|
||||
if (!match) return
|
||||
const name = match[0]
|
||||
yield* Effect.gen(function* () {
|
||||
// add() or remove() may have replaced or deleted the entry while we waited for the lock.
|
||||
const entry = entries.get(name)
|
||||
const entry = runtime.get(name)
|
||||
if (!entry || entry.integrationID !== integrationID) return
|
||||
if (entry.status.status === "disabled") return
|
||||
yield* stopServer(name, entry)
|
||||
@@ -653,55 +658,33 @@ export const layer = (options?: Options) =>
|
||||
Effect.ignore,
|
||||
),
|
||||
)
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "mcp",
|
||||
initial: () => ({
|
||||
servers: new Map(
|
||||
Array.from(overrides).flatMap(([name, config]) =>
|
||||
config === false ? [] : [[name, cloneConfig(config)] as const],
|
||||
),
|
||||
),
|
||||
removed: new Set(
|
||||
Array.from(overrides).flatMap(([name, config]) => (config === false ? [name] : [])),
|
||||
),
|
||||
}),
|
||||
draft: (draft) => ({
|
||||
list: () => Array.from(draft.servers),
|
||||
get: (server) => draft.servers.get(ServerName.make(server)),
|
||||
set: (server, serverConfig) => {
|
||||
const name = ServerName.make(server)
|
||||
if (draft.removed.has(name)) return
|
||||
draft.servers.set(name, cloneConfig(serverConfig))
|
||||
},
|
||||
update: (server, update) => {
|
||||
const current = draft.servers.get(ServerName.make(server))
|
||||
if (!current) return
|
||||
update(current)
|
||||
},
|
||||
remove: (server) => draft.servers.delete(ServerName.make(server)),
|
||||
}),
|
||||
finalize: reconcile,
|
||||
})
|
||||
yield* bus.subscribe(Event.Updated).pipe(
|
||||
Stream.runForEach(() =>
|
||||
reloadConfig().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload MCP config", { cause }))),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
// Close the gap between the initial snapshot and the live subscription becoming active.
|
||||
yield* reloadConfig()
|
||||
|
||||
// Suspend so each await sees current entries; a bare Map iterator is exhausted after one run.
|
||||
const whenAllReady = Effect.suspend(() =>
|
||||
Effect.forEach(Array.from(entries.values()), (entry) => Deferred.await(entry.startup), {
|
||||
Effect.forEach(Array.from(runtime.values()), (entry) => Deferred.await(entry.startup), {
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
}),
|
||||
)
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
servers: Effect.fn("MCP.servers")(function* () {
|
||||
return Array.from(entries)
|
||||
return Array.from(runtime)
|
||||
.toSorted(([a], [b]) => a.localeCompare(b))
|
||||
.map(([name, entry]) => new ServerInfo({ name, status: entry.status, integrationID: entry.integrationID }))
|
||||
}),
|
||||
add: Effect.fn("MCP.add")(function* (server, config) {
|
||||
const name = ServerName.make(server)
|
||||
overrides.set(name, config)
|
||||
yield* state.reload()
|
||||
yield* replaceServer(name, { ...config, timeout: { ...configState.timeout, ...config.timeout } }).pipe(
|
||||
locks.withLock(name),
|
||||
)
|
||||
}),
|
||||
connect: Effect.fn("MCP.connect")(function* (server) {
|
||||
const name = ServerName.make(server)
|
||||
@@ -722,13 +705,14 @@ export const layer = (options?: Options) =>
|
||||
}),
|
||||
remove: Effect.fn("MCP.remove")(function* (server) {
|
||||
const name = ServerName.make(server)
|
||||
yield* requireServer(name)
|
||||
overrides.set(name, false)
|
||||
yield* state.reload()
|
||||
yield* Effect.gen(function* () {
|
||||
yield* requireServer(name)
|
||||
yield* removeServer(name)
|
||||
}).pipe(locks.withLock(name))
|
||||
}),
|
||||
tools: Effect.fn("MCP.tools")(function* () {
|
||||
yield* whenAllReady
|
||||
return Array.from(entries.values())
|
||||
return Array.from(runtime.values())
|
||||
.flatMap((entry) => entry.tools ?? [])
|
||||
.toSorted((a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name))
|
||||
}),
|
||||
@@ -758,7 +742,7 @@ export const layer = (options?: Options) =>
|
||||
}),
|
||||
instructions: Effect.fn("MCP.instructions")(function* () {
|
||||
yield* whenAllReady
|
||||
return Array.from(entries)
|
||||
return Array.from(runtime)
|
||||
.flatMap(([server, entry]) => {
|
||||
const instructions = entry.client?.instructions
|
||||
if (!instructions) return []
|
||||
@@ -767,7 +751,7 @@ export const layer = (options?: Options) =>
|
||||
.toSorted((a, b) => a.server.localeCompare(b.server))
|
||||
}),
|
||||
prompts: Effect.fn("MCP.prompts")(function* () {
|
||||
return Array.from(entries.values())
|
||||
return Array.from(runtime.values())
|
||||
.flatMap((entry) => entry.prompts ?? [])
|
||||
.toSorted((a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name))
|
||||
}),
|
||||
@@ -790,7 +774,7 @@ export const layer = (options?: Options) =>
|
||||
resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () {
|
||||
yield* whenAllReady
|
||||
const catalogs = yield* Effect.forEach(
|
||||
Array.from(entries),
|
||||
Array.from(runtime),
|
||||
([name, entry]) => {
|
||||
if (!entry.client) return Effect.succeed({ resources: [], templates: [] })
|
||||
return Effect.all(
|
||||
@@ -847,7 +831,7 @@ export function configured(options?: Options) {
|
||||
return makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [Location.node, Environment.node, Bus.node, Form.node, Integration.node, Credential.node],
|
||||
deps: [Config.node, Location.node, Environment.node, Bus.node, Form.node, Integration.node, Credential.node],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Catalog } from "./catalog.js"
|
||||
import { Command } from "./command.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { MCP } from "./mcp/index.js"
|
||||
import { Location } from "./location.js"
|
||||
import { PluginHost } from "./plugin/host.js"
|
||||
import { PluginRuntime } from "./plugin/runtime.js"
|
||||
@@ -155,7 +154,6 @@ export const node = makeLocationNode({
|
||||
Catalog.node,
|
||||
Command.node,
|
||||
Integration.node,
|
||||
MCP.node,
|
||||
Location.node,
|
||||
Reference.node,
|
||||
Skill.node,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Plugin } from "@opencode-ai/plugin/effect"
|
||||
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
|
||||
import type { CredentialOAuth } from "@opencode-ai/sdk/v2/types"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { App } from "../app.js"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Agent } from "../agent.js"
|
||||
@@ -16,7 +15,6 @@ import { Bus } from "../bus.js"
|
||||
import { Integration } from "../integration.js"
|
||||
import { Location } from "../location.js"
|
||||
import { Model } from "../model.js"
|
||||
import { MCP } from "../mcp/index.js"
|
||||
import { PluginRuntime } from "./runtime.js"
|
||||
import { Provider } from "../provider.js"
|
||||
import { Reference } from "../reference.js"
|
||||
@@ -36,7 +34,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
const commands = yield* Command.Service
|
||||
const bus = yield* Bus.Service
|
||||
const integration = yield* Integration.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const location = yield* Location.Service
|
||||
const reference = yield* Reference.Service
|
||||
const skill = yield* Skill.Service
|
||||
@@ -272,44 +269,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
})
|
||||
}),
|
||||
},
|
||||
mcp: {
|
||||
list: (input) => {
|
||||
const ref = locationRef(input)
|
||||
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.list(ref)
|
||||
return response(mcp.servers())
|
||||
},
|
||||
add: (input) => {
|
||||
const ref = locationRef(input)
|
||||
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.add(ref, input.server, input.config)
|
||||
return mcp.add(input.server, input.config)
|
||||
},
|
||||
remove: (input) => {
|
||||
const ref = locationRef(input)
|
||||
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.remove(ref, input.server)
|
||||
return mcp.remove(input.server)
|
||||
},
|
||||
connect: (input) => {
|
||||
const ref = locationRef(input)
|
||||
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.connect(ref, input.server)
|
||||
return mcp.connect(input.server)
|
||||
},
|
||||
disconnect: (input) => {
|
||||
const ref = locationRef(input)
|
||||
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.disconnect(ref, input.server)
|
||||
return mcp.disconnect(input.server)
|
||||
},
|
||||
reload: mcp.reload,
|
||||
transform: (callback) =>
|
||||
mcp.transform((draft) => {
|
||||
callback({
|
||||
list: () => draft.list().map(([name, config]) => [name, mutable(config)]),
|
||||
get: (name) => mutable(draft.get(name)),
|
||||
set: (name, config) => draft.set(name, Schema.decodeUnknownSync(Mcp.ServerConfig)(config)),
|
||||
update: draft.update,
|
||||
remove: draft.remove,
|
||||
})
|
||||
}),
|
||||
},
|
||||
plugin: {
|
||||
list: () => response(plugin.list()),
|
||||
},
|
||||
|
||||
@@ -13,7 +13,6 @@ import { Credential } from "../credential.js"
|
||||
import { ConfigAgentPlugin } from "../config/plugin/agent.js"
|
||||
import { ConfigCommandPlugin } from "../config/plugin/command.js"
|
||||
import { ConfigInstructionPlugin } from "../config/plugin/instruction.js"
|
||||
import { ConfigMCPPlugin } from "../config/plugin/mcp.js"
|
||||
import { ConfigProviderPlugin } from "../config/plugin/provider.js"
|
||||
import { ConfigPolicyPlugin } from "../config/plugin/policy.js"
|
||||
import { ConfigReferencePlugin } from "../config/plugin/reference.js"
|
||||
@@ -35,7 +34,6 @@ import { KV } from "../kv.js"
|
||||
import { Location } from "../location.js"
|
||||
import { LocationMutation } from "../location-mutation.js"
|
||||
import { ModelsDev } from "../models-dev.js"
|
||||
import { MCP } from "../mcp/index.js"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Permission } from "../permission.js"
|
||||
import { Reference } from "../reference.js"
|
||||
@@ -65,7 +63,6 @@ import { AgentPlugin } from "./agent.js"
|
||||
import { CommandPlugin } from "./command.js"
|
||||
import { PlanPlugin } from "./plan.js"
|
||||
import { ModelsDevPlugin } from "./models-dev.js"
|
||||
import { MCPCodeModeExclusionPlugin } from "./mcp-codemode-exclusion.js"
|
||||
import { ProviderPlugins } from "./provider.js"
|
||||
import { WebSearchPlugins } from "./websearch/index.js"
|
||||
import { PluginRuntime } from "./runtime.js"
|
||||
@@ -97,7 +94,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const location = yield* Location.Service
|
||||
const locationMutation = yield* LocationMutation.Service
|
||||
const models = yield* ModelsDev.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const npm = yield* Npm.Service
|
||||
const permission = yield* Permission.Service
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
@@ -135,7 +131,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
Context.make(Location.Service, location),
|
||||
Context.make(LocationMutation.Service, locationMutation),
|
||||
Context.make(ModelsDev.Service, models),
|
||||
Context.make(MCP.Service, mcp),
|
||||
Context.make(Npm.Service, npm),
|
||||
Context.make(Permission.Service, permission),
|
||||
Context.make(PluginRuntime.Service, runtime),
|
||||
@@ -180,7 +175,6 @@ export const requirements = LayerNode.group([
|
||||
Location.node,
|
||||
LocationMutation.node,
|
||||
ModelsDev.node,
|
||||
MCP.node,
|
||||
Npm.node,
|
||||
Permission.node,
|
||||
PluginRuntime.node,
|
||||
@@ -201,8 +195,6 @@ export const requirements = LayerNode.group([
|
||||
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
|
||||
|
||||
const pre = [
|
||||
ConfigMCPPlugin.Plugin,
|
||||
MCPCodeModeExclusionPlugin.Plugin,
|
||||
WellKnownPlugin.Plugin,
|
||||
AgentPlugin.Plugin,
|
||||
PlanPlugin.Plugin,
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
export * as MCPCodeModeExclusionPlugin from "./mcp-codemode-exclusion.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect } from "effect"
|
||||
|
||||
// These servers provide Code Mode, so expose them directly instead of nesting them inside OpenCode Code Mode.
|
||||
const urls = [/^https:\/\/mcp\.cloudflare\.com\/mcp$/, /^https:\/\/executor\.sh\/[^/]+\/mcp$/]
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.mcp.codemode-exclusion",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.mcp.transform((draft) => {
|
||||
for (const [, server] of draft.list()) {
|
||||
if (server.codemode !== undefined) continue
|
||||
if (server.type === "local") {
|
||||
if (server.command[0] === "executor" && server.command[1] === "mcp") server.codemode = false
|
||||
continue
|
||||
}
|
||||
if (!URL.canParse(server.url)) continue
|
||||
const url = new URL(server.url)
|
||||
const endpoint = `${url.origin}${url.pathname.replace(/\/+$/, "")}`
|
||||
if (urls.some((pattern) => pattern.test(endpoint))) server.codemode = false
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -2,12 +2,10 @@ export * as PluginRuntime from "./runtime.js"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Job } from "../job.js"
|
||||
import { Location } from "../location.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { MCP } from "../mcp/index.js"
|
||||
import { Session } from "../session.js"
|
||||
|
||||
export interface Interface {
|
||||
@@ -32,15 +30,6 @@ export interface Interface {
|
||||
ref: Location.Ref,
|
||||
) => Effect.Effect<{ readonly location: Location.Info; readonly data: Agent.Info[] }>
|
||||
}
|
||||
readonly mcp: {
|
||||
readonly list: (
|
||||
ref: Location.Ref,
|
||||
) => Effect.Effect<{ readonly location: Location.Info; readonly data: MCP.ServerInfo[] }, unknown>
|
||||
readonly add: (ref: Location.Ref, server: string, config: Mcp.ServerConfig) => Effect.Effect<void, unknown>
|
||||
readonly remove: (ref: Location.Ref, server: string) => Effect.Effect<void, unknown>
|
||||
readonly connect: (ref: Location.Ref, server: string) => Effect.Effect<void, unknown>
|
||||
readonly disconnect: (ref: Location.Ref, server: string) => Effect.Effect<void, unknown>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,13 +79,6 @@ export const layerWithCell = (cell: Cell) =>
|
||||
agent: {
|
||||
list: (ref) => require(cell, (runtime) => runtime.location.agent.list(ref)),
|
||||
},
|
||||
mcp: {
|
||||
list: (ref) => require(cell, (runtime) => runtime.location.mcp.list(ref)),
|
||||
add: (ref, server, config) => require(cell, (runtime) => runtime.location.mcp.add(ref, server, config)),
|
||||
remove: (ref, server) => require(cell, (runtime) => runtime.location.mcp.remove(ref, server)),
|
||||
connect: (ref, server) => require(cell, (runtime) => runtime.location.mcp.connect(ref, server)),
|
||||
disconnect: (ref, server) => require(cell, (runtime) => runtime.location.mcp.disconnect(ref, server)),
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -126,29 +108,6 @@ export const providerLayerWithCell = (cell: Cell) =>
|
||||
}
|
||||
}).pipe(Effect.provide(locations.get(ref)), Effect.orDie),
|
||||
},
|
||||
mcp: {
|
||||
list: (ref) =>
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const mcp = yield* MCP.Service
|
||||
return {
|
||||
location: new Location.Info({
|
||||
directory: location.directory,
|
||||
workspaceID: location.workspaceID,
|
||||
project: location.project,
|
||||
}),
|
||||
data: yield* mcp.servers(),
|
||||
}
|
||||
}).pipe(Effect.provide(locations.get(ref))),
|
||||
add: (ref, server, config) =>
|
||||
MCP.Service.use((mcp) => mcp.add(server, config)).pipe(Effect.provide(locations.get(ref))),
|
||||
remove: (ref, server) =>
|
||||
MCP.Service.use((mcp) => mcp.remove(server)).pipe(Effect.provide(locations.get(ref))),
|
||||
connect: (ref, server) =>
|
||||
MCP.Service.use((mcp) => mcp.connect(server)).pipe(Effect.provide(locations.get(ref))),
|
||||
disconnect: (ref, server) =>
|
||||
MCP.Service.use((mcp) => mcp.disconnect(server)).pipe(Effect.provide(locations.get(ref))),
|
||||
},
|
||||
},
|
||||
}
|
||||
cell.runtime = runtime
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { isNetworkFilesystemType } from "@opencode-ai/core/database/network-filesystem"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { sql } from "drizzle-orm"
|
||||
import { Effect } from "effect"
|
||||
|
||||
test.each([
|
||||
["SMB", 0x0000517b],
|
||||
["9P", 0x01021997],
|
||||
["FUSE", 0x65735546],
|
||||
["NFS", 0x00006969],
|
||||
["CIFS", 0xff534d42],
|
||||
])("disables WAL on %s", (_name, type) => {
|
||||
expect(isNetworkFilesystemType(type)).toBe(true)
|
||||
})
|
||||
|
||||
test("keeps WAL on local filesystems", () => {
|
||||
expect(isNetworkFilesystemType(0xef53)).toBe(false)
|
||||
})
|
||||
|
||||
test("allows WAL to be disabled explicitly", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-database-"))
|
||||
try {
|
||||
const mode = await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
return yield* database.db.all<{ journal_mode: string }>(sql`PRAGMA journal_mode`)
|
||||
}).pipe(
|
||||
Effect.provide(Database.layer({ path: join(directory, "opencode.db"), wal: false })),
|
||||
Effect.provideService(Global.Service, Global.make({ data: directory })),
|
||||
Effect.scoped,
|
||||
),
|
||||
)
|
||||
expect(mode).toEqual([{ journal_mode: "delete" }])
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -8,8 +8,6 @@ import { location } from "./location"
|
||||
export const emptyMcpLayer = Layer.succeed(
|
||||
MCP.Service,
|
||||
MCP.Service.of({
|
||||
transform: () => Effect.die("unused mcp.transform"),
|
||||
reload: () => Effect.die("unused mcp.reload"),
|
||||
servers: () => Effect.succeed([]),
|
||||
add: () => Effect.die("unused mcp.add"),
|
||||
connect: () => Effect.die("unused mcp.connect"),
|
||||
|
||||
@@ -16,7 +16,6 @@ import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -810,61 +809,4 @@ describe("LocationServiceMap", () => {
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
itWithSdk.live("lets public plugins mutate configured and runtime MCP servers", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const url = "https://example.com/mcp"
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(
|
||||
path.join(dir.path, "opencode.json"),
|
||||
JSON.stringify({ mcp: { servers: { example: { type: "remote", url, disabled: true } } } }),
|
||||
),
|
||||
)
|
||||
const observed: Record<string, boolean | undefined> = {}
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(
|
||||
EffectPlugin.define({
|
||||
id: "mcp-codemode-policy",
|
||||
effect: (ctx) =>
|
||||
ctx.mcp
|
||||
.transform((mcp) => {
|
||||
for (const [name, server] of mcp.list()) {
|
||||
if (server.type !== "remote" || new URL(server.url).hostname !== "example.com") continue
|
||||
mcp.update(name, (current) => {
|
||||
current.codemode = false
|
||||
observed[name] = current.codemode
|
||||
})
|
||||
}
|
||||
})
|
||||
.pipe(Effect.asVoid),
|
||||
}),
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
const mcp = yield* MCP.Service
|
||||
yield* supervisor.flush
|
||||
expect(observed.example).toBe(false)
|
||||
yield* mcp.add("dynamic", {
|
||||
type: "remote",
|
||||
url: "https://example.com/dynamic",
|
||||
disabled: true,
|
||||
})
|
||||
expect(observed.dynamic).toBe(false)
|
||||
expect((yield* mcp.servers()).map((server) => String(server.name))).toEqual(["dynamic", "example"])
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(
|
||||
LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -14,9 +14,7 @@ import {
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigMCPPlugin } from "@opencode-ai/core/config/plugin/mcp"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
@@ -176,18 +174,11 @@ function resourceMcpLayer(
|
||||
entries?: Config.Interface["entries"]
|
||||
subscribe?: Bus.Interface["subscribe"]
|
||||
environment?: Layer.Layer<Environment.Service>
|
||||
published?: string[]
|
||||
},
|
||||
) {
|
||||
const directory = AbsolutePath.make(import.meta.dir)
|
||||
const unusedIntegration = () => Effect.die("unused integration service")
|
||||
return Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
yield* ConfigMCPPlugin.register(bus.subscribe())
|
||||
}),
|
||||
).pipe(
|
||||
Layer.provideMerge(MCP.layer(options)),
|
||||
return MCP.layer(options).pipe(
|
||||
Layer.provideMerge(Form.layer),
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
@@ -224,7 +215,6 @@ function resourceMcpLayer(
|
||||
type: definition.type,
|
||||
data,
|
||||
} as Payload<typeof definition>
|
||||
overrides?.published?.push(event.type)
|
||||
if (event.type !== Form.Event.Created.type || !onFormCreated) return Effect.succeed(event)
|
||||
return onFormCreated(Schema.decodeUnknownSync(Form.Event.Created.data)(data).form).pipe(Effect.as(event))
|
||||
},
|
||||
@@ -922,7 +912,6 @@ test("loads and reads MCP resources", async () => {
|
||||
})
|
||||
|
||||
test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
|
||||
const published: string[] = []
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
@@ -930,7 +919,6 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
|
||||
const service = yield* MCP.Service
|
||||
|
||||
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
|
||||
expect(published).toContain(McpEvent.StatusChanged.type)
|
||||
expect(yield* service.connect("missing").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
|
||||
expect(yield* service.disconnect("missing").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
|
||||
yield* service.add(
|
||||
@@ -984,9 +972,6 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
|
||||
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
|
||||
disabled: true,
|
||||
}),
|
||||
undefined,
|
||||
undefined,
|
||||
{ published },
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -995,70 +980,6 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("restores runtime MCP config when a transform is disposed", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const service = yield* MCP.Service
|
||||
const config = new ConfigMCP.Remote({
|
||||
type: "remote",
|
||||
url: "https://example.com/mcp",
|
||||
headers: { Authorization: "original" },
|
||||
oauth: false,
|
||||
disabled: true,
|
||||
})
|
||||
yield* service.add("dynamic", config)
|
||||
const transformed = yield* service.transform((draft) =>
|
||||
draft.update("dynamic", (server) => {
|
||||
if (server.type === "remote") server.headers = { Authorization: "transformed" }
|
||||
}),
|
||||
)
|
||||
let observed: string | undefined
|
||||
yield* service.transform((draft) => {
|
||||
const server = draft.get("dynamic")
|
||||
observed = server?.type === "remote" ? server.headers?.Authorization : undefined
|
||||
})
|
||||
|
||||
expect(observed).toBe("transformed")
|
||||
expect(config.headers?.Authorization).toBe("original")
|
||||
yield* transformed.dispose
|
||||
expect(observed).toBe("original")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true })),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("isolates nested configured MCP mutations and reconciles them", async () => {
|
||||
const published: string[] = []
|
||||
const config = new ConfigMCP.Remote({
|
||||
type: "remote",
|
||||
url: "https://example.com/mcp",
|
||||
headers: { Authorization: "original" },
|
||||
oauth: false,
|
||||
disabled: true,
|
||||
})
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const service = yield* MCP.Service
|
||||
expect(published.filter((type) => type === McpEvent.StatusChanged.type)).toHaveLength(1)
|
||||
yield* service.transform((draft) =>
|
||||
draft.update("resources", (server) => {
|
||||
if (server.type === "remote") server.headers = { Authorization: "transformed" }
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config.headers?.Authorization).toBe("original")
|
||||
expect(published.filter((type) => type === McpEvent.StatusChanged.type)).toHaveLength(2)
|
||||
}).pipe(Effect.provide(resourceMcpLayer(config, undefined, undefined, { published }))),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("reconciles only changed MCP server config", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
@@ -1149,6 +1070,35 @@ test("reconciles only changed MCP server config", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("reconciles MCP config changed during startup", async () => {
|
||||
const server = new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true })
|
||||
let reads = 0
|
||||
const entries = () =>
|
||||
Effect.sync(() => {
|
||||
reads += 1
|
||||
return [
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({
|
||||
mcp: new ConfigMCP.Info({
|
||||
servers: reads === 1 ? { initial: server } : { initial: server, added: server },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]
|
||||
})
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const service = yield* MCP.Service
|
||||
expect((yield* service.servers()).map((item) => String(item.name))).toEqual(["added", "initial"])
|
||||
expect(reads).toBeGreaterThanOrEqual(2)
|
||||
}).pipe(Effect.provide(resourceMcpLayer(server, undefined, undefined, { entries }))),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("serializes concurrent MCP lifecycle operations", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
|
||||
@@ -7,10 +7,6 @@ import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
@@ -42,63 +38,6 @@ describe("Plugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes explicit MCP locations through the plugin runtime", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const target = AbsolutePath.make("/target")
|
||||
const routed: string[] = []
|
||||
const host = yield* PluginHost.make(plugins).pipe(
|
||||
Effect.provideService(
|
||||
PluginRuntime.Service,
|
||||
PluginRuntime.Service.of({
|
||||
...runtime,
|
||||
location: {
|
||||
agent: runtime.location.agent,
|
||||
mcp: {
|
||||
list: (ref) =>
|
||||
Effect.sync(() => {
|
||||
routed.push(`list:${ref.directory}`)
|
||||
return {
|
||||
location: new Location.Info({
|
||||
directory: ref.directory,
|
||||
project: {
|
||||
id: Project.ID.make("project"),
|
||||
directory: ref.directory,
|
||||
canonical: ref.directory,
|
||||
},
|
||||
}),
|
||||
data: [],
|
||||
}
|
||||
}),
|
||||
add: (ref) => Effect.sync(() => routed.push(`add:${ref.directory}`)),
|
||||
remove: (ref) => Effect.sync(() => routed.push(`remove:${ref.directory}`)),
|
||||
connect: (ref) => Effect.sync(() => routed.push(`connect:${ref.directory}`)),
|
||||
disconnect: (ref) => Effect.sync(() => routed.push(`disconnect:${ref.directory}`)),
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
const location = { directory: target }
|
||||
|
||||
yield* host.mcp
|
||||
.add({ location, server: "routed", config: { type: "local", command: ["unused"], disabled: true } })
|
||||
.pipe(Effect.orDie)
|
||||
yield* host.mcp.remove({ location, server: "routed" }).pipe(Effect.orDie)
|
||||
yield* host.mcp.connect({ location, server: "routed" }).pipe(Effect.orDie)
|
||||
yield* host.mcp.disconnect({ location, server: "routed" }).pipe(Effect.orDie)
|
||||
expect((yield* host.mcp.list({ location }).pipe(Effect.orDie)).location.directory).toBe(target)
|
||||
expect(routed).toEqual([
|
||||
"add:/target",
|
||||
"remove:/target",
|
||||
"connect:/target",
|
||||
"disconnect:/target",
|
||||
"list:/target",
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces plugins by ID and version", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
|
||||
@@ -12,7 +12,6 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
@@ -25,7 +24,6 @@ import { Tool } from "@opencode-ai/core/tool"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { tempLocationLayer } from "../fixture/location"
|
||||
import { emptyMcpLayer } from "../fixture/mcp"
|
||||
|
||||
const npmLayer = Layer.succeed(
|
||||
Npm.Service,
|
||||
@@ -51,7 +49,6 @@ export const PluginTestLayer = LayerNode.compile(
|
||||
Catalog.node,
|
||||
Command.node,
|
||||
Integration.node,
|
||||
MCP.node,
|
||||
PluginRuntime.node,
|
||||
PluginHooks.node,
|
||||
Reference.node,
|
||||
@@ -66,6 +63,5 @@ export const PluginTestLayer = LayerNode.compile(
|
||||
[Location.node, tempLocationLayer],
|
||||
[Npm.node, npmLayer],
|
||||
[Config.node, Config.testLayer()],
|
||||
[MCP.node, emptyMcpLayer],
|
||||
],
|
||||
) as unknown as Layer.Layer<unknown, never>
|
||||
|
||||
@@ -72,15 +72,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
resolve: () => Effect.die("unused integration.connection.resolve"),
|
||||
},
|
||||
},
|
||||
mcp: overrides.mcp ?? {
|
||||
list: () => Effect.die("unused mcp.list"),
|
||||
add: () => Effect.die("unused mcp.add"),
|
||||
remove: () => Effect.die("unused mcp.remove"),
|
||||
connect: () => Effect.die("unused mcp.connect"),
|
||||
disconnect: () => Effect.die("unused mcp.disconnect"),
|
||||
transform: () => Effect.die("unused mcp.transform"),
|
||||
reload: () => Effect.die("unused mcp.reload"),
|
||||
},
|
||||
plugin: overrides.plugin ?? {
|
||||
list: () => Effect.die("unused plugin.list"),
|
||||
},
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { MCPCodeModeExclusionPlugin } from "@opencode-ai/core/plugin/mcp-codemode-exclusion"
|
||||
import type { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { Effect, type Types } from "effect"
|
||||
import { it } from "../lib/effect"
|
||||
import { host } from "./host"
|
||||
|
||||
it.effect("defaults only known Code Mode MCP servers to direct tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const cases: Array<{
|
||||
name: string
|
||||
server: Types.DeepMutable<Mcp.ServerConfig>
|
||||
codemode: boolean | undefined
|
||||
}> = [
|
||||
{
|
||||
name: "executor remote",
|
||||
server: { type: "remote", url: "https://executor.sh/example/mcp?source=opencode" },
|
||||
codemode: false,
|
||||
},
|
||||
{ name: "executor local", server: { type: "local", command: ["executor", "mcp"] }, codemode: false },
|
||||
{
|
||||
name: "cloudflare code mode",
|
||||
server: { type: "remote", url: "https://mcp.cloudflare.com/mcp/" },
|
||||
codemode: false,
|
||||
},
|
||||
{
|
||||
name: "cloudflare docs",
|
||||
server: { type: "remote", url: "https://docs.mcp.cloudflare.com/mcp" },
|
||||
codemode: undefined,
|
||||
},
|
||||
{
|
||||
name: "explicit true",
|
||||
server: { type: "remote", url: "https://mcp.cloudflare.com/mcp", codemode: true },
|
||||
codemode: true,
|
||||
},
|
||||
{
|
||||
name: "explicit false",
|
||||
server: { type: "remote", url: "https://executor.sh/example/mcp", codemode: false },
|
||||
codemode: false,
|
||||
},
|
||||
{ name: "exa", server: { type: "remote", url: "https://mcp.exa.ai/mcp" }, codemode: undefined },
|
||||
{ name: "unrelated", server: { type: "remote", url: "https://example.com/mcp" }, codemode: undefined },
|
||||
]
|
||||
const servers: Record<string, Types.DeepMutable<Mcp.ServerConfig>> = Object.fromEntries(
|
||||
cases.map((test) => [test.name, test.server]),
|
||||
)
|
||||
const base = host()
|
||||
|
||||
yield* MCPCodeModeExclusionPlugin.Plugin.effect(
|
||||
host({
|
||||
mcp: {
|
||||
...base.mcp,
|
||||
transform: (transform) =>
|
||||
Effect.sync(() => {
|
||||
transform({
|
||||
list: () => Object.entries(servers),
|
||||
get: (name) => servers[name],
|
||||
set: () => {
|
||||
throw new Error("unused")
|
||||
},
|
||||
update: (name, update) => {
|
||||
const server = servers[name]
|
||||
if (server) update(server)
|
||||
},
|
||||
remove: (name) => {
|
||||
delete servers[name]
|
||||
},
|
||||
})
|
||||
return { dispose: Effect.void }
|
||||
}),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
cases.forEach((test) => expect(servers[test.name]?.codemode).toBe(test.codemode))
|
||||
}),
|
||||
)
|
||||
@@ -220,133 +220,129 @@ const setup = Effect.gen(function* () {
|
||||
return { db, bus, instructions: yield* instructionBuiltIns.load(sessionID) }
|
||||
})
|
||||
|
||||
it.effect(
|
||||
"generates from fresh settled Session context without durable mutation",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
requests.length = 0
|
||||
hasHttpMiddleware = false
|
||||
instruction = "Initial context"
|
||||
const { db, bus, instructions } = yield* setup
|
||||
yield* InstructionState.prepare(db, bus, instructions, sessionID)
|
||||
const existing = SessionMessage.ID.create()
|
||||
yield* bus.publish(SessionEvent.InboxEnqueued, {
|
||||
sessionID,
|
||||
inboxID: existing,
|
||||
item: { type: "user", payload: { text: "Existing durable context" }, delivery: "steer" },
|
||||
})
|
||||
yield* bus.publish(SessionEvent.InboxDelivered, {
|
||||
sessionID,
|
||||
inboxID: existing,
|
||||
})
|
||||
const settledAssistant = SessionMessage.ID.create()
|
||||
yield* bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID: settledAssistant,
|
||||
agent: Agent.ID.make("build"),
|
||||
model: { id: ID.make("generate-model"), providerID: Provider.ID.make("test") },
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Text.Started, {
|
||||
sessionID,
|
||||
assistantMessageID: settledAssistant,
|
||||
ordinal: 0,
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Text.Ended, {
|
||||
sessionID,
|
||||
assistantMessageID: settledAssistant,
|
||||
ordinal: 0,
|
||||
text: "Settled partial answer",
|
||||
})
|
||||
const activeAssistant = SessionMessage.ID.create()
|
||||
yield* bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID: activeAssistant,
|
||||
agent: Agent.ID.make("build"),
|
||||
model: { id: ID.make("generate-model"), providerID: Provider.ID.make("test") },
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Tool.Input.Started, {
|
||||
sessionID,
|
||||
assistantMessageID: activeAssistant,
|
||||
id: "active-call",
|
||||
name: "echo",
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
|
||||
sessionID,
|
||||
assistantMessageID: activeAssistant,
|
||||
id: "active-call",
|
||||
text: "{}",
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Tool.Called, {
|
||||
sessionID,
|
||||
assistantMessageID: activeAssistant,
|
||||
id: "active-call",
|
||||
input: {},
|
||||
executed: false,
|
||||
})
|
||||
yield* bus.publish(SessionEvent.InboxEnqueued, {
|
||||
sessionID,
|
||||
inboxID: SessionMessage.ID.create(),
|
||||
item: { type: "user", payload: { text: "Queued input must remain invisible" }, delivery: "queue" },
|
||||
})
|
||||
instruction = "Changed context"
|
||||
const before = yield* durableState(db, sessionID)
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* hooks.register("session", "context", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.system = [SystemPart.make("Hooked system"), ...event.system]
|
||||
if (event.tools.lookup) event.tools.lookup.description = "Hooked lookup"
|
||||
}),
|
||||
)
|
||||
it.effect("generates from fresh settled Session context without durable mutation", () =>
|
||||
Effect.gen(function* () {
|
||||
requests.length = 0
|
||||
hasHttpMiddleware = false
|
||||
instruction = "Initial context"
|
||||
const { db, bus, instructions } = yield* setup
|
||||
yield* InstructionState.prepare(db, bus, instructions, sessionID)
|
||||
const existing = SessionMessage.ID.create()
|
||||
yield* bus.publish(SessionEvent.InboxEnqueued, {
|
||||
sessionID,
|
||||
inboxID: existing,
|
||||
item: { type: "user", payload: { text: "Existing durable context" }, delivery: "steer" },
|
||||
})
|
||||
yield* bus.publish(SessionEvent.InboxDelivered, {
|
||||
sessionID,
|
||||
inboxID: existing,
|
||||
})
|
||||
const settledAssistant = SessionMessage.ID.create()
|
||||
yield* bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID: settledAssistant,
|
||||
agent: Agent.ID.make("build"),
|
||||
model: { id: ID.make("generate-model"), providerID: Provider.ID.make("test") },
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Text.Started, {
|
||||
sessionID,
|
||||
assistantMessageID: settledAssistant,
|
||||
ordinal: 0,
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Text.Ended, {
|
||||
sessionID,
|
||||
assistantMessageID: settledAssistant,
|
||||
ordinal: 0,
|
||||
text: "Settled partial answer",
|
||||
})
|
||||
const activeAssistant = SessionMessage.ID.create()
|
||||
yield* bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID: activeAssistant,
|
||||
agent: Agent.ID.make("build"),
|
||||
model: { id: ID.make("generate-model"), providerID: Provider.ID.make("test") },
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Tool.Input.Started, {
|
||||
sessionID,
|
||||
assistantMessageID: activeAssistant,
|
||||
id: "active-call",
|
||||
name: "echo",
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
|
||||
sessionID,
|
||||
assistantMessageID: activeAssistant,
|
||||
id: "active-call",
|
||||
text: "{}",
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Tool.Called, {
|
||||
sessionID,
|
||||
assistantMessageID: activeAssistant,
|
||||
id: "active-call",
|
||||
input: {},
|
||||
executed: false,
|
||||
})
|
||||
yield* bus.publish(SessionEvent.InboxEnqueued, {
|
||||
sessionID,
|
||||
inboxID: SessionMessage.ID.create(),
|
||||
item: { type: "user", payload: { text: "Queued input must remain invisible" }, delivery: "queue" },
|
||||
})
|
||||
instruction = "Changed context"
|
||||
const before = yield* durableState(db, sessionID)
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* hooks.register("session", "context", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.system = [SystemPart.make("Hooked system"), ...event.system]
|
||||
if (event.tools.lookup) event.tools.lookup.description = "Hooked lookup"
|
||||
}),
|
||||
)
|
||||
|
||||
const generate = yield* SessionGenerate.Service
|
||||
const result = yield* generate.generate({ sessionID, prompt: "Summarize privately" })
|
||||
const generate = yield* SessionGenerate.Service
|
||||
const result = yield* generate.generate({ sessionID, prompt: "Summarize privately" })
|
||||
|
||||
expect(result).toBe("Transient answer")
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(hasHttpMiddleware).toBe(true)
|
||||
expect(requests[0]?.model).toBe(model)
|
||||
expect(requests[0]?.system[0]?.text).toBe("Hooked system")
|
||||
expect(requests[0]?.system.map((part) => part.text)).toContain("Initial context")
|
||||
expect(requests[0]?.http?.headers).toMatchObject({ "X-Session-Id": sessionID })
|
||||
expect(requests[0]?.promptCacheKey).toBe(sessionID)
|
||||
const instructionUpdates = requests[0]?.messages.flatMap((message) =>
|
||||
message.role === "system"
|
||||
expect(result).toBe("Transient answer")
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(hasHttpMiddleware).toBe(true)
|
||||
expect(requests[0]?.model).toBe(model)
|
||||
expect(requests[0]?.system[0]?.text).toBe("Hooked system")
|
||||
expect(requests[0]?.system.map((part) => part.text)).toContain("Initial context")
|
||||
expect(requests[0]?.http?.headers).toMatchObject({ "X-Session-Id": sessionID })
|
||||
expect(requests[0]?.promptCacheKey).toBe(sessionID)
|
||||
const instructionUpdates = requests[0]?.messages.flatMap((message) =>
|
||||
message.role === "system"
|
||||
? message.content.flatMap((content) => (content.type === "text" ? [content.text] : []))
|
||||
: [],
|
||||
)
|
||||
expect(instructionUpdates).toHaveLength(1)
|
||||
expect(instructionUpdates?.[0]).toContain("Changed context")
|
||||
expect(instructionUpdates?.[0]).toContain("tools.captured.lookup(input: {}): Promise<string>")
|
||||
expect(userTexts(requests[0])).toEqual(["Existing durable context", "Summarize privately"])
|
||||
expect(
|
||||
requests[0]?.messages.flatMap((message) =>
|
||||
message.role === "assistant"
|
||||
? message.content.flatMap((content) => (content.type === "text" ? [content.text] : []))
|
||||
: [],
|
||||
)
|
||||
expect(instructionUpdates).toHaveLength(1)
|
||||
expect(instructionUpdates?.[0]).toContain("Changed context")
|
||||
expect(instructionUpdates?.[0]).toContain("tools.captured.lookup(input: {}): Promise<string>")
|
||||
expect(userTexts(requests[0])).toEqual(["Existing durable context", "Summarize privately"])
|
||||
expect(
|
||||
requests[0]?.messages.flatMap((message) =>
|
||||
message.role === "assistant"
|
||||
? message.content.flatMap((content) => (content.type === "text" ? [content.text] : []))
|
||||
: [],
|
||||
),
|
||||
).toEqual(["Settled partial answer"])
|
||||
expect(requests[0]?.tools).toMatchObject([{ name: "lookup", description: "Hooked lookup" }])
|
||||
expect(requests[0]?.toolChoice).toBeUndefined()
|
||||
expect(yield* durableState(db, sessionID)).toEqual(before)
|
||||
}),
|
||||
),
|
||||
).toEqual(["Settled partial answer"])
|
||||
expect(requests[0]?.tools).toMatchObject([{ name: "lookup", description: "Hooked lookup" }])
|
||||
expect(requests[0]?.toolChoice).toBeUndefined()
|
||||
expect(yield* durableState(db, sessionID)).toEqual(before)
|
||||
}),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"blocks unavailable initial instructions before generation",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
requests.length = 0
|
||||
instruction = Instructions.unavailable
|
||||
const { db } = yield* setup
|
||||
const before = yield* durableState(db, sessionID)
|
||||
const generate = yield* SessionGenerate.Service
|
||||
it.effect("blocks unavailable initial instructions before generation", () =>
|
||||
Effect.gen(function* () {
|
||||
requests.length = 0
|
||||
instruction = Instructions.unavailable
|
||||
const { db } = yield* setup
|
||||
const before = yield* durableState(db, sessionID)
|
||||
const generate = yield* SessionGenerate.Service
|
||||
|
||||
const error = yield* generate.generate({ sessionID, prompt: "Summarize privately" }).pipe(Effect.flip)
|
||||
const error = yield* generate.generate({ sessionID, prompt: "Summarize privately" }).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(Instructions.InitializationBlocked)
|
||||
expect(requests).toEqual([])
|
||||
expect(yield* durableState(db, sessionID)).toEqual(before)
|
||||
}),
|
||||
expect(error).toBeInstanceOf(Instructions.InitializationBlocked)
|
||||
expect(requests).toEqual([])
|
||||
expect(yield* durableState(db, sessionID)).toEqual(before)
|
||||
}),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
@@ -946,61 +946,6 @@ describe("V1Migration database workflow", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("imports previous V2 databases missing newer nullable columns", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filename = path.join(tmp.path, "opencode-next.db")
|
||||
const sqlite = await import("bun:sqlite")
|
||||
const source = new sqlite.Database(filename)
|
||||
source.run(`
|
||||
CREATE TABLE project (
|
||||
id text PRIMARY KEY, worktree text NOT NULL, vcs text, name text, icon_url text,
|
||||
time_created integer NOT NULL, time_updated integer NOT NULL, time_initialized integer,
|
||||
sandboxes text NOT NULL
|
||||
);
|
||||
CREATE TABLE session (
|
||||
id text PRIMARY KEY, project_id text NOT NULL, workspace_id text, parent_id text, fork_session_id text,
|
||||
slug text NOT NULL, directory text NOT NULL, path text, title text, version text NOT NULL,
|
||||
share_url text, summary_additions integer, summary_deletions integer, summary_files integer, summary_diffs text,
|
||||
metadata text, cost real DEFAULT 0 NOT NULL, tokens_input integer DEFAULT 0 NOT NULL,
|
||||
tokens_output integer DEFAULT 0 NOT NULL, tokens_reasoning integer DEFAULT 0 NOT NULL,
|
||||
tokens_cache_read integer DEFAULT 0 NOT NULL, tokens_cache_write integer DEFAULT 0 NOT NULL, revert text,
|
||||
permission text, agent text, model text, time_created integer NOT NULL, time_updated integer NOT NULL,
|
||||
time_compacting integer, time_archived integer
|
||||
);
|
||||
CREATE TABLE session_message (
|
||||
id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL,
|
||||
time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL
|
||||
);
|
||||
INSERT INTO project VALUES (
|
||||
'next-project', '/tmp/next', 'git', 'Source project', 'https://example.com/icon.png', 1, 2, NULL, '[]'
|
||||
);
|
||||
INSERT INTO session (
|
||||
id, project_id, slug, directory, title, version, time_created, time_updated
|
||||
) VALUES ('ses_next', 'next-project', 'next', '/tmp/next', 'Imported', '2', 10, 20);
|
||||
`)
|
||||
source.close()
|
||||
|
||||
await database(
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
expect(yield* V1Migration.run({ nextDatabasePath: filename })).toEqual({ status: "completed" })
|
||||
expect(
|
||||
yield* database.db.get(sql`SELECT fork_boundary, time_suspended FROM session_v2 WHERE id = 'ses_next'`),
|
||||
).toEqual({ fork_boundary: null, time_suspended: null })
|
||||
expect(
|
||||
yield* database.db.get(
|
||||
sql`SELECT icon_url, icon_url_override, icon_color, commands FROM project WHERE id = 'next-project'`,
|
||||
),
|
||||
).toEqual({
|
||||
icon_url: "https://example.com/icon.png",
|
||||
icon_url_override: "https://example.com/icon.png",
|
||||
icon_color: null,
|
||||
commands: null,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("derives required status from the durable cursor", async () => {
|
||||
await database(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -180,30 +180,28 @@ describeHg("Vcs mercurial", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"diffs a named branch against the default branch",
|
||||
() =>
|
||||
withHg((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
|
||||
await commitAll(directory, "initial")
|
||||
})
|
||||
const vcs = yield* Vcs.Service
|
||||
expect(yield* vcs.diff("branch")).toEqual([])
|
||||
it.live("diffs a named branch against the default branch", () =>
|
||||
withHg((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
|
||||
await commitAll(directory, "initial")
|
||||
})
|
||||
const vcs = yield* Vcs.Service
|
||||
expect(yield* vcs.diff("branch")).toEqual([])
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await hg(directory, "branch", "-q", "feature")
|
||||
await fs.writeFile(path.join(directory, "file.txt"), "one\ntwo\n")
|
||||
await commitAll(directory, "feature change")
|
||||
})
|
||||
const diff = yield* vcs.diff("branch")
|
||||
expect(diff.map((item) => ({ file: item.file, status: item.status }))).toEqual([
|
||||
{ file: "file.txt", status: "modified" },
|
||||
])
|
||||
expect(diff[0].patch).toContain("+two")
|
||||
}),
|
||||
),
|
||||
yield* Effect.promise(async () => {
|
||||
await hg(directory, "branch", "-q", "feature")
|
||||
await fs.writeFile(path.join(directory, "file.txt"), "one\ntwo\n")
|
||||
await commitAll(directory, "feature change")
|
||||
})
|
||||
const diff = yield* vcs.diff("branch")
|
||||
expect(diff.map((item) => ({ file: item.file, status: item.status }))).toEqual([
|
||||
{ file: "file.txt", status: "modified" },
|
||||
])
|
||||
expect(diff[0].patch).toContain("+two")
|
||||
}),
|
||||
),
|
||||
15_000,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -10,16 +10,10 @@ import { app, BrowserWindow } from "electron"
|
||||
import { Deferred, Effect, Fiber } from "effect"
|
||||
import contextMenu from "electron-context-menu"
|
||||
|
||||
import type { ServerReadyData } from "../shared/ipc-contract"
|
||||
import type { ServerReadyData } from "../preload/types"
|
||||
import { checkAppExists, resolveAppPath } from "./apps"
|
||||
import { CHANNEL, VERSION } from "./constants"
|
||||
import {
|
||||
registerIpcHandlers,
|
||||
registerUpdaterIpcHandlers,
|
||||
registerWslIpcHandlers,
|
||||
sendDeepLinks,
|
||||
sendMenuCommand,
|
||||
} from "./ipc"
|
||||
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand } from "./ipc"
|
||||
import { forwardInitializationFailure } from "./initialization"
|
||||
import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
|
||||
import { createMenu } from "./menu"
|
||||
@@ -29,7 +23,7 @@ import {
|
||||
isFirstLaunchOnboardingPending,
|
||||
} from "./onboarding"
|
||||
import { getDefaultServerUrl, preferAppEnv, setDefaultServerUrl } from "./server"
|
||||
import { createUpdaterIpc, setupAutoUpdater, showUpdaterDialog } from "./updater"
|
||||
import { registerUpdaterIpc, setupAutoUpdater, showUpdaterDialog } from "./updater"
|
||||
import { safeWebContentsURL } from "./window-state"
|
||||
import {
|
||||
getLastFocusedWindow,
|
||||
@@ -40,7 +34,7 @@ import {
|
||||
setDockIcon,
|
||||
restoreMainWindows,
|
||||
} from "./windows"
|
||||
import { createWslIpc } from "./wsl/ipc"
|
||||
import { registerWslIpcHandlers } from "./wsl/ipc"
|
||||
import { cleanupStoreFiles } from "./store-cleanup"
|
||||
import { startBackgroundCli } from "./background-cli"
|
||||
import { setNativeTranslations } from "./native-translations"
|
||||
@@ -273,7 +267,7 @@ const main = Effect.gen(function* () {
|
||||
if (setNativeTranslations(bundle)) createMenu(menuDeps)
|
||||
},
|
||||
})
|
||||
registerUpdaterIpcHandlers(createUpdaterIpc(updater))
|
||||
registerUpdaterIpc(updater)
|
||||
void updater.start()
|
||||
const updateTimer = setInterval(() => void updater.check(), 10 * 60 * 1000)
|
||||
updateTimer.unref()
|
||||
@@ -320,7 +314,7 @@ const main = Effect.gen(function* () {
|
||||
|
||||
async function startWslServers(cli: { version: string; wslBuild?: { script: string; output: string } }) {
|
||||
if (process.platform !== "win32") {
|
||||
registerWslIpcHandlers(createWslIpc())
|
||||
registerWslIpcHandlers()
|
||||
return async () => {}
|
||||
}
|
||||
|
||||
@@ -350,7 +344,7 @@ async function startWslServers(cli: { version: string; wslBuild?: { script: stri
|
||||
error: (message, meta) => logger.error(message, meta),
|
||||
},
|
||||
})
|
||||
registerWslIpcHandlers(createWslIpc(controller))
|
||||
registerWslIpcHandlers(controller)
|
||||
controller.startConfiguredServers()
|
||||
return async () => controller.stopServers()
|
||||
}
|
||||
|
||||
+103
-134
@@ -3,18 +3,10 @@ import { stat } from "node:fs/promises"
|
||||
import { basename, join } from "node:path"
|
||||
import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from "electron"
|
||||
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
|
||||
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
||||
import { parseDesktopNativeBundle, type DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
|
||||
|
||||
import {
|
||||
Ipc,
|
||||
sendIpcEvent,
|
||||
type FatalRendererError,
|
||||
type IpcInvoke,
|
||||
type IpcInvokeArgs,
|
||||
type IpcInvokeResult,
|
||||
type IpcSend,
|
||||
type ServerReadyData,
|
||||
} from "../shared/ipc-contract"
|
||||
import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
|
||||
import { runDesktopMenuAction } from "./desktop-menu-actions"
|
||||
import { setForceFocus } from "./debug"
|
||||
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
|
||||
@@ -30,24 +22,6 @@ import {
|
||||
} from "./windows"
|
||||
import { createDesktopDraftStore } from "./draft-store"
|
||||
import { nativeT } from "./native-translations"
|
||||
import type { UpdaterIpc } from "./updater"
|
||||
import type { WslIpc } from "./wsl/ipc"
|
||||
|
||||
type MaybePromise<Value> = Value | Promise<Value>
|
||||
|
||||
function handle<Channel extends keyof IpcInvoke>(
|
||||
channel: Channel,
|
||||
listener: (event: IpcMainInvokeEvent, ...args: IpcInvokeArgs<Channel>) => MaybePromise<IpcInvokeResult<Channel>>,
|
||||
) {
|
||||
ipcMain.handle(channel, listener)
|
||||
}
|
||||
|
||||
function on<Channel extends keyof IpcSend>(
|
||||
channel: Channel,
|
||||
listener: (event: IpcMainEvent, ...args: IpcSend[Channel]) => void,
|
||||
) {
|
||||
ipcMain.on(channel, listener)
|
||||
}
|
||||
|
||||
const pickerFilters = (ext?: string[]) => {
|
||||
if (!ext || ext.length === 0) return undefined
|
||||
@@ -79,21 +53,27 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
app.once("will-quit", () => drafts.close())
|
||||
app.on("browser-window-created", (_event, win) => win.on("session-end", () => drafts.flush()))
|
||||
|
||||
handle(Ipc.app.awaitInitialization, () => deps.awaitInitialization())
|
||||
handle(Ipc.app.consumeInitialDeepLinks, () => deps.consumeInitialDeepLinks())
|
||||
handle(Ipc.app.getDefaultServerUrl, () => deps.getDefaultServerUrl())
|
||||
handle(Ipc.app.setDefaultServerUrl, (_event, url) => deps.setDefaultServerUrl(url))
|
||||
handle(Ipc.app.isFirstLaunchOnboardingPending, () => deps.isFirstLaunchOnboardingPending())
|
||||
handle(Ipc.app.finishFirstLaunchOnboarding, (_event, createDefaultProject) =>
|
||||
ipcMain.handle("await-initialization", () => deps.awaitInitialization())
|
||||
ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks())
|
||||
ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl())
|
||||
ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) =>
|
||||
deps.setDefaultServerUrl(url),
|
||||
)
|
||||
ipcMain.handle("is-first-launch-onboarding-pending", () => deps.isFirstLaunchOnboardingPending())
|
||||
ipcMain.handle("finish-first-launch-onboarding", (_event: IpcMainInvokeEvent, createDefaultProject: boolean) =>
|
||||
deps.finishFirstLaunchOnboarding(createDefaultProject),
|
||||
)
|
||||
handle(Ipc.app.checkAppExists, (_event, appName) => deps.checkAppExists(appName))
|
||||
handle(Ipc.app.resolveAppPath, (_event, appName) => deps.resolveAppPath(appName))
|
||||
handle(Ipc.app.setBackgroundColor, (_event, color) => deps.setBackgroundColor(color))
|
||||
handle(Ipc.app.exportDebugLogs, () => deps.exportDebugLogs())
|
||||
handle(Ipc.app.setForceFocus, (event, enabled) => setForceFocus(event.sender, enabled))
|
||||
handle(Ipc.app.recordFatalRendererError, (_event, error) => deps.recordFatalRendererError(error))
|
||||
handle(Ipc.app.setNativeTranslations, (event, value) => {
|
||||
ipcMain.handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName))
|
||||
ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
|
||||
ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
|
||||
ipcMain.handle("export-debug-logs", () => deps.exportDebugLogs())
|
||||
ipcMain.handle("set-force-focus", (event: IpcMainInvokeEvent, enabled: boolean) =>
|
||||
setForceFocus(event.sender, enabled),
|
||||
)
|
||||
ipcMain.handle("record-fatal-renderer-error", (_event: IpcMainInvokeEvent, error: FatalRendererError) =>
|
||||
deps.recordFatalRendererError(error),
|
||||
)
|
||||
ipcMain.handle("set-native-translations", (event: IpcMainInvokeEvent, value: unknown) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win || win.isDestroyed() || win.webContents !== event.sender || event.senderFrame !== event.sender.mainFrame) {
|
||||
throw new Error("Invalid native translation sender")
|
||||
@@ -102,7 +82,7 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
if (!bundle) throw new Error("Invalid native translation bundle")
|
||||
deps.setNativeTranslations(bundle)
|
||||
})
|
||||
handle(Ipc.storage.get, (_event, name, key) => {
|
||||
ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
|
||||
try {
|
||||
const store = getStore(name)
|
||||
const value = store.get(key)
|
||||
@@ -112,90 +92,102 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
return null
|
||||
}
|
||||
})
|
||||
handle(Ipc.storage.set, (_event, name, key, value) => {
|
||||
ipcMain.handle("store-set", (_event: IpcMainInvokeEvent, name: string, key: string, value: string) => {
|
||||
getStore(name).set(key, value)
|
||||
})
|
||||
handle(Ipc.storage.delete, (_event, name, key) => {
|
||||
ipcMain.handle("store-delete", (_event: IpcMainInvokeEvent, name: string, key: string) => {
|
||||
getStore(name).delete(key)
|
||||
void removeStoreFileIfEmpty(name)
|
||||
})
|
||||
handle(Ipc.storage.clear, (_event, name) => {
|
||||
ipcMain.handle("store-clear", (_event: IpcMainInvokeEvent, name: string) => {
|
||||
getStore(name).clear()
|
||||
void removeStoreFileIfEmpty(name)
|
||||
})
|
||||
handle(Ipc.storage.keys, (_event, name) => {
|
||||
ipcMain.handle("store-keys", (_event: IpcMainInvokeEvent, name: string) => {
|
||||
const store = getStore(name)
|
||||
return Object.keys(store.store)
|
||||
})
|
||||
handle(Ipc.storage.length, (_event, name) => {
|
||||
ipcMain.handle("store-length", (_event: IpcMainInvokeEvent, name: string) => {
|
||||
const store = getStore(name)
|
||||
return Object.keys(store.store).length
|
||||
})
|
||||
handle(Ipc.drafts.get, (_event, key) => drafts.get(key))
|
||||
handle(Ipc.drafts.set, (_event, key, value) => drafts.set(key, value))
|
||||
handle(Ipc.drafts.delete, (_event, key) => drafts.set(key, null))
|
||||
handle(Ipc.drafts.putBlob, (_event, data) => drafts.putBlob(new Uint8Array(data)))
|
||||
handle(Ipc.drafts.getBlob, (_event, id) => {
|
||||
ipcMain.handle("draft-get", (_event, key: string) => drafts.get(key))
|
||||
ipcMain.handle("draft-set", (_event, key: string, value: string) => drafts.set(key, value))
|
||||
ipcMain.handle("draft-delete", (_event, key: string) => drafts.set(key, null))
|
||||
ipcMain.handle("draft-blob-put", (_event, data: ArrayBuffer) => drafts.putBlob(new Uint8Array(data)))
|
||||
ipcMain.handle("draft-blob-get", (_event, id: string) => {
|
||||
const data = drafts.getBlob(id)
|
||||
return data ? new Uint8Array(data).buffer : null
|
||||
return data ? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) : null
|
||||
})
|
||||
|
||||
handle(Ipc.files.openDirectoryPicker, async (_event, opts) => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : []), "createDirectory"],
|
||||
title: opts?.title ?? nativeT("desktop.dialog.chooseFolder"),
|
||||
defaultPath: opts?.defaultPath,
|
||||
})
|
||||
if (result.canceled) return null
|
||||
return opts?.multiple ? result.filePaths : result.filePaths[0]
|
||||
})
|
||||
ipcMain.handle(
|
||||
"open-directory-picker",
|
||||
async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : []), "createDirectory"],
|
||||
title: opts?.title ?? nativeT("desktop.dialog.chooseFolder"),
|
||||
defaultPath: opts?.defaultPath,
|
||||
})
|
||||
if (result.canceled) return null
|
||||
return opts?.multiple ? result.filePaths : result.filePaths[0]
|
||||
},
|
||||
)
|
||||
|
||||
handle(Ipc.files.openFilePicker, async (event, opts) => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])],
|
||||
title: opts?.title ?? nativeT("desktop.dialog.chooseFile"),
|
||||
defaultPath: opts?.defaultPath,
|
||||
filters: pickerFilters(opts?.extensions),
|
||||
})
|
||||
if (result.canceled) return null
|
||||
const files = await Promise.all(
|
||||
result.filePaths.map(async (filePath) => ({
|
||||
path: filePath,
|
||||
name: basename(filePath),
|
||||
size: (await stat(filePath)).size,
|
||||
})),
|
||||
)
|
||||
assertAttachmentBudget(files)
|
||||
const token = pickedFiles.add(event.sender.id, result.filePaths)
|
||||
return { token, files }
|
||||
})
|
||||
ipcMain.handle(
|
||||
"open-file-picker",
|
||||
async (
|
||||
event: IpcMainInvokeEvent,
|
||||
opts?: { multiple?: boolean; title?: string; defaultPath?: string; extensions?: string[] },
|
||||
) => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])],
|
||||
title: opts?.title ?? nativeT("desktop.dialog.chooseFile"),
|
||||
defaultPath: opts?.defaultPath,
|
||||
filters: pickerFilters(opts?.extensions),
|
||||
})
|
||||
if (result.canceled) return null
|
||||
const files = await Promise.all(
|
||||
result.filePaths.map(async (filePath) => ({
|
||||
path: filePath,
|
||||
name: basename(filePath),
|
||||
size: (await stat(filePath)).size,
|
||||
})),
|
||||
)
|
||||
assertAttachmentBudget(files)
|
||||
const token = pickedFiles.add(event.sender.id, result.filePaths)
|
||||
return { token, files }
|
||||
},
|
||||
)
|
||||
|
||||
handle(Ipc.files.readPickedFile, async (event, token, filePath) => {
|
||||
ipcMain.handle("read-picked-file", async (event: IpcMainInvokeEvent, token: string, filePath: string) => {
|
||||
return pickedFiles.read(event.sender.id, token, filePath)
|
||||
})
|
||||
|
||||
handle(Ipc.files.releasePickedFiles, (event, token) => {
|
||||
ipcMain.handle("release-picked-files", (event: IpcMainInvokeEvent, token: string) => {
|
||||
pickedFiles.release(event.sender.id, token)
|
||||
})
|
||||
|
||||
handle(Ipc.files.saveFilePicker, async (_event, opts) => {
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: opts?.title ?? nativeT("desktop.dialog.saveFile"),
|
||||
defaultPath: opts?.defaultPath,
|
||||
})
|
||||
if (result.canceled) return null
|
||||
return result.filePath ?? null
|
||||
})
|
||||
ipcMain.handle(
|
||||
"save-file-picker",
|
||||
async (_event: IpcMainInvokeEvent, opts?: { title?: string; defaultPath?: string }) => {
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: opts?.title ?? nativeT("desktop.dialog.saveFile"),
|
||||
defaultPath: opts?.defaultPath,
|
||||
})
|
||||
if (result.canceled) return null
|
||||
return result.filePath ?? null
|
||||
},
|
||||
)
|
||||
|
||||
on(Ipc.files.openExternal, (_event, url) => {
|
||||
ipcMain.on("open-external", (_event: IpcMainEvent, url: string) => {
|
||||
openExternalURL(url)
|
||||
})
|
||||
|
||||
on(Ipc.files.openLocalFile, (_event, url) => {
|
||||
ipcMain.on("open-local-file", (_event: IpcMainEvent, url: string) => {
|
||||
openLocalFileURL(url)
|
||||
})
|
||||
|
||||
handle(Ipc.files.openPath, async (_event, path, app) => {
|
||||
ipcMain.handle("open-path", async (_event: IpcMainInvokeEvent, path: string, app?: string) => {
|
||||
if (!app) return shell.openPath(path)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const [cmd, args] =
|
||||
@@ -204,7 +196,7 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
})
|
||||
})
|
||||
|
||||
handle(Ipc.files.revealPath, async (_event, path) => {
|
||||
ipcMain.handle("reveal-path", async (_event: IpcMainInvokeEvent, path: string) => {
|
||||
const exists = await stat(path).then(
|
||||
() => true,
|
||||
() => false,
|
||||
@@ -214,15 +206,15 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
return true
|
||||
})
|
||||
|
||||
handle(Ipc.files.readClipboardImage, () => {
|
||||
ipcMain.handle("read-clipboard-image", () => {
|
||||
const image = clipboard.readImage()
|
||||
if (image.isEmpty()) return null
|
||||
const buffer = new Uint8Array(image.toPNG()).buffer
|
||||
const buffer = image.toPNG().buffer
|
||||
const size = image.getSize()
|
||||
return { buffer, width: size.width, height: size.height }
|
||||
})
|
||||
|
||||
handle(Ipc.window.getId, (event) => {
|
||||
ipcMain.handle("get-window-id", (event: IpcMainInvokeEvent) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win) throw new Error("Window not found")
|
||||
const id = getWindowID(win)
|
||||
@@ -230,47 +222,47 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
return id
|
||||
})
|
||||
|
||||
handle(Ipc.window.getFocused, (event) => {
|
||||
ipcMain.handle("get-window-focused", (event: IpcMainInvokeEvent) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
return win?.isFocused() ?? false
|
||||
})
|
||||
|
||||
handle(Ipc.window.getFullscreen, (event) => {
|
||||
ipcMain.handle("get-window-fullscreen", (event: IpcMainInvokeEvent) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
return win?.isFullScreen() ?? false
|
||||
})
|
||||
|
||||
handle(Ipc.window.setFocus, (event) => {
|
||||
ipcMain.handle("set-window-focus", (event: IpcMainInvokeEvent) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
win?.focus()
|
||||
})
|
||||
|
||||
handle(Ipc.window.show, (event) => {
|
||||
ipcMain.handle("show-window", (event: IpcMainInvokeEvent) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
win?.show()
|
||||
})
|
||||
|
||||
on(Ipc.app.relaunch, () => {
|
||||
ipcMain.on("relaunch", () => {
|
||||
deps.relaunch()
|
||||
})
|
||||
|
||||
handle(Ipc.window.getZoomFactor, (event) => event.sender.getZoomFactor())
|
||||
handle(Ipc.window.setZoomFactor, (event, factor) => {
|
||||
ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor())
|
||||
ipcMain.handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => {
|
||||
event.sender.setZoomFactor(factor)
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win) return
|
||||
updateTitlebar(win)
|
||||
})
|
||||
handle(Ipc.window.getPinchZoomEnabled, () => getPinchZoomEnabled())
|
||||
handle(Ipc.window.setPinchZoomEnabled, (_event, enabled) => {
|
||||
ipcMain.handle("get-pinch-zoom-enabled", () => getPinchZoomEnabled())
|
||||
ipcMain.handle("set-pinch-zoom-enabled", (_event: IpcMainInvokeEvent, enabled: boolean) => {
|
||||
setPinchZoomEnabled(enabled)
|
||||
})
|
||||
handle(Ipc.window.setTitlebar, (event, theme) => {
|
||||
ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win) return
|
||||
setTitlebar(win, theme)
|
||||
})
|
||||
handle(Ipc.menu.runAction, (event, action) => {
|
||||
ipcMain.handle("run-desktop-menu-action", (event: IpcMainInvokeEvent, action: DesktopMenuAction) => {
|
||||
runDesktopMenuAction(BrowserWindow.fromWebContents(event.sender), action, {
|
||||
checkForUpdates: () => void deps.showUpdater(),
|
||||
relaunch: deps.relaunch,
|
||||
@@ -278,33 +270,10 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
})
|
||||
}
|
||||
|
||||
export function registerUpdaterIpcHandlers(updater: UpdaterIpc) {
|
||||
handle(Ipc.updater.subscribe, (event) => updater.subscribe(event.sender))
|
||||
handle(Ipc.updater.unsubscribe, (event) => updater.unsubscribe(event.sender.id))
|
||||
handle(Ipc.updater.check, () => updater.check())
|
||||
handle(Ipc.updater.install, () => updater.install())
|
||||
}
|
||||
|
||||
export function registerWslIpcHandlers(wsl: WslIpc) {
|
||||
handle(Ipc.wsl.subscribe, (event) => wsl.subscribe(event.sender))
|
||||
handle(Ipc.wsl.unsubscribe, (event) => wsl.unsubscribe(event.sender.id))
|
||||
handle(Ipc.wsl.getState, () => wsl.getState())
|
||||
handle(Ipc.wsl.probeRuntime, () => wsl.probeRuntime())
|
||||
handle(Ipc.wsl.refreshDistros, () => wsl.refreshDistros())
|
||||
handle(Ipc.wsl.installWsl, () => wsl.installWsl())
|
||||
handle(Ipc.wsl.installDistro, (_event, value) => wsl.installDistro(value))
|
||||
handle(Ipc.wsl.probeAddable, (_event, value) => wsl.probeAddable(value))
|
||||
handle(Ipc.wsl.installOpencode, (_event, value) => wsl.installOpencode(value))
|
||||
handle(Ipc.wsl.openTerminal, (_event, value) => wsl.openTerminal(value))
|
||||
handle(Ipc.wsl.addServer, (_event, value) => wsl.addServer(value))
|
||||
handle(Ipc.wsl.removeServer, (_event, value) => wsl.removeServer(value))
|
||||
handle(Ipc.wsl.startServer, (_event, value) => wsl.startServer(value))
|
||||
}
|
||||
|
||||
export function sendMenuCommand(win: BrowserWindow, id: string) {
|
||||
sendIpcEvent(win.webContents, Ipc.menu.command, id)
|
||||
win.webContents.send("menu-command", id)
|
||||
}
|
||||
|
||||
export function sendDeepLinks(win: BrowserWindow, urls: string[]) {
|
||||
sendIpcEvent(win.webContents, Ipc.app.deepLink, urls)
|
||||
win.webContents.send("deep-link", urls)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { app, dialog } from "electron"
|
||||
import type { WebContents } from "electron"
|
||||
import { Ipc, sendIpcEvent } from "../shared/ipc-contract"
|
||||
import { app, dialog, ipcMain } from "electron"
|
||||
import { UPDATER_ENABLED } from "./constants"
|
||||
import { createUpdaterController, type UpdaterController, type UpdaterReadyRecord } from "./updater-controller"
|
||||
import { getLogger } from "./logging"
|
||||
@@ -30,7 +28,7 @@ export function setupAutoUpdater(prepareToRestart: () => Promise<void>) {
|
||||
})
|
||||
}
|
||||
|
||||
export function createUpdaterIpc(controller: UpdaterController) {
|
||||
export function registerUpdaterIpc(controller: UpdaterController) {
|
||||
const subscriptions = new Map<number, () => void>()
|
||||
const unsubscribe = (id: number) => {
|
||||
subscriptions.get(id)?.()
|
||||
@@ -38,27 +36,23 @@ export function createUpdaterIpc(controller: UpdaterController) {
|
||||
}
|
||||
app.once("will-quit", () => subscriptions.forEach((dispose) => dispose()))
|
||||
|
||||
return {
|
||||
subscribe(sender: WebContents) {
|
||||
const id = sender.id
|
||||
subscriptions.get(id)?.() // a reloaded renderer replaces its previous subscription
|
||||
subscriptions.set(
|
||||
id,
|
||||
controller.subscribe((state) => {
|
||||
if (sender.isDestroyed()) return unsubscribe(id)
|
||||
sendIpcEvent(sender, Ipc.updater.state, state)
|
||||
}),
|
||||
)
|
||||
sender.once("destroyed", () => unsubscribe(id))
|
||||
},
|
||||
unsubscribe,
|
||||
check: () => controller.check(),
|
||||
install: () => controller.install(),
|
||||
}
|
||||
ipcMain.handle("updater-subscribe", (event) => {
|
||||
const id = event.sender.id
|
||||
subscriptions.get(id)?.() // a reloaded renderer replaces its previous subscription
|
||||
subscriptions.set(
|
||||
id,
|
||||
controller.subscribe((state) => {
|
||||
if (event.sender.isDestroyed()) return unsubscribe(id)
|
||||
event.sender.send("updater-state", state)
|
||||
}),
|
||||
)
|
||||
event.sender.once("destroyed", () => unsubscribe(id))
|
||||
})
|
||||
ipcMain.handle("updater-unsubscribe", (event) => unsubscribe(event.sender.id))
|
||||
ipcMain.handle("updater-check", () => controller.check())
|
||||
ipcMain.handle("updater-install", () => controller.install())
|
||||
}
|
||||
|
||||
export type UpdaterIpc = ReturnType<typeof createUpdaterIpc>
|
||||
|
||||
export async function showUpdaterDialog(controller: UpdaterController) {
|
||||
const state = await controller.check()
|
||||
if (state.status === "error") {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { rmSync } from "node:fs"
|
||||
import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol, shell } from "electron"
|
||||
import { dirname, isAbsolute, join, relative, resolve } from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import { Ipc, sendIpcEvent, type TitlebarTheme } from "../shared/ipc-contract"
|
||||
import type { TitlebarTheme } from "../preload/types"
|
||||
import { exportDebugLogs, write as writeLog } from "./logging"
|
||||
import { getStore, removeStoreFile } from "./store"
|
||||
import { PINCH_ZOOM_ENABLED_KEY, WINDOW_IDS_KEY } from "./store-keys"
|
||||
@@ -132,7 +132,7 @@ export function setPinchZoomEnabled(enabled: boolean) {
|
||||
getStore().set(PINCH_ZOOM_ENABLED_KEY, enabled)
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
pinchZoomEnabled.set(win, enabled)
|
||||
sendIpcEvent(win.webContents, Ipc.window.pinchZoomEnabledChanged, enabled)
|
||||
win.webContents.send("pinch-zoom-enabled-changed", enabled)
|
||||
if (!enabled && win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1)
|
||||
updateZoom(win)
|
||||
}
|
||||
@@ -533,7 +533,7 @@ function wireZoom(win: BrowserWindow) {
|
||||
function wireFullscreen(win: BrowserWindow) {
|
||||
const send = (fullscreen: boolean) => {
|
||||
if (win.isDestroyed() || win.webContents.isDestroyed()) return
|
||||
sendIpcEvent(win.webContents, Ipc.window.fullscreenChanged, fullscreen)
|
||||
win.webContents.send("window-fullscreen-changed", fullscreen)
|
||||
}
|
||||
|
||||
win.on("enter-full-screen", () => send(true))
|
||||
@@ -546,7 +546,7 @@ function clampZoom(value: number) {
|
||||
|
||||
function updateZoom(win: BrowserWindow) {
|
||||
updateTitlebar(win)
|
||||
sendIpcEvent(win.webContents, Ipc.window.zoomFactorChanged, win.webContents.getZoomFactor())
|
||||
win.webContents.send("zoom-factor-changed", win.webContents.getZoomFactor())
|
||||
}
|
||||
|
||||
function upsertKeyValue(obj: Record<string, any>, keyToChange: string, value: any) {
|
||||
|
||||
@@ -1,28 +1,14 @@
|
||||
import { app } from "electron"
|
||||
import type { WebContents } from "electron"
|
||||
import type { WslServerConfig, WslServersState } from "@opencode-ai/app/wsl/types"
|
||||
import { Ipc, sendIpcEvent } from "../../shared/ipc-contract"
|
||||
import { app, ipcMain } from "electron"
|
||||
import type { IpcMainInvokeEvent } from "electron"
|
||||
import type { WslServersController } from "./servers"
|
||||
import type { WslServersState } from "../../preload/types"
|
||||
import { nativeT } from "../native-translations"
|
||||
|
||||
export type WslIpc = {
|
||||
subscribe(sender: WebContents): void
|
||||
unsubscribe(id: number): void
|
||||
getState(): WslServersState
|
||||
probeRuntime(): Promise<void>
|
||||
refreshDistros(): Promise<void>
|
||||
installWsl(): Promise<void>
|
||||
installDistro(value: string): Promise<void>
|
||||
probeAddable(value: string[]): Promise<void>
|
||||
installOpencode(value: string): Promise<void>
|
||||
openTerminal(value: string): Promise<void>
|
||||
addServer(value: string): Promise<WslServerConfig>
|
||||
removeServer(value: string): Promise<void>
|
||||
startServer(value: string): Promise<void>
|
||||
}
|
||||
|
||||
export function createWslIpc(controller?: WslServersController): WslIpc {
|
||||
if (!controller) return createUnavailableWslIpc()
|
||||
export function registerWslIpcHandlers(controller?: WslServersController) {
|
||||
if (!controller) {
|
||||
registerUnavailableWslIpcHandlers()
|
||||
return
|
||||
}
|
||||
|
||||
const subscriptions = new Map<number, () => void>()
|
||||
const unsubscribe = (id: number) => {
|
||||
@@ -37,38 +23,62 @@ export function createWslIpc(controller?: WslServersController): WslIpc {
|
||||
subscriptions.clear()
|
||||
})
|
||||
|
||||
return {
|
||||
subscribe(sender) {
|
||||
const id = sender.id
|
||||
if (subscriptions.has(id)) return
|
||||
subscriptions.set(
|
||||
id,
|
||||
controller.subscribe((payload) => {
|
||||
if (sender.isDestroyed()) {
|
||||
unsubscribe(id)
|
||||
return
|
||||
}
|
||||
sendIpcEvent(sender, Ipc.wsl.event, payload)
|
||||
}),
|
||||
)
|
||||
sender.once("destroyed", () => unsubscribe(id))
|
||||
},
|
||||
unsubscribe,
|
||||
getState: () => controller.getState(),
|
||||
probeRuntime: () => controller.probeRuntime(),
|
||||
refreshDistros: () => controller.refreshDistros(),
|
||||
installWsl: () => controller.installWsl(),
|
||||
installDistro: (value) => controller.installDistro(requireWslIpcString("distro", value)),
|
||||
probeAddable: (value) => controller.probeAddable(requireWslIpcStrings("distro", value)),
|
||||
installOpencode: (value) => controller.installOpencode(requireWslIpcString("distro", value)),
|
||||
openTerminal: (value) => controller.openTerminal(requireWslIpcString("distro", value)),
|
||||
addServer: (value) => controller.addServer(requireWslIpcString("distro", value)),
|
||||
removeServer: (value) => controller.removeServer(requireWslIpcString("server id", value)),
|
||||
startServer: (value) => controller.startServer(requireWslIpcString("server id", value)),
|
||||
}
|
||||
ipcMain.handle("wsl-servers-subscribe", (event) => {
|
||||
const id = event.sender.id
|
||||
if (subscriptions.has(id)) return
|
||||
subscriptions.set(
|
||||
id,
|
||||
controller.subscribe((payload) => {
|
||||
if (event.sender.isDestroyed()) {
|
||||
unsubscribe(id)
|
||||
return
|
||||
}
|
||||
event.sender.send("wsl-servers-event", payload)
|
||||
}),
|
||||
)
|
||||
event.sender.once("destroyed", () => unsubscribe(id))
|
||||
})
|
||||
ipcMain.handle("wsl-servers-unsubscribe", (event) => unsubscribe(event.sender.id))
|
||||
ipcMain.handle("wsl-servers-get-state", () => controller.getState())
|
||||
ipcMain.handle("wsl-servers-probe-runtime", () => controller.probeRuntime())
|
||||
ipcMain.handle("wsl-servers-refresh-distros", () => controller.refreshDistros())
|
||||
ipcMain.handle("wsl-servers-install-wsl", () => controller.installWsl())
|
||||
ipcMain.handle("wsl-servers-install-distro", (_event: IpcMainInvokeEvent, name: string) =>
|
||||
controller.installDistro(requireWslIpcString("distro", name)),
|
||||
)
|
||||
ipcMain.handle("wsl-servers-probe-addable", (_event: IpcMainInvokeEvent, distros: string[]) =>
|
||||
controller.probeAddable(requireWslIpcStrings("distro", distros)),
|
||||
)
|
||||
ipcMain.handle("wsl-servers-install-opencode", (_event: IpcMainInvokeEvent, name: string) =>
|
||||
controller.installOpencode(requireWslIpcString("distro", name)),
|
||||
)
|
||||
ipcMain.handle("wsl-servers-open-terminal", (_event: IpcMainInvokeEvent, name: string) =>
|
||||
controller.openTerminal(requireWslIpcString("distro", name)),
|
||||
)
|
||||
ipcMain.handle("wsl-servers-add", (_event: IpcMainInvokeEvent, distro: string) =>
|
||||
controller.addServer(requireWslIpcString("distro", distro)),
|
||||
)
|
||||
ipcMain.handle("wsl-servers-remove", (_event: IpcMainInvokeEvent, id: string) =>
|
||||
controller.removeServer(requireWslIpcString("server id", id)),
|
||||
)
|
||||
ipcMain.handle("wsl-servers-start", (_event: IpcMainInvokeEvent, id: string) =>
|
||||
controller.startServer(requireWslIpcString("server id", id)),
|
||||
)
|
||||
}
|
||||
|
||||
function createUnavailableWslIpc(): WslIpc {
|
||||
function requireWslIpcString(name: string, value: unknown) {
|
||||
if (typeof value === "string" && value.length > 0) return value
|
||||
throw new Error(`Invalid ${name}`)
|
||||
}
|
||||
|
||||
function requireWslIpcStrings(name: string, value: unknown) {
|
||||
if (!Array.isArray(value)) throw new Error(`Invalid ${name}`)
|
||||
const values = value.map((item) => requireWslIpcString(name, item))
|
||||
if (values.length) return values
|
||||
throw new Error(`Invalid ${name}`)
|
||||
}
|
||||
|
||||
function registerUnavailableWslIpcHandlers() {
|
||||
const unavailable = () => {
|
||||
throw new Error(nativeT("desktop.wsl.error.windowsOnly"))
|
||||
}
|
||||
@@ -87,31 +97,19 @@ function createUnavailableWslIpc(): WslIpc {
|
||||
job: null,
|
||||
})
|
||||
|
||||
return {
|
||||
subscribe: (sender) => sendIpcEvent(sender, Ipc.wsl.event, { type: "state", state: state() }),
|
||||
unsubscribe: () => undefined,
|
||||
getState: state,
|
||||
probeRuntime: unavailable,
|
||||
refreshDistros: unavailable,
|
||||
installWsl: unavailable,
|
||||
installDistro: unavailable,
|
||||
probeAddable: unavailable,
|
||||
installOpencode: unavailable,
|
||||
openTerminal: unavailable,
|
||||
addServer: unavailable,
|
||||
removeServer: unavailable,
|
||||
startServer: unavailable,
|
||||
}
|
||||
}
|
||||
|
||||
function requireWslIpcString(name: string, value: unknown) {
|
||||
if (typeof value === "string" && value.length > 0) return value
|
||||
throw new Error(`Invalid ${name}`)
|
||||
}
|
||||
|
||||
function requireWslIpcStrings(name: string, value: unknown) {
|
||||
if (!Array.isArray(value)) throw new Error(`Invalid ${name}`)
|
||||
const values = value.map((item) => requireWslIpcString(name, item))
|
||||
if (values.length) return values
|
||||
throw new Error(`Invalid ${name}`)
|
||||
ipcMain.handle("wsl-servers-subscribe", (event) => {
|
||||
event.sender.send("wsl-servers-event", { type: "state", state: state() })
|
||||
})
|
||||
ipcMain.handle("wsl-servers-unsubscribe", () => undefined)
|
||||
ipcMain.handle("wsl-servers-get-state", () => state())
|
||||
ipcMain.handle("wsl-servers-probe-runtime", unavailable)
|
||||
ipcMain.handle("wsl-servers-refresh-distros", unavailable)
|
||||
ipcMain.handle("wsl-servers-install-wsl", unavailable)
|
||||
ipcMain.handle("wsl-servers-install-distro", unavailable)
|
||||
ipcMain.handle("wsl-servers-probe-addable", unavailable)
|
||||
ipcMain.handle("wsl-servers-install-opencode", unavailable)
|
||||
ipcMain.handle("wsl-servers-open-terminal", unavailable)
|
||||
ipcMain.handle("wsl-servers-add", unavailable)
|
||||
ipcMain.handle("wsl-servers-remove", unavailable)
|
||||
ipcMain.handle("wsl-servers-start", unavailable)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process"
|
||||
import { existsSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import * as pty from "@lydell/node-pty"
|
||||
import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "@opencode-ai/app/wsl/types"
|
||||
import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../../preload/types"
|
||||
import { parseCliVersion } from "../cli-version"
|
||||
import { nativeT } from "../native-translations"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { WslServerConfig } from "@opencode-ai/app/wsl/types"
|
||||
import type { WslServerConfig } from "../../preload/types"
|
||||
import { wslCliInstallCommand } from "./runtime"
|
||||
import { createWslServersController } from "./servers"
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
WslServerRuntime,
|
||||
WslServersEvent,
|
||||
WslServersState,
|
||||
} from "@opencode-ai/app/wsl/types"
|
||||
} from "../../preload/types"
|
||||
import { WSL_SERVERS_KEY } from "../store-keys"
|
||||
import { getStore } from "../store"
|
||||
import { nativeT } from "../native-translations"
|
||||
|
||||
@@ -1,138 +1,133 @@
|
||||
import { contextBridge, ipcRenderer, webUtils } from "electron"
|
||||
import type { IpcRendererEvent } from "electron"
|
||||
import type { ElectronAPI } from "./types"
|
||||
import type { ElectronAPI, WslServersEvent } from "./types"
|
||||
import type { UpdaterState } from "@opencode-ai/app/updater"
|
||||
import {
|
||||
Ipc,
|
||||
type IpcEvent,
|
||||
type IpcEventListener,
|
||||
type IpcInvoke,
|
||||
type IpcInvokeArgs,
|
||||
type IpcInvokeResult,
|
||||
type IpcSend,
|
||||
} from "../shared/ipc-contract"
|
||||
|
||||
function invoke<Channel extends keyof IpcInvoke>(channel: Channel, ...args: IpcInvokeArgs<Channel>) {
|
||||
return ipcRenderer.invoke(channel, ...args) as Promise<IpcInvokeResult<Channel>>
|
||||
}
|
||||
|
||||
function send<Channel extends keyof IpcSend>(channel: Channel, ...args: IpcSend[Channel]) {
|
||||
ipcRenderer.send(channel, ...args)
|
||||
}
|
||||
|
||||
function listen<Channel extends keyof IpcEvent>(channel: Channel, listener: IpcEventListener<Channel>) {
|
||||
const handler = (_event: IpcRendererEvent, ...args: IpcEvent[Channel]) => listener(...args)
|
||||
ipcRenderer.on(channel, handler)
|
||||
return () => ipcRenderer.removeListener(channel, handler)
|
||||
}
|
||||
|
||||
const updaterCallbacks = new Set<(state: UpdaterState) => void>()
|
||||
let updaterState: UpdaterState | undefined
|
||||
let updaterSubscription: Promise<void> | undefined
|
||||
let updaterListener: (() => void) | undefined
|
||||
const updaterHandler = (state: UpdaterState) => {
|
||||
const updaterHandler = (_: unknown, state: UpdaterState) => {
|
||||
updaterState = state
|
||||
updaterCallbacks.forEach((callback) => callback(state))
|
||||
}
|
||||
|
||||
const api: ElectronAPI = {
|
||||
awaitInitialization: () => invoke(Ipc.app.awaitInitialization),
|
||||
awaitInitialization: () => ipcRenderer.invoke("await-initialization"),
|
||||
wslServers: {
|
||||
getState: () => invoke(Ipc.wsl.getState),
|
||||
getState: () => ipcRenderer.invoke("wsl-servers-get-state"),
|
||||
subscribe: (cb) => {
|
||||
const dispose = listen(Ipc.wsl.event, cb)
|
||||
void invoke(Ipc.wsl.subscribe)
|
||||
const handler = (_: unknown, event: WslServersEvent) => cb(event)
|
||||
ipcRenderer.on("wsl-servers-event", handler)
|
||||
void ipcRenderer.invoke("wsl-servers-subscribe")
|
||||
return () => {
|
||||
dispose()
|
||||
void invoke(Ipc.wsl.unsubscribe)
|
||||
ipcRenderer.removeListener("wsl-servers-event", handler)
|
||||
void ipcRenderer.invoke("wsl-servers-unsubscribe")
|
||||
}
|
||||
},
|
||||
probeRuntime: () => invoke(Ipc.wsl.probeRuntime),
|
||||
refreshDistros: () => invoke(Ipc.wsl.refreshDistros),
|
||||
installWsl: () => invoke(Ipc.wsl.installWsl),
|
||||
installDistro: (name) => invoke(Ipc.wsl.installDistro, name),
|
||||
probeAddable: (distros) => invoke(Ipc.wsl.probeAddable, distros),
|
||||
installOpencode: (name) => invoke(Ipc.wsl.installOpencode, name),
|
||||
openTerminal: (name) => invoke(Ipc.wsl.openTerminal, name),
|
||||
addServer: (distro) => invoke(Ipc.wsl.addServer, distro),
|
||||
removeServer: (id) => invoke(Ipc.wsl.removeServer, id),
|
||||
startServer: (id) => invoke(Ipc.wsl.startServer, id),
|
||||
probeRuntime: () => ipcRenderer.invoke("wsl-servers-probe-runtime"),
|
||||
refreshDistros: () => ipcRenderer.invoke("wsl-servers-refresh-distros"),
|
||||
installWsl: () => ipcRenderer.invoke("wsl-servers-install-wsl"),
|
||||
installDistro: (name) => ipcRenderer.invoke("wsl-servers-install-distro", name),
|
||||
probeAddable: (distros) => ipcRenderer.invoke("wsl-servers-probe-addable", distros),
|
||||
installOpencode: (name) => ipcRenderer.invoke("wsl-servers-install-opencode", name),
|
||||
openTerminal: (name) => ipcRenderer.invoke("wsl-servers-open-terminal", name),
|
||||
addServer: (distro) => ipcRenderer.invoke("wsl-servers-add", distro),
|
||||
removeServer: (id) => ipcRenderer.invoke("wsl-servers-remove", id),
|
||||
startServer: (id) => ipcRenderer.invoke("wsl-servers-start", id),
|
||||
},
|
||||
updater: {
|
||||
subscribe: async (cb) => {
|
||||
updaterCallbacks.add(cb)
|
||||
if (updaterState) cb(updaterState)
|
||||
if (!updaterSubscription) {
|
||||
updaterListener = listen(Ipc.updater.state, updaterHandler)
|
||||
updaterSubscription = invoke(Ipc.updater.subscribe)
|
||||
ipcRenderer.on("updater-state", updaterHandler)
|
||||
updaterSubscription = ipcRenderer.invoke("updater-subscribe")
|
||||
}
|
||||
await updaterSubscription
|
||||
return () => {
|
||||
updaterCallbacks.delete(cb)
|
||||
if (updaterCallbacks.size > 0) return
|
||||
updaterListener?.()
|
||||
updaterListener = undefined
|
||||
ipcRenderer.removeListener("updater-state", updaterHandler)
|
||||
updaterSubscription = undefined
|
||||
void invoke(Ipc.updater.unsubscribe)
|
||||
void ipcRenderer.invoke("updater-unsubscribe")
|
||||
}
|
||||
},
|
||||
check: () => invoke(Ipc.updater.check),
|
||||
install: () => invoke(Ipc.updater.install),
|
||||
check: () => ipcRenderer.invoke("updater-check"),
|
||||
install: () => ipcRenderer.invoke("updater-install"),
|
||||
},
|
||||
consumeInitialDeepLinks: () => invoke(Ipc.app.consumeInitialDeepLinks),
|
||||
getDefaultServerUrl: () => invoke(Ipc.app.getDefaultServerUrl),
|
||||
setDefaultServerUrl: (url) => invoke(Ipc.app.setDefaultServerUrl, url),
|
||||
isFirstLaunchOnboardingPending: () => invoke(Ipc.app.isFirstLaunchOnboardingPending),
|
||||
consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"),
|
||||
getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
|
||||
setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url),
|
||||
isFirstLaunchOnboardingPending: () => ipcRenderer.invoke("is-first-launch-onboarding-pending"),
|
||||
finishFirstLaunchOnboarding: (createDefaultProject) =>
|
||||
invoke(Ipc.app.finishFirstLaunchOnboarding, createDefaultProject),
|
||||
checkAppExists: (appName) => invoke(Ipc.app.checkAppExists, appName),
|
||||
resolveAppPath: (appName) => invoke(Ipc.app.resolveAppPath, appName),
|
||||
storeGet: (name, key) => invoke(Ipc.storage.get, name, key),
|
||||
storeSet: (name, key, value) => invoke(Ipc.storage.set, name, key, value),
|
||||
storeDelete: (name, key) => invoke(Ipc.storage.delete, name, key),
|
||||
storeClear: (name) => invoke(Ipc.storage.clear, name),
|
||||
storeKeys: (name) => invoke(Ipc.storage.keys, name),
|
||||
storeLength: (name) => invoke(Ipc.storage.length, name),
|
||||
draftGet: (key) => invoke(Ipc.drafts.get, key),
|
||||
draftSet: (key, value) => invoke(Ipc.drafts.set, key, value),
|
||||
draftDelete: (key) => invoke(Ipc.drafts.delete, key),
|
||||
draftBlobPut: (data) => invoke(Ipc.drafts.putBlob, data),
|
||||
draftBlobGet: (id) => invoke(Ipc.drafts.getBlob, id),
|
||||
ipcRenderer.invoke("finish-first-launch-onboarding", createDefaultProject),
|
||||
checkAppExists: (appName) => ipcRenderer.invoke("check-app-exists", appName),
|
||||
resolveAppPath: (appName) => ipcRenderer.invoke("resolve-app-path", appName),
|
||||
storeGet: (name, key) => ipcRenderer.invoke("store-get", name, key),
|
||||
storeSet: (name, key, value) => ipcRenderer.invoke("store-set", name, key, value),
|
||||
storeDelete: (name, key) => ipcRenderer.invoke("store-delete", name, key),
|
||||
storeClear: (name) => ipcRenderer.invoke("store-clear", name),
|
||||
storeKeys: (name) => ipcRenderer.invoke("store-keys", name),
|
||||
storeLength: (name) => ipcRenderer.invoke("store-length", name),
|
||||
draftGet: (key) => ipcRenderer.invoke("draft-get", key),
|
||||
draftSet: (key, value) => ipcRenderer.invoke("draft-set", key, value),
|
||||
draftDelete: (key) => ipcRenderer.invoke("draft-delete", key),
|
||||
draftBlobPut: (data) => ipcRenderer.invoke("draft-blob-put", data),
|
||||
draftBlobGet: (id) => ipcRenderer.invoke("draft-blob-get", id),
|
||||
|
||||
getWindowID: () => invoke(Ipc.window.getId),
|
||||
onMenuCommand: (cb) => listen(Ipc.menu.command, cb),
|
||||
onDeepLink: (cb) => listen(Ipc.app.deepLink, cb),
|
||||
getWindowID: () => ipcRenderer.invoke("get-window-id"),
|
||||
onMenuCommand: (cb) => {
|
||||
const handler = (_: unknown, id: string) => cb(id)
|
||||
ipcRenderer.on("menu-command", handler)
|
||||
return () => ipcRenderer.removeListener("menu-command", handler)
|
||||
},
|
||||
onDeepLink: (cb) => {
|
||||
const handler = (_: unknown, urls: string[]) => cb(urls)
|
||||
ipcRenderer.on("deep-link", handler)
|
||||
return () => ipcRenderer.removeListener("deep-link", handler)
|
||||
},
|
||||
|
||||
openDirectoryPicker: (opts) => invoke(Ipc.files.openDirectoryPicker, opts),
|
||||
openFilePicker: (opts) => invoke(Ipc.files.openFilePicker, opts),
|
||||
readPickedFile: (token, path) => invoke(Ipc.files.readPickedFile, token, path),
|
||||
releasePickedFiles: (token) => invoke(Ipc.files.releasePickedFiles, token),
|
||||
openDirectoryPicker: (opts) => ipcRenderer.invoke("open-directory-picker", opts),
|
||||
openFilePicker: (opts) => ipcRenderer.invoke("open-file-picker", opts),
|
||||
readPickedFile: (token, path) => ipcRenderer.invoke("read-picked-file", token, path),
|
||||
releasePickedFiles: (token) => ipcRenderer.invoke("release-picked-files", token),
|
||||
getPathForFile: (file) => webUtils.getPathForFile(file),
|
||||
saveFilePicker: (opts) => invoke(Ipc.files.saveFilePicker, opts),
|
||||
openExternal: (url) => send(Ipc.files.openExternal, url),
|
||||
openLocalFile: (url) => send(Ipc.files.openLocalFile, url),
|
||||
openPath: (path, app) => invoke(Ipc.files.openPath, path, app),
|
||||
revealPath: (path) => invoke(Ipc.files.revealPath, path),
|
||||
readClipboardImage: () => invoke(Ipc.files.readClipboardImage),
|
||||
getWindowFocused: () => invoke(Ipc.window.getFocused),
|
||||
getWindowFullscreen: () => invoke(Ipc.window.getFullscreen),
|
||||
onWindowFullscreenChanged: (cb) => listen(Ipc.window.fullscreenChanged, cb),
|
||||
setWindowFocus: () => invoke(Ipc.window.setFocus),
|
||||
showWindow: () => invoke(Ipc.window.show),
|
||||
relaunch: () => send(Ipc.app.relaunch),
|
||||
getZoomFactor: () => invoke(Ipc.window.getZoomFactor),
|
||||
setZoomFactor: (factor) => invoke(Ipc.window.setZoomFactor, factor),
|
||||
getPinchZoomEnabled: () => invoke(Ipc.window.getPinchZoomEnabled),
|
||||
setPinchZoomEnabled: (enabled) => invoke(Ipc.window.setPinchZoomEnabled, enabled),
|
||||
onPinchZoomEnabledChanged: (cb) => listen(Ipc.window.pinchZoomEnabledChanged, cb),
|
||||
onZoomFactorChanged: (cb) => listen(Ipc.window.zoomFactorChanged, cb),
|
||||
setTitlebar: (theme) => invoke(Ipc.window.setTitlebar, theme),
|
||||
runDesktopMenuAction: (action) => invoke(Ipc.menu.runAction, action),
|
||||
setBackgroundColor: (color) => invoke(Ipc.app.setBackgroundColor, color),
|
||||
exportDebugLogs: () => invoke(Ipc.app.exportDebugLogs),
|
||||
setForceFocus: (enabled) => invoke(Ipc.app.setForceFocus, enabled),
|
||||
recordFatalRendererError: (error) => invoke(Ipc.app.recordFatalRendererError, error),
|
||||
setNativeTranslations: (bundle) => invoke(Ipc.app.setNativeTranslations, bundle),
|
||||
saveFilePicker: (opts) => ipcRenderer.invoke("save-file-picker", opts),
|
||||
openExternal: (url) => ipcRenderer.send("open-external", url),
|
||||
openLocalFile: (url) => ipcRenderer.send("open-local-file", url),
|
||||
openPath: (path, app) => ipcRenderer.invoke("open-path", path, app),
|
||||
revealPath: (path) => ipcRenderer.invoke("reveal-path", path),
|
||||
readClipboardImage: () => ipcRenderer.invoke("read-clipboard-image"),
|
||||
getWindowFocused: () => ipcRenderer.invoke("get-window-focused"),
|
||||
getWindowFullscreen: () => ipcRenderer.invoke("get-window-fullscreen"),
|
||||
onWindowFullscreenChanged: (cb) => {
|
||||
const handler = (_: unknown, fullscreen: boolean) => cb(fullscreen)
|
||||
ipcRenderer.on("window-fullscreen-changed", handler)
|
||||
return () => ipcRenderer.removeListener("window-fullscreen-changed", handler)
|
||||
},
|
||||
setWindowFocus: () => ipcRenderer.invoke("set-window-focus"),
|
||||
showWindow: () => ipcRenderer.invoke("show-window"),
|
||||
relaunch: () => ipcRenderer.send("relaunch"),
|
||||
getZoomFactor: () => ipcRenderer.invoke("get-zoom-factor"),
|
||||
setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor),
|
||||
getPinchZoomEnabled: () => ipcRenderer.invoke("get-pinch-zoom-enabled"),
|
||||
setPinchZoomEnabled: (enabled) => ipcRenderer.invoke("set-pinch-zoom-enabled", enabled),
|
||||
onPinchZoomEnabledChanged: (cb) => {
|
||||
const handler = (_: unknown, enabled: boolean) => cb(enabled)
|
||||
ipcRenderer.on("pinch-zoom-enabled-changed", handler)
|
||||
return () => ipcRenderer.removeListener("pinch-zoom-enabled-changed", handler)
|
||||
},
|
||||
onZoomFactorChanged: (cb) => {
|
||||
const handler = (_: unknown, factor: number) => cb(factor)
|
||||
ipcRenderer.on("zoom-factor-changed", handler)
|
||||
return () => ipcRenderer.removeListener("zoom-factor-changed", handler)
|
||||
},
|
||||
setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme),
|
||||
runDesktopMenuAction: (action) => ipcRenderer.invoke("run-desktop-menu-action", action),
|
||||
setBackgroundColor: (color: string) => ipcRenderer.invoke("set-background-color", color),
|
||||
exportDebugLogs: () => ipcRenderer.invoke("export-debug-logs"),
|
||||
setForceFocus: (enabled) => ipcRenderer.invoke("set-force-focus", enabled),
|
||||
recordFatalRendererError: (error) => ipcRenderer.invoke("record-fatal-renderer-error", error),
|
||||
setNativeTranslations: (bundle) => ipcRenderer.invoke("set-native-translations", bundle),
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("api", api)
|
||||
|
||||
@@ -1,74 +1,110 @@
|
||||
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
||||
import type { WslServersPlatform } from "@opencode-ai/app/wsl/types"
|
||||
import {
|
||||
Ipc,
|
||||
type IpcEventListener,
|
||||
type IpcEventSubscription,
|
||||
type IpcInvokeMethod,
|
||||
type IpcSendMethod,
|
||||
} from "../shared/ipc-contract"
|
||||
import type { UpdaterState } from "@opencode-ai/app/updater"
|
||||
import type { DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
|
||||
export type {
|
||||
WslDistroProbe,
|
||||
WslInstalledDistro,
|
||||
WslJob,
|
||||
WslOnlineDistro,
|
||||
WslOpencodeCheck,
|
||||
WslRuntimeCheck,
|
||||
WslServerConfig,
|
||||
WslServerItem,
|
||||
WslServerRuntime,
|
||||
WslServersEvent,
|
||||
WslServersState,
|
||||
} from "@opencode-ai/app/wsl/types"
|
||||
|
||||
export type ServerReadyData = {
|
||||
url: string
|
||||
username: string | null
|
||||
password: string | null
|
||||
}
|
||||
|
||||
export type WslServersAPI = WslServersPlatform
|
||||
export type UpdaterAPI = {
|
||||
subscribe: (cb: IpcEventListener<typeof Ipc.updater.state>) => Promise<() => void>
|
||||
check: IpcInvokeMethod<typeof Ipc.updater.check>
|
||||
install: IpcInvokeMethod<typeof Ipc.updater.install>
|
||||
subscribe: (cb: (state: UpdaterState) => void) => Promise<() => void>
|
||||
check: () => Promise<UpdaterState>
|
||||
install: () => Promise<void>
|
||||
}
|
||||
|
||||
export type TitlebarTheme = {
|
||||
mode: "light" | "dark"
|
||||
scheme?: "system" | "light" | "dark"
|
||||
}
|
||||
export type FatalRendererError = {
|
||||
error: string
|
||||
url: string
|
||||
version?: string
|
||||
platform: string
|
||||
os?: string
|
||||
}
|
||||
|
||||
export type ElectronAPI = {
|
||||
awaitInitialization: IpcInvokeMethod<typeof Ipc.app.awaitInitialization>
|
||||
awaitInitialization: () => Promise<ServerReadyData>
|
||||
wslServers: WslServersAPI
|
||||
updater: UpdaterAPI
|
||||
consumeInitialDeepLinks: IpcInvokeMethod<typeof Ipc.app.consumeInitialDeepLinks>
|
||||
getDefaultServerUrl: IpcInvokeMethod<typeof Ipc.app.getDefaultServerUrl>
|
||||
setDefaultServerUrl: IpcInvokeMethod<typeof Ipc.app.setDefaultServerUrl>
|
||||
isFirstLaunchOnboardingPending: IpcInvokeMethod<typeof Ipc.app.isFirstLaunchOnboardingPending>
|
||||
finishFirstLaunchOnboarding: IpcInvokeMethod<typeof Ipc.app.finishFirstLaunchOnboarding>
|
||||
checkAppExists: IpcInvokeMethod<typeof Ipc.app.checkAppExists>
|
||||
resolveAppPath: IpcInvokeMethod<typeof Ipc.app.resolveAppPath>
|
||||
storeGet: IpcInvokeMethod<typeof Ipc.storage.get>
|
||||
storeSet: IpcInvokeMethod<typeof Ipc.storage.set>
|
||||
storeDelete: IpcInvokeMethod<typeof Ipc.storage.delete>
|
||||
storeClear: IpcInvokeMethod<typeof Ipc.storage.clear>
|
||||
storeKeys: IpcInvokeMethod<typeof Ipc.storage.keys>
|
||||
storeLength: IpcInvokeMethod<typeof Ipc.storage.length>
|
||||
draftGet: IpcInvokeMethod<typeof Ipc.drafts.get>
|
||||
draftSet: IpcInvokeMethod<typeof Ipc.drafts.set>
|
||||
draftDelete: IpcInvokeMethod<typeof Ipc.drafts.delete>
|
||||
draftBlobPut: IpcInvokeMethod<typeof Ipc.drafts.putBlob>
|
||||
draftBlobGet: IpcInvokeMethod<typeof Ipc.drafts.getBlob>
|
||||
consumeInitialDeepLinks: () => Promise<string[]>
|
||||
getDefaultServerUrl: () => Promise<string | null>
|
||||
setDefaultServerUrl: (url: string | null) => Promise<void>
|
||||
isFirstLaunchOnboardingPending: () => Promise<boolean>
|
||||
finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise<string | null>
|
||||
checkAppExists: (appName: string) => Promise<boolean>
|
||||
resolveAppPath: (appName: string) => Promise<string | null>
|
||||
storeGet: (name: string, key: string) => Promise<string | null>
|
||||
storeSet: (name: string, key: string, value: string) => Promise<void>
|
||||
storeDelete: (name: string, key: string) => Promise<void>
|
||||
storeClear: (name: string) => Promise<void>
|
||||
storeKeys: (name: string) => Promise<string[]>
|
||||
storeLength: (name: string) => Promise<number>
|
||||
draftGet: (key: string) => Promise<string | null>
|
||||
draftSet: (key: string, value: string) => Promise<void>
|
||||
draftDelete: (key: string) => Promise<void>
|
||||
draftBlobPut: (data: ArrayBuffer) => Promise<string>
|
||||
draftBlobGet: (id: string) => Promise<ArrayBuffer | null>
|
||||
|
||||
getWindowID: IpcInvokeMethod<typeof Ipc.window.getId>
|
||||
onMenuCommand: IpcEventSubscription<typeof Ipc.menu.command>
|
||||
onDeepLink: IpcEventSubscription<typeof Ipc.app.deepLink>
|
||||
getWindowID: () => Promise<string>
|
||||
onMenuCommand: (cb: (id: string) => void) => () => void
|
||||
onDeepLink: (cb: (urls: string[]) => void) => () => void
|
||||
|
||||
openDirectoryPicker: IpcInvokeMethod<typeof Ipc.files.openDirectoryPicker>
|
||||
openFilePicker: IpcInvokeMethod<typeof Ipc.files.openFilePicker>
|
||||
readPickedFile: IpcInvokeMethod<typeof Ipc.files.readPickedFile>
|
||||
releasePickedFiles: IpcInvokeMethod<typeof Ipc.files.releasePickedFiles>
|
||||
openDirectoryPicker: (opts?: {
|
||||
multiple?: boolean
|
||||
title?: string
|
||||
defaultPath?: string
|
||||
}) => Promise<string | string[] | null>
|
||||
openFilePicker: (opts?: {
|
||||
multiple?: boolean
|
||||
title?: string
|
||||
defaultPath?: string
|
||||
extensions?: string[]
|
||||
}) => Promise<{ token: string; files: { path: string; name: string; size: number }[] } | null>
|
||||
readPickedFile: (token: string, path: string) => Promise<ArrayBuffer>
|
||||
releasePickedFiles: (token: string) => Promise<void>
|
||||
getPathForFile: (file: File) => string
|
||||
saveFilePicker: IpcInvokeMethod<typeof Ipc.files.saveFilePicker>
|
||||
openExternal: IpcSendMethod<typeof Ipc.files.openExternal>
|
||||
openLocalFile: IpcSendMethod<typeof Ipc.files.openLocalFile>
|
||||
openPath: IpcInvokeMethod<typeof Ipc.files.openPath>
|
||||
revealPath: IpcInvokeMethod<typeof Ipc.files.revealPath>
|
||||
readClipboardImage: IpcInvokeMethod<typeof Ipc.files.readClipboardImage>
|
||||
getWindowFocused: IpcInvokeMethod<typeof Ipc.window.getFocused>
|
||||
getWindowFullscreen: IpcInvokeMethod<typeof Ipc.window.getFullscreen>
|
||||
onWindowFullscreenChanged: IpcEventSubscription<typeof Ipc.window.fullscreenChanged>
|
||||
setWindowFocus: IpcInvokeMethod<typeof Ipc.window.setFocus>
|
||||
showWindow: IpcInvokeMethod<typeof Ipc.window.show>
|
||||
relaunch: IpcSendMethod<typeof Ipc.app.relaunch>
|
||||
getZoomFactor: IpcInvokeMethod<typeof Ipc.window.getZoomFactor>
|
||||
setZoomFactor: IpcInvokeMethod<typeof Ipc.window.setZoomFactor>
|
||||
getPinchZoomEnabled: IpcInvokeMethod<typeof Ipc.window.getPinchZoomEnabled>
|
||||
setPinchZoomEnabled: IpcInvokeMethod<typeof Ipc.window.setPinchZoomEnabled>
|
||||
onPinchZoomEnabledChanged: IpcEventSubscription<typeof Ipc.window.pinchZoomEnabledChanged>
|
||||
onZoomFactorChanged: IpcEventSubscription<typeof Ipc.window.zoomFactorChanged>
|
||||
setTitlebar: IpcInvokeMethod<typeof Ipc.window.setTitlebar>
|
||||
runDesktopMenuAction: IpcInvokeMethod<typeof Ipc.menu.runAction>
|
||||
setBackgroundColor: IpcInvokeMethod<typeof Ipc.app.setBackgroundColor>
|
||||
exportDebugLogs: IpcInvokeMethod<typeof Ipc.app.exportDebugLogs>
|
||||
setForceFocus: IpcInvokeMethod<typeof Ipc.app.setForceFocus>
|
||||
recordFatalRendererError: IpcInvokeMethod<typeof Ipc.app.recordFatalRendererError>
|
||||
setNativeTranslations: IpcInvokeMethod<typeof Ipc.app.setNativeTranslations>
|
||||
saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
|
||||
openExternal: (url: string) => void
|
||||
openLocalFile: (url: string) => void
|
||||
openPath: (path: string, app?: string) => Promise<void>
|
||||
revealPath: (path: string) => Promise<boolean>
|
||||
readClipboardImage: () => Promise<{ buffer: ArrayBuffer; width: number; height: number } | null>
|
||||
getWindowFocused: () => Promise<boolean>
|
||||
getWindowFullscreen: () => Promise<boolean>
|
||||
onWindowFullscreenChanged: (cb: (fullscreen: boolean) => void) => () => void
|
||||
setWindowFocus: () => Promise<void>
|
||||
showWindow: () => Promise<void>
|
||||
relaunch: () => void
|
||||
getZoomFactor: () => Promise<number>
|
||||
setZoomFactor: (factor: number) => Promise<void>
|
||||
getPinchZoomEnabled: () => Promise<boolean>
|
||||
setPinchZoomEnabled: (enabled: boolean) => Promise<void>
|
||||
onPinchZoomEnabledChanged: (cb: (enabled: boolean) => void) => () => void
|
||||
onZoomFactorChanged: (cb: (factor: number) => void) => () => void
|
||||
setTitlebar: (theme: TitlebarTheme) => Promise<void>
|
||||
runDesktopMenuAction: (action: DesktopMenuAction) => Promise<void>
|
||||
setBackgroundColor: (color: string) => Promise<void>
|
||||
exportDebugLogs: () => Promise<string>
|
||||
setForceFocus: (enabled: boolean) => Promise<void>
|
||||
recordFatalRendererError: (error: FatalRendererError) => Promise<void>
|
||||
setNativeTranslations: (bundle: DesktopNativeBundle) => Promise<void>
|
||||
}
|
||||
|
||||
@@ -223,10 +223,9 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
|
||||
async openPath(path: string, app?: string) {
|
||||
if (os === "windows") {
|
||||
const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null
|
||||
await window.api.openPath(path, resolvedApp ?? undefined)
|
||||
return
|
||||
return window.api.openPath(path, resolvedApp ?? undefined)
|
||||
}
|
||||
await window.api.openPath(path, app)
|
||||
return window.api.openPath(path, app)
|
||||
},
|
||||
async revealPath(path: string) {
|
||||
return window.api.revealPath(path)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Ipc } from "../shared/ipc-contract"
|
||||
import { initializationData, initializationReady } from "./initialization"
|
||||
|
||||
describe("desktop renderer initialization", () => {
|
||||
@@ -17,7 +16,7 @@ describe("desktop renderer initialization", () => {
|
||||
|
||||
test("removes Electron's remote invocation wrapper from startup errors", () => {
|
||||
const error = new Error(
|
||||
`Error invoking remote method '${Ipc.app.awaitInitialization}': Error: Cannot migrate session_message projections`,
|
||||
"Error invoking remote method 'await-initialization': Error: Cannot migrate session_message projections",
|
||||
)
|
||||
|
||||
try {
|
||||
|
||||
@@ -5,7 +5,7 @@ export function initializationData<A>(state: (() => A | undefined) & { error: un
|
||||
|
||||
function markLocalServerStartup(error: unknown) {
|
||||
const failure = error instanceof Error ? error : new Error(String(error))
|
||||
const prefix = `Error invoking remote method '${Ipc.app.awaitInitialization}': Error: `
|
||||
const prefix = "Error invoking remote method 'await-initialization': Error: "
|
||||
if (failure.message.startsWith(prefix)) {
|
||||
const previous = failure.message
|
||||
failure.message = failure.message.slice(prefix.length)
|
||||
@@ -20,4 +20,3 @@ export function initializationReady<A>(state: (() => A | undefined) & { error: u
|
||||
initializationData(state)
|
||||
return true
|
||||
}
|
||||
import { Ipc } from "../shared/ipc-contract"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useLanguage } from "@opencode-ai/app"
|
||||
import { LoaderV2 } from "@opencode-ai/ui/v2/loader-v2"
|
||||
import { showToastV2, toasterV2, ToastV2 } from "@opencode-ai/ui/v2/toast-v2"
|
||||
import { createRoot, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import type { ServerReadyData } from "../shared/ipc-contract"
|
||||
import type { ServerReadyData } from "../preload/types"
|
||||
|
||||
type Progress = Extract<MigrationV1StatusOutput, { status: "running" }>["progress"]
|
||||
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
||||
import type { DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
|
||||
import type { UpdaterState } from "@opencode-ai/app/updater"
|
||||
import type { WslServerConfig, WslServersEvent, WslServersState } from "@opencode-ai/app/wsl/types"
|
||||
|
||||
export const Ipc = {
|
||||
app: {
|
||||
awaitInitialization: "await-initialization",
|
||||
consumeInitialDeepLinks: "consume-initial-deep-links",
|
||||
deepLink: "deep-link",
|
||||
getDefaultServerUrl: "get-default-server-url",
|
||||
setDefaultServerUrl: "set-default-server-url",
|
||||
isFirstLaunchOnboardingPending: "is-first-launch-onboarding-pending",
|
||||
finishFirstLaunchOnboarding: "finish-first-launch-onboarding",
|
||||
checkAppExists: "check-app-exists",
|
||||
resolveAppPath: "resolve-app-path",
|
||||
relaunch: "relaunch",
|
||||
setBackgroundColor: "set-background-color",
|
||||
exportDebugLogs: "export-debug-logs",
|
||||
setForceFocus: "set-force-focus",
|
||||
recordFatalRendererError: "record-fatal-renderer-error",
|
||||
setNativeTranslations: "set-native-translations",
|
||||
},
|
||||
storage: {
|
||||
get: "store-get",
|
||||
set: "store-set",
|
||||
delete: "store-delete",
|
||||
clear: "store-clear",
|
||||
keys: "store-keys",
|
||||
length: "store-length",
|
||||
},
|
||||
drafts: {
|
||||
get: "draft-get",
|
||||
set: "draft-set",
|
||||
delete: "draft-delete",
|
||||
putBlob: "draft-blob-put",
|
||||
getBlob: "draft-blob-get",
|
||||
},
|
||||
files: {
|
||||
openDirectoryPicker: "open-directory-picker",
|
||||
openFilePicker: "open-file-picker",
|
||||
readPickedFile: "read-picked-file",
|
||||
releasePickedFiles: "release-picked-files",
|
||||
saveFilePicker: "save-file-picker",
|
||||
openExternal: "open-external",
|
||||
openLocalFile: "open-local-file",
|
||||
openPath: "open-path",
|
||||
revealPath: "reveal-path",
|
||||
readClipboardImage: "read-clipboard-image",
|
||||
},
|
||||
window: {
|
||||
getId: "get-window-id",
|
||||
getFocused: "get-window-focused",
|
||||
getFullscreen: "get-window-fullscreen",
|
||||
fullscreenChanged: "window-fullscreen-changed",
|
||||
setFocus: "set-window-focus",
|
||||
show: "show-window",
|
||||
getZoomFactor: "get-zoom-factor",
|
||||
setZoomFactor: "set-zoom-factor",
|
||||
zoomFactorChanged: "zoom-factor-changed",
|
||||
getPinchZoomEnabled: "get-pinch-zoom-enabled",
|
||||
setPinchZoomEnabled: "set-pinch-zoom-enabled",
|
||||
pinchZoomEnabledChanged: "pinch-zoom-enabled-changed",
|
||||
setTitlebar: "set-titlebar",
|
||||
},
|
||||
menu: {
|
||||
command: "menu-command",
|
||||
runAction: "run-desktop-menu-action",
|
||||
},
|
||||
updater: {
|
||||
subscribe: "updater-subscribe",
|
||||
unsubscribe: "updater-unsubscribe",
|
||||
check: "updater-check",
|
||||
install: "updater-install",
|
||||
state: "updater-state",
|
||||
},
|
||||
wsl: {
|
||||
subscribe: "wsl-servers-subscribe",
|
||||
unsubscribe: "wsl-servers-unsubscribe",
|
||||
getState: "wsl-servers-get-state",
|
||||
probeRuntime: "wsl-servers-probe-runtime",
|
||||
refreshDistros: "wsl-servers-refresh-distros",
|
||||
installWsl: "wsl-servers-install-wsl",
|
||||
installDistro: "wsl-servers-install-distro",
|
||||
probeAddable: "wsl-servers-probe-addable",
|
||||
installOpencode: "wsl-servers-install-opencode",
|
||||
openTerminal: "wsl-servers-open-terminal",
|
||||
addServer: "wsl-servers-add",
|
||||
removeServer: "wsl-servers-remove",
|
||||
startServer: "wsl-servers-start",
|
||||
event: "wsl-servers-event",
|
||||
},
|
||||
} as const
|
||||
|
||||
export type ServerReadyData = {
|
||||
url: string
|
||||
username: string | null
|
||||
password: string | null
|
||||
}
|
||||
|
||||
export type TitlebarTheme = {
|
||||
mode: "light" | "dark"
|
||||
scheme?: "system" | "light" | "dark"
|
||||
}
|
||||
|
||||
export type FatalRendererError = {
|
||||
error: string
|
||||
url: string
|
||||
version?: string
|
||||
platform: string
|
||||
os?: string
|
||||
}
|
||||
|
||||
export type DirectoryPickerOptions = {
|
||||
multiple?: boolean
|
||||
title?: string
|
||||
defaultPath?: string
|
||||
}
|
||||
|
||||
export type FilePickerOptions = DirectoryPickerOptions & {
|
||||
extensions?: string[]
|
||||
}
|
||||
|
||||
export type PickedFiles = {
|
||||
token: string
|
||||
files: { path: string; name: string; size: number }[]
|
||||
}
|
||||
|
||||
export type SaveFilePickerOptions = {
|
||||
title?: string
|
||||
defaultPath?: string
|
||||
}
|
||||
|
||||
export type ClipboardImage = {
|
||||
buffer: ArrayBuffer
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export type IpcInvoke = {
|
||||
[Ipc.app.awaitInitialization]: { args: []; result: ServerReadyData }
|
||||
[Ipc.app.consumeInitialDeepLinks]: { args: []; result: string[] }
|
||||
[Ipc.app.getDefaultServerUrl]: { args: []; result: string | null }
|
||||
[Ipc.app.setDefaultServerUrl]: { args: [url: string | null]; result: void }
|
||||
[Ipc.app.isFirstLaunchOnboardingPending]: { args: []; result: boolean }
|
||||
[Ipc.app.finishFirstLaunchOnboarding]: { args: [createDefaultProject: boolean]; result: string | null }
|
||||
[Ipc.app.checkAppExists]: { args: [appName: string]; result: boolean }
|
||||
[Ipc.app.resolveAppPath]: { args: [appName: string]; result: string | null }
|
||||
[Ipc.app.setBackgroundColor]: { args: [color: string]; result: void }
|
||||
[Ipc.app.exportDebugLogs]: { args: []; result: string }
|
||||
[Ipc.app.setForceFocus]: { args: [enabled: boolean]; result: void }
|
||||
[Ipc.app.recordFatalRendererError]: { args: [error: FatalRendererError]; result: void }
|
||||
[Ipc.app.setNativeTranslations]: { args: [bundle: DesktopNativeBundle]; result: void }
|
||||
|
||||
[Ipc.storage.get]: { args: [name: string, key: string]; result: string | null }
|
||||
[Ipc.storage.set]: { args: [name: string, key: string, value: string]; result: void }
|
||||
[Ipc.storage.delete]: { args: [name: string, key: string]; result: void }
|
||||
[Ipc.storage.clear]: { args: [name: string]; result: void }
|
||||
[Ipc.storage.keys]: { args: [name: string]; result: string[] }
|
||||
[Ipc.storage.length]: { args: [name: string]; result: number }
|
||||
|
||||
[Ipc.drafts.get]: { args: [key: string]; result: string | null }
|
||||
[Ipc.drafts.set]: { args: [key: string, value: string]; result: void }
|
||||
[Ipc.drafts.delete]: { args: [key: string]; result: void }
|
||||
[Ipc.drafts.putBlob]: { args: [data: ArrayBuffer]; result: string }
|
||||
[Ipc.drafts.getBlob]: { args: [id: string]; result: ArrayBuffer | null }
|
||||
|
||||
[Ipc.files.openDirectoryPicker]: {
|
||||
args: [options?: DirectoryPickerOptions]
|
||||
result: string | string[] | null
|
||||
}
|
||||
[Ipc.files.openFilePicker]: { args: [options?: FilePickerOptions]; result: PickedFiles | null }
|
||||
[Ipc.files.readPickedFile]: { args: [token: string, path: string]; result: ArrayBuffer }
|
||||
[Ipc.files.releasePickedFiles]: { args: [token: string]; result: void }
|
||||
[Ipc.files.saveFilePicker]: { args: [options?: SaveFilePickerOptions]; result: string | null }
|
||||
[Ipc.files.openPath]: { args: [path: string, app?: string]; result: string | undefined }
|
||||
[Ipc.files.revealPath]: { args: [path: string]; result: boolean }
|
||||
[Ipc.files.readClipboardImage]: { args: []; result: ClipboardImage | null }
|
||||
|
||||
[Ipc.window.getId]: { args: []; result: string }
|
||||
[Ipc.window.getFocused]: { args: []; result: boolean }
|
||||
[Ipc.window.getFullscreen]: { args: []; result: boolean }
|
||||
[Ipc.window.setFocus]: { args: []; result: void }
|
||||
[Ipc.window.show]: { args: []; result: void }
|
||||
[Ipc.window.getZoomFactor]: { args: []; result: number }
|
||||
[Ipc.window.setZoomFactor]: { args: [factor: number]; result: void }
|
||||
[Ipc.window.getPinchZoomEnabled]: { args: []; result: boolean }
|
||||
[Ipc.window.setPinchZoomEnabled]: { args: [enabled: boolean]; result: void }
|
||||
[Ipc.window.setTitlebar]: { args: [theme: TitlebarTheme]; result: void }
|
||||
[Ipc.menu.runAction]: { args: [action: DesktopMenuAction]; result: void }
|
||||
|
||||
[Ipc.updater.subscribe]: { args: []; result: void }
|
||||
[Ipc.updater.unsubscribe]: { args: []; result: void }
|
||||
[Ipc.updater.check]: { args: []; result: UpdaterState }
|
||||
[Ipc.updater.install]: { args: []; result: void }
|
||||
|
||||
[Ipc.wsl.subscribe]: { args: []; result: void }
|
||||
[Ipc.wsl.unsubscribe]: { args: []; result: void }
|
||||
[Ipc.wsl.getState]: { args: []; result: WslServersState }
|
||||
[Ipc.wsl.probeRuntime]: { args: []; result: void }
|
||||
[Ipc.wsl.refreshDistros]: { args: []; result: void }
|
||||
[Ipc.wsl.installWsl]: { args: []; result: void }
|
||||
[Ipc.wsl.installDistro]: { args: [name: string]; result: void }
|
||||
[Ipc.wsl.probeAddable]: { args: [distros: string[]]; result: void }
|
||||
[Ipc.wsl.installOpencode]: { args: [name: string]; result: void }
|
||||
[Ipc.wsl.openTerminal]: { args: [name: string]; result: void }
|
||||
[Ipc.wsl.addServer]: { args: [distro: string]; result: WslServerConfig }
|
||||
[Ipc.wsl.removeServer]: { args: [id: string]; result: void }
|
||||
[Ipc.wsl.startServer]: { args: [id: string]; result: void }
|
||||
}
|
||||
|
||||
export type IpcSend = {
|
||||
[Ipc.app.relaunch]: []
|
||||
[Ipc.files.openExternal]: [url: string]
|
||||
[Ipc.files.openLocalFile]: [url: string]
|
||||
}
|
||||
|
||||
export type IpcEvent = {
|
||||
[Ipc.app.deepLink]: [urls: string[]]
|
||||
[Ipc.menu.command]: [id: string]
|
||||
[Ipc.updater.state]: [state: UpdaterState]
|
||||
[Ipc.wsl.event]: [event: WslServersEvent]
|
||||
[Ipc.window.fullscreenChanged]: [fullscreen: boolean]
|
||||
[Ipc.window.pinchZoomEnabledChanged]: [enabled: boolean]
|
||||
[Ipc.window.zoomFactorChanged]: [factor: number]
|
||||
}
|
||||
|
||||
export type IpcInvokeArgs<Channel extends keyof IpcInvoke> = IpcInvoke[Channel]["args"]
|
||||
export type IpcInvokeResult<Channel extends keyof IpcInvoke> = IpcInvoke[Channel]["result"]
|
||||
export type IpcInvokeMethod<Channel extends keyof IpcInvoke> = (
|
||||
...args: IpcInvokeArgs<Channel>
|
||||
) => Promise<IpcInvokeResult<Channel>>
|
||||
export type IpcSendMethod<Channel extends keyof IpcSend> = (...args: IpcSend[Channel]) => void
|
||||
export type IpcEventListener<Channel extends keyof IpcEvent> = (...args: IpcEvent[Channel]) => void
|
||||
export type IpcEventSubscription<Channel extends keyof IpcEvent> = (listener: IpcEventListener<Channel>) => () => void
|
||||
|
||||
type IpcEventSender = {
|
||||
send<Channel extends keyof IpcEvent>(channel: Channel, ...args: IpcEvent[Channel]): void
|
||||
}
|
||||
|
||||
export function sendIpcEvent<Channel extends keyof IpcEvent>(
|
||||
sender: IpcEventSender,
|
||||
channel: Channel,
|
||||
...args: IpcEvent[Channel]
|
||||
) {
|
||||
sender.send(channel, ...args)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ export { Command } from "@opencode-ai/schema/command"
|
||||
export { Connection } from "@opencode-ai/schema/connection"
|
||||
export { Credential } from "@opencode-ai/schema/credential"
|
||||
export { Integration } from "@opencode-ai/schema/integration"
|
||||
export { Mcp } from "@opencode-ai/schema/mcp"
|
||||
export { Model } from "@opencode-ai/schema/model"
|
||||
export { Provider } from "@opencode-ai/schema/provider"
|
||||
export { Reference } from "@opencode-ai/schema/reference"
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { McpApi } from "@opencode-ai/client/effect/api"
|
||||
import type { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import type { Effect, Types } from "effect"
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
export interface MCPDraft {
|
||||
list(): readonly [string, Types.DeepMutable<Mcp.ServerConfig>][]
|
||||
get(name: string): Types.DeepMutable<Mcp.ServerConfig> | undefined
|
||||
set(name: string, config: Mcp.ServerConfig): void
|
||||
update(name: string, update: (config: Types.DeepMutable<Mcp.ServerConfig>) => void): void
|
||||
remove(name: string): void
|
||||
}
|
||||
|
||||
export interface MCPDomain extends Omit<McpApi<unknown>, "resource"> {
|
||||
readonly transform: Transform<MCPDraft>
|
||||
readonly reload: () => Effect.Effect<void>
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import type { CatalogDomain } from "./catalog.js"
|
||||
import type { CommandDomain } from "./command.js"
|
||||
import type { EventDomain } from "./event.js"
|
||||
import type { IntegrationDomain } from "./integration.js"
|
||||
import type { MCPDomain } from "./mcp.js"
|
||||
import type { ReferenceDomain } from "./reference.js"
|
||||
import type { SessionDomain } from "./session.js"
|
||||
import type { ShellDomain } from "./shell.js"
|
||||
@@ -25,7 +24,6 @@ export interface Context {
|
||||
readonly command: CommandDomain
|
||||
readonly event: EventDomain
|
||||
readonly integration: IntegrationDomain
|
||||
readonly mcp: MCPDomain
|
||||
readonly plugin: PluginApi<unknown>
|
||||
readonly reference: ReferenceDomain
|
||||
readonly session: SessionDomain
|
||||
|
||||
@@ -75,7 +75,6 @@ export function fromPromise(plugin: Plugin) {
|
||||
const AgentEndpoints = ClientApi.groups["server.agent"].endpoints
|
||||
const CommandEndpoints = ClientApi.groups["server.command"].endpoints
|
||||
const IntegrationEndpoints = ClientApi.groups["server.integration"].endpoints
|
||||
const McpEndpoints = ClientApi.groups["server.mcp"].endpoints
|
||||
const ModelEndpoints = ClientApi.groups["server.model"].endpoints
|
||||
const PluginEndpoints = ClientApi.groups["server.plugin"].endpoints
|
||||
const ProviderEndpoints = ClientApi.groups["server.provider"].endpoints
|
||||
@@ -236,15 +235,6 @@ export function fromPromise(plugin: Plugin) {
|
||||
resolve: (connection) => Effect.runPromiseWith(context)(host.integration.connection.resolve(connection)),
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
list: adaptApiMethod(McpEndpoints["mcp.list"], host.mcp.list),
|
||||
add: adaptApiMethod(McpEndpoints["mcp.add"], host.mcp.add),
|
||||
remove: adaptApiMethod(McpEndpoints["mcp.remove"], host.mcp.remove),
|
||||
connect: adaptApiMethod(McpEndpoints["mcp.connect"], host.mcp.connect),
|
||||
disconnect: adaptApiMethod(McpEndpoints["mcp.disconnect"], host.mcp.disconnect),
|
||||
transform: transform(host.mcp),
|
||||
reload: () => run(host.mcp.reload()),
|
||||
},
|
||||
plugin: {
|
||||
list: adaptApiMethod(PluginEndpoints["plugin.list"], host.plugin.list),
|
||||
},
|
||||
|
||||
@@ -6,7 +6,6 @@ export { Command } from "@opencode-ai/schema/command"
|
||||
export { Connection } from "@opencode-ai/schema/connection"
|
||||
export { Credential } from "@opencode-ai/schema/credential"
|
||||
export { Integration } from "@opencode-ai/schema/integration"
|
||||
export { Mcp } from "@opencode-ai/schema/mcp"
|
||||
export { Model } from "@opencode-ai/schema/model"
|
||||
export { Provider } from "@opencode-ai/schema/provider"
|
||||
export { Reference } from "@opencode-ai/schema/reference"
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { McpApi } from "@opencode-ai/client/promise/api"
|
||||
import type { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import type { Transform } from "./registration.js"
|
||||
import type { DeepMutable } from "./types.js"
|
||||
|
||||
export interface MCPDraft {
|
||||
list(): readonly [string, DeepMutable<Mcp.ServerConfig>][]
|
||||
get(name: string): DeepMutable<Mcp.ServerConfig> | undefined
|
||||
set(name: string, config: Mcp.ServerConfig): void
|
||||
update(name: string, update: (config: DeepMutable<Mcp.ServerConfig>) => void): void
|
||||
remove(name: string): void
|
||||
}
|
||||
|
||||
export interface MCPDomain extends Omit<McpApi, "resource"> {
|
||||
readonly transform: Transform<MCPDraft>
|
||||
readonly reload: () => Promise<void>
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import type { CatalogDomain } from "./catalog.js"
|
||||
import type { CommandDomain } from "./command.js"
|
||||
import type { EventDomain } from "./event.js"
|
||||
import type { IntegrationDomain } from "./integration.js"
|
||||
import type { MCPDomain } from "./mcp.js"
|
||||
import type { ReferenceDomain } from "./reference.js"
|
||||
import type { SessionDomain } from "./session.js"
|
||||
import type { ShellDomain } from "./shell.js"
|
||||
@@ -24,7 +23,6 @@ export interface Context {
|
||||
readonly command: CommandDomain
|
||||
readonly event: EventDomain
|
||||
readonly integration: IntegrationDomain
|
||||
readonly mcp: MCPDomain
|
||||
readonly plugin: PluginApi
|
||||
readonly reference: ReferenceDomain
|
||||
readonly session: SessionDomain
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Command } from "@opencode-ai/schema/command"
|
||||
import { Connection } from "@opencode-ai/schema/connection"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Reference } from "@opencode-ai/schema/reference"
|
||||
@@ -24,7 +23,6 @@ test.each([
|
||||
expect(entrypoint.Connection).toBe(Connection)
|
||||
expect(entrypoint.Credential).toBe(Credential)
|
||||
expect(entrypoint.Integration).toBe(Integration)
|
||||
expect(entrypoint.Mcp).toBe(Mcp)
|
||||
expect(entrypoint.Model).toBe(Model)
|
||||
expect(entrypoint.Provider).toBe(Provider)
|
||||
expect(entrypoint.Reference).toBe(Reference)
|
||||
@@ -36,7 +34,6 @@ test.each([
|
||||
"Connection",
|
||||
"Credential",
|
||||
"Integration",
|
||||
"Mcp",
|
||||
"Model",
|
||||
"Plugin",
|
||||
"Provider",
|
||||
|
||||
@@ -2595,61 +2595,58 @@ type ToolProps = {
|
||||
}
|
||||
function GenericTool(props: ToolProps) {
|
||||
const theme = useTheme()
|
||||
const { currentSyntax: syntax } = useThemes()
|
||||
const output = createMemo(() => props.output?.trim() ?? "")
|
||||
const input = createMemo(() => Object.entries(props.input))
|
||||
const args = createMemo(() => JSON.stringify(props.input, null, 2))
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const expandable = createMemo(() => input().length > 0 || output().length > 0)
|
||||
const loading = createMemo(() => props.part.state.status === "streaming" || props.part.state.status === "running")
|
||||
const expandable = createMemo(() => Object.keys(props.input).length > 0 || output().length > 0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<InlineTool
|
||||
icon={props.part.state.status === "error" ? "✗" : "✓"}
|
||||
complete={props.part.state.status === "completed"}
|
||||
pending={props.tool}
|
||||
spinner={loading()}
|
||||
part={props.part}
|
||||
onClick={expandable() ? () => setExpanded((value) => !value) : undefined}
|
||||
>
|
||||
{genericToolSummary(props.tool, props.input)}
|
||||
</InlineTool>
|
||||
<BlockTool
|
||||
title={`◆ ${props.tool}`}
|
||||
part={props.part}
|
||||
spinner={props.part.state.status === "streaming" || props.part.state.status === "running"}
|
||||
onClick={expandable() ? () => setExpanded((value) => !value) : undefined}
|
||||
>
|
||||
<Show when={expanded()}>
|
||||
<box paddingLeft={3 + INLINE_TOOL_ICON_WIDTH}>
|
||||
<For each={input()}>
|
||||
{([key, value]) => (
|
||||
<box flexDirection="row">
|
||||
<text flexShrink={0} fg={theme.text.subdued}>
|
||||
{key}:{" "}
|
||||
</text>
|
||||
<text flexGrow={1} wrapMode="word" fg={theme.text.default}>
|
||||
{typeof value === "string" ? value : JSON.stringify(value, null, 2)}
|
||||
</text>
|
||||
<box gap={1} paddingTop={1}>
|
||||
<Show when={Object.keys(props.input).length > 0}>
|
||||
<box gap={1}>
|
||||
<text>
|
||||
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}> Input </span>
|
||||
</text>
|
||||
<box paddingLeft={1}>
|
||||
<code
|
||||
content={args()}
|
||||
filetype="json"
|
||||
syntaxStyle={syntax()}
|
||||
conceal={false}
|
||||
drawUnstyledText={false}
|
||||
fg={theme.text.default}
|
||||
/>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={output()}>
|
||||
{(value) => (
|
||||
<box flexDirection="row">
|
||||
<text flexShrink={0} fg={theme.text.subdued}>
|
||||
output:{" "}
|
||||
</text>
|
||||
<text flexGrow={1} fg={theme.text.default} wrapMode="word">
|
||||
{value()}
|
||||
<box gap={1}>
|
||||
<text>
|
||||
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}> Output </span>
|
||||
</text>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.text.default} wrapMode="word">
|
||||
{value()}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</>
|
||||
</BlockTool>
|
||||
)
|
||||
}
|
||||
|
||||
export function genericToolSummary(tool: string, input: Record<string, unknown>) {
|
||||
const args = primitiveInputSummary(input).replace(/\s+/g, " ")
|
||||
return `${tool}${args ? ` ${args}` : ""}`
|
||||
}
|
||||
|
||||
function useToolPermission(part: () => SessionMessageAssistantTool | undefined) {
|
||||
const ctx = use()
|
||||
const data = useData()
|
||||
|
||||
@@ -20,8 +20,11 @@ export function primitiveInputSummary(input: Record<string, unknown>, omit: read
|
||||
}
|
||||
|
||||
export function webSearchProviderLabel(provider: unknown) {
|
||||
if (typeof provider !== "string" || !provider) return "Web Search"
|
||||
return `Web Search via ${provider[0].toUpperCase()}${provider.slice(1)}`
|
||||
if (provider === "parallel") return "Parallel Web Search"
|
||||
if (provider === "exa") return "Exa Web Search"
|
||||
if (provider === "firecrawl") return "Firecrawl Web Search"
|
||||
if (provider === "tavily") return "Tavily Web Search"
|
||||
return "Web Search"
|
||||
}
|
||||
|
||||
export function toolDisplayMetadata(state: unknown): Record<string, unknown> {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { testRender, type JSX } from "@opentui/solid"
|
||||
import {
|
||||
InlineToolRow,
|
||||
executeCallSummary,
|
||||
genericToolSummary,
|
||||
isBackgroundSubagent,
|
||||
parseApplyPatchFiles,
|
||||
parseDiagnostics,
|
||||
@@ -200,21 +199,6 @@ describe("TUI inline tool wrapping", () => {
|
||||
).toBe("↳ session.prompt [text=first line second line]")
|
||||
})
|
||||
|
||||
test("summarizes generic tool arguments on one line", () => {
|
||||
expect(
|
||||
genericToolSummary("demo_search_catalog", {
|
||||
query: "wireless keyboard",
|
||||
limit: 8,
|
||||
includeArchived: false,
|
||||
filters: { category: "accessories" },
|
||||
}),
|
||||
).toBe("demo_search_catalog [query=wireless keyboard, limit=8, includeArchived=false]")
|
||||
expect(genericToolSummary("demo_get_weather", { city: "Tokyo", units: "celsius" })).toBe(
|
||||
"demo_get_weather [city=Tokyo, units=celsius]",
|
||||
)
|
||||
expect(genericToolSummary("demo_refresh", {})).toBe("demo_refresh")
|
||||
})
|
||||
|
||||
test("ignores diagnostics with malformed nested ranges", () => {
|
||||
expect(
|
||||
parseDiagnostics(
|
||||
|
||||
@@ -147,7 +147,7 @@ describe("run permission shared", () => {
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
title: 'Web Search via Parallel "current releases"',
|
||||
title: 'Parallel Web Search "current releases"',
|
||||
lines: ["Query: current releases"],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,14 +21,9 @@ test("normalizes shared tool primitives", () => {
|
||||
|
||||
describe("webSearchProviderLabel", () => {
|
||||
test("labels known providers", () => {
|
||||
expect(webSearchProviderLabel("parallel")).toBe("Web Search via Parallel")
|
||||
expect(webSearchProviderLabel("exa")).toBe("Web Search via Exa")
|
||||
expect(webSearchProviderLabel("firecrawl")).toBe("Web Search via Firecrawl")
|
||||
expect(webSearchProviderLabel("tavily")).toBe("Web Search via Tavily")
|
||||
})
|
||||
|
||||
test("labels providers dynamically", () => {
|
||||
expect(webSearchProviderLabel("other")).toBe("Web Search via Other")
|
||||
expect(webSearchProviderLabel("parallel")).toBe("Parallel Web Search")
|
||||
expect(webSearchProviderLabel("exa")).toBe("Exa Web Search")
|
||||
expect(webSearchProviderLabel("tavily")).toBe("Tavily Web Search")
|
||||
})
|
||||
|
||||
for (const [name, provider] of [
|
||||
@@ -37,7 +32,7 @@ describe("webSearchProviderLabel", () => {
|
||||
["an object", {}],
|
||||
["an array", []],
|
||||
["a number", 1],
|
||||
["an empty string", ""],
|
||||
["an unexpected string", "other"],
|
||||
] as const) {
|
||||
test(`uses the generic label for ${name}`, () => {
|
||||
expect(webSearchProviderLabel(provider)).toBe("Web Search")
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
--text-shimmer-index: 0;
|
||||
--text-shimmer-angle: 90deg;
|
||||
--text-shimmer-spread: 5.2ch;
|
||||
--text-shimmer-gutter: calc(var(--text-shimmer-spread) + 1ch);
|
||||
--text-shimmer-size: calc(200% + var(--text-shimmer-gutter) + var(--text-shimmer-gutter));
|
||||
--text-shimmer-size: 360%;
|
||||
--text-shimmer-base-color: var(--text-weak);
|
||||
--text-shimmer-peak-color: var(--text-strong);
|
||||
--text-shimmer-sweep: linear-gradient(
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
--_index: 0;
|
||||
--_angle: 90deg;
|
||||
--_spread: 5.2ch;
|
||||
--_gutter: calc(var(--_spread) + 1ch);
|
||||
--_size: calc(200% + var(--_gutter) + var(--_gutter));
|
||||
--_size: 360%;
|
||||
--_base-color: var(--v2-text-text-muted);
|
||||
--_peak-color: var(--v2-text-text-base);
|
||||
--_sweep: linear-gradient(
|
||||
|
||||
@@ -99,6 +99,9 @@ The database normally lives at:
|
||||
|
||||
`OPENCODE_DB` can override the database location.
|
||||
|
||||
OpenCode disables SQLite WAL mode when it detects NFS or another shared network filesystem. Set
|
||||
`OPENCODE_DB_WAL=true` to force WAL mode or `OPENCODE_DB_WAL=false` to disable it explicitly.
|
||||
|
||||
<Callout type="warning">
|
||||
Do not delete or edit service files or the database while troubleshooting. Use the service commands to manage the
|
||||
daemon, and make a backup before inspecting persistent data with external tools.
|
||||
|
||||
Reference in New Issue
Block a user