Compare commits

..

61 Commits

Author SHA1 Message Date
James Long 243a7adff7 refactor(core): finish test layer node conversion 2026-06-29 00:15:23 -04:00
James Long 4c1c374a41 test(core): fix layer node type assertion 2026-06-28 23:54:35 -04:00
James Long b9fa63a0bc refactor(core): convert more tests to layer nodes 2026-06-28 23:53:07 -04:00
opencode-agent[bot] 846d548154 chore: generate 2026-06-29 03:49:43 +00:00
James Long 84336e4f91 refactor(core): refine layer node replacements (#34377) 2026-06-28 23:48:18 -04:00
OpeOginni 01a5c69244 feat(tui): integrate ServerAuth headers into transport configuration for external served TUI thread (#29876) 2026-06-28 19:24:27 -05:00
opencode-agent[bot] d78f91afeb chore: generate 2026-06-28 23:36:32 +00:00
Brendan Allan 33762292f7 fix(app): wrap model.set in startTransition (#34351) 2026-06-29 07:35:09 +08:00
opencode-agent[bot] 5d6aa3b41a chore: generate 2026-06-28 23:18:35 +00:00
OpeOginni 683aca5dbe feat(desktop): Display stored totals for Tokens and Cost in Desktop Session Context (#28887) 2026-06-28 23:17:08 +00:00
Aiden Cline 92025e9a47 fix(mcp): clarify debug oauth probe (#34350) 2026-06-28 18:16:29 -05:00
Max Anderson 411e053572 fix(mcp): reconnect after OAuth even when server is disabled
Closes #33915
2026-06-28 18:07:10 -05:00
Frank b862d178bf zen: update alert role
deploy / deploy (push) Has been cancelled
2026-06-28 18:53:32 -04:00
Frank bda0ddc207 zen: new inference 2026-06-28 13:48:42 -04:00
Brendan Allan 58ba99e505 fix(desktop): avoid destroyed window permission checks (#34300) 2026-06-28 20:06:15 +08:00
Filip 6ee817d041 fix(app): disable add project when given server is offline (#34294) 2026-06-28 18:45:05 +08:00
Kit Langton dfeb1b5051 feat(client): generate complete protocol client (#34164)
deploy / deploy (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
containers / build (push) Has been cancelled
generate / generate (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
2026-06-27 22:25:40 -04:00
opencode-agent[bot] 61a7f6db35 chore: update nix node_modules hashes 2026-06-28 01:20:08 +00:00
Dax 6446b8ae3b fix(sdk): preserve V2Event name for SSE streams (#34171) 2026-06-27 21:05:47 -04:00
Brendan Allan ae53163cad fix(app): transition draft project updates (#34252) 2026-06-28 02:58:07 +08:00
opencode-agent[bot] 41202819a4 chore: generate 2026-06-27 18:11:37 +00:00
James Long a31698f99b refactor(core): move more tests to nodes (#34248) 2026-06-27 18:10:07 +00:00
opencode-agent[bot] 6248542c49 chore: generate 2026-06-27 17:20:42 +00:00
James Long d25c91e5eb refactor(core): move session test to nodes (#34245) 2026-06-27 13:19:14 -04:00
James Long 5d63020dcd test(core): cover app node builder graphs (#34244) 2026-06-27 16:54:18 +00:00
opencode-agent[bot] 062f54590e chore: generate 2026-06-27 16:30:47 +00:00
James Long a76c6918d2 refactor(core): rename app node modules (#34238) 2026-06-27 12:29:21 -04:00
Ben Guthrie 2b91a6f210 fix(tui): register prompt.skills keybinds (#34180) 2026-06-27 11:06:14 -05:00
opencode-agent[bot] 10579cceb2 chore: generate 2026-06-27 15:38:27 +00:00
Aarav Sareen 25702e01ce feat(app): new debug bar (#34237) 2026-06-27 15:37:02 +00:00
James Long ecc5c44d9a refactor(core): make node build bind maps conditionally (#34218) 2026-06-27 11:09:07 -04:00
Aarav Sareen f5a0b920a2 feat(app): minor visual updates (#34205) 2026-06-27 18:07:17 +08:00
Brendan Allan 2caa016fe1 fix(app): batch new session tab navigation (#34196) 2026-06-27 17:13:09 +08:00
opencode-agent[bot] 6861fedd09 chore: generate 2026-06-27 07:43:42 +00:00
Luke Parker 3d072112ce fix(app): reconcile session pages with concurrent events (#34042) 2026-06-27 17:42:17 +10:00
OpeOginni bdfea046db fix(desktop): recognize normal auth metadata input prompts in connect provider dialog (#33024)
deploy / deploy (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
docs-locale-sync / sync-locales (push) Has been cancelled
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
2026-06-27 05:19:59 +00:00
Aarav Sareen a2d08fb63b feat(app): update home screen alignment + markdown styles (#34172) 2026-06-27 13:00:52 +08:00
opencode-agent[bot] 5a55135d89 fix(ui): make select hover feedback immediate (#34121)
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
2026-06-27 12:47:32 +08:00
opencode-agent[bot] 8870d36e0f fix(app): space home sessions from scrollbar (#34132)
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
2026-06-27 12:46:33 +08:00
opencode-agent[bot] eb923c27ca chore: update nix node_modules hashes 2026-06-27 04:27:56 +00:00
opencode-agent[bot] 9903abc704 fix(opencode): allow empty provider config default (#34167)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-26 23:19:42 -05:00
opencode-agent[bot] 225a1fbf35 chore: generate 2026-06-27 04:10:30 +00:00
Dax Raad 93159bccbf feat(core): port v2 runtime fixes onto dev
Cherry-picks the packages/core changes from the v2 branch onto dev:
- combined ordered stdout/stderr in AppProcess + bash structured output
- edit/apply-patch return FileDiff info with status and line stats
- ignore no-op model switches; record reasoning timestamps
- return unexpected local tool defects to the model and continue
- keep OAuth account metadata out of request bodies
- nest OpenAI reasoning effort/summary options
- load OpenCode provider config asynchronously; batch plugin boot
- export latest public event manifest

Includes the supporting schema reasoning time field and regenerated
client/SDK types.
2026-06-27 00:07:25 -04:00
opencode-agent[bot] fab8ec4f54 fix(app): migrate composer tooltips to v2 (#34147)
Co-authored-by: Jay V <air@live.ca>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-27 14:01:45 +10:00
opencode-agent[bot] a3035c53ea chore: update nix node_modules hashes 2026-06-27 03:47:28 +00:00
Aiden Cline 36c416e143 fix(mcp): request refresh token scope (#34125) 2026-06-26 22:27:43 -05:00
Aiden Cline e1e0304a96 fix(mcp): surface OAuth completion errors (#34145) 2026-06-26 22:21:16 -05:00
opencode-agent[bot] 71c3a7c8f2 chore: generate 2026-06-27 02:47:33 +00:00
James Long ecdfff5a42 refactor(core): separate out location node functionality and integrate into v2 (#34119) 2026-06-26 22:46:07 -04:00
opencode-agent[bot] 4b948c5d74 fix(app): hide home session archive action (#34136)
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
2026-06-27 02:23:36 +00:00
opencode-agent[bot] cd56c51e2d fix(app): keep bare slash as plain inline code (#34122)
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
2026-06-26 22:21:01 +00:00
opencode-agent[bot] 43e39d7f68 fix(tui): use generated event union (#34118)
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
2026-06-26 18:04:53 -04:00
Frank 7a17925495 zen: new inference
deploy / deploy (push) Has been cancelled
2026-06-26 16:25:07 -04:00
Brendan Allan 5acb2530b4 fix(app): centralize notification state (#34105) 2026-06-26 19:24:33 +00:00
opencode-agent[bot] 44a6787359 chore: generate 2026-06-26 19:18:18 +00:00
Kit Langton 42e6b7db32 feat(sdk): expose live event stream (#34098) 2026-06-26 21:16:33 +02:00
opencode-agent[bot] 2c02f8bace chore: update nix node_modules hashes 2026-06-26 18:53:34 +00:00
Affan Ali 2ec20e576b fix(app): slow tooltip display for models (#30745)
Co-authored-by: affanali2k3 <affanalikhanxx@gmail.com>
2026-06-27 02:53:08 +08:00
opencode-agent[bot] 20f47fec7a chore: generate 2026-06-26 18:51:42 +00:00
Kit Langton 65210f2d97 feat(api): add finite durable session history pages (#34097) 2026-06-26 14:49:47 -04:00
Brendan Allan af0b7ffae7 fix(app): animate prompt selectors after loading (#34101) 2026-06-27 02:36:01 +08:00
225 changed files with 5103 additions and 6988 deletions
+2 -7
View File
@@ -6,7 +6,6 @@ on:
branches:
- ci
- dev
- v2
- beta
- fix/npm-native-binary-install
- snapshot-*
@@ -32,9 +31,6 @@ permissions:
contents: write
packages: write
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'next') || '' }}
jobs:
version:
runs-on: blacksmith-4vcpu-ubuntu-2404
@@ -126,7 +122,7 @@ jobs:
- build-cli
- version
runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
if: github.repository == 'anomalyco/opencode'
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
@@ -225,7 +221,7 @@ jobs:
needs:
- build-cli
- version
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
if: github.repository == 'anomalyco/opencode'
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -451,7 +447,6 @@ jobs:
path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2'
with:
name: opencode-cli-signed-windows
path: packages/opencode/dist
+5 -2
View File
@@ -4,7 +4,6 @@ on:
push:
branches:
- dev
- v2
pull_request:
workflow_dispatch:
@@ -75,9 +74,13 @@ jobs:
working-directory: packages/client
run: bun run check:generated
- name: Run HttpApi exerciser gates
if: runner.os == 'Linux'
working-directory: packages/opencode
run: bun run test:httpapi
e2e:
name: e2e (${{ matrix.settings.name }})
if: github.ref_name != 'v2' && github.head_ref != 'v2'
strategy:
fail-fast: false
matrix:
+1 -7
View File
@@ -94,7 +94,7 @@
"name": "@opencode-ai/cli",
"version": "1.17.11",
"bin": {
"opencode2": "./bin/opencode2.cjs",
"lildax": "./bin/lildax.cjs",
},
"dependencies": {
"@effect/platform-node": "catalog:",
@@ -106,15 +106,12 @@
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"effect": "catalog:",
"jsonc-parser": "3.3.1",
"semver": "catalog:",
"solid-js": "catalog:",
},
"devDependencies": {
"@opencode-ai/script": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/semver": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
@@ -939,7 +936,6 @@
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"clipboardy": "4.0.0",
"diff": "catalog:",
"effect": "catalog:",
@@ -5983,8 +5979,6 @@
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
+1 -2
View File
@@ -2,12 +2,11 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "0.0.0",
"private": true,
"type": "module",
"packageManager": "bun@1.3.14",
"scripts": {
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
@@ -9,7 +9,7 @@ import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
@@ -45,6 +45,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
})
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
const usd = createMemo(
() =>
@@ -54,10 +55,10 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
}),
)
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
const context = createMemo(() => metrics().context)
const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
const tokens = createMemo(() => info()?.tokens)
const cost = createMemo(() => {
return usd().format(metrics().totalCost)
return usd().format(info()?.cost ?? 0)
})
const openContext = () => {
@@ -82,18 +83,22 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const tooltipValue = () => (
<div>
<Show when={tokens()}>
{(value) => (
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">
{getSessionTokenTotal(value())?.toLocaleString(language.intl())}
</span>
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
</div>
)}
</Show>
<Show when={context()}>
{(ctx) => (
<>
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{ctx().total.toLocaleString(language.intl())}</span>
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{ctx().usage ?? 0}%</span>
<span class="text-text-invert-base">{language.t("context.usage.usage")}</span>
</div>
</>
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{ctx().usage ?? 0}%</span>
<span class="text-text-invert-base">{language.t("context.usage.usage")}</span>
</div>
)}
</Show>
<div class="flex items-center gap-2">
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { getSessionContextMetrics } from "./session-context-metrics"
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
const assistant = (
id: string,
@@ -37,8 +37,8 @@ const user = (id: string) => {
} as unknown as Message
}
describe("getSessionContextMetrics", () => {
test("computes totals and usage from latest assistant with tokens", () => {
describe("getSessionContext", () => {
test("computes usage from latest assistant with tokens", () => {
const messages = [
user("u1"),
assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5),
@@ -57,45 +57,52 @@ describe("getSessionContextMetrics", () => {
},
]
const metrics = getSessionContextMetrics(messages, providers)
const ctx = getSessionContext(messages, providers)
expect(metrics.totalCost).toBe(1.75)
expect(metrics.context?.message.id).toBe("a2")
expect(metrics.context?.total).toBe(500)
expect(metrics.context?.usage).toBe(50)
expect(metrics.context?.providerLabel).toBe("OpenAI")
expect(metrics.context?.modelLabel).toBe("GPT-4.1")
expect(ctx?.message.id).toBe("a2")
expect(ctx?.usage).toBe(50)
expect(ctx?.providerLabel).toBe("OpenAI")
expect(ctx?.modelLabel).toBe("GPT-4.1")
})
test("preserves fallback labels and null usage when model metadata is missing", () => {
const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")]
const providers = [{ id: "p-1", models: {} }]
const metrics = getSessionContextMetrics(messages, providers)
const ctx = getSessionContext(messages, providers)
expect(metrics.context?.providerLabel).toBe("p-1")
expect(metrics.context?.modelLabel).toBe("m-1")
expect(metrics.context?.limit).toBeUndefined()
expect(metrics.context?.usage).toBeNull()
expect(ctx?.providerLabel).toBe("p-1")
expect(ctx?.modelLabel).toBe("m-1")
expect(ctx?.limit).toBeUndefined()
expect(ctx?.usage).toBeNull()
})
test("recomputes when message array is mutated in place", () => {
const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)]
const providers = [{ id: "openai", models: {} }]
const one = getSessionContextMetrics(messages, providers)
const one = getSessionContext(messages, providers)
messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75))
const two = getSessionContextMetrics(messages, providers)
const two = getSessionContext(messages, providers)
expect(one.context?.message.id).toBe("a1")
expect(two.context?.message.id).toBe("a2")
expect(two.totalCost).toBe(1)
expect(one?.message.id).toBe("a1")
expect(two?.message.id).toBe("a2")
})
test("returns empty metrics when inputs are undefined", () => {
const metrics = getSessionContextMetrics(undefined, undefined)
test("returns undefined when inputs are undefined", () => {
const ctx = getSessionContext(undefined, undefined)
expect(metrics.totalCost).toBe(0)
expect(metrics.context).toBeUndefined()
expect(ctx).toBeUndefined()
})
test("computes stored session token totals", () => {
expect(
getSessionTokenTotal({
input: 10,
output: 20,
reasoning: 30,
cache: { read: 40, write: 50 },
}),
).toBe(150)
})
})
@@ -1,4 +1,4 @@
import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
import type { AssistantMessage, Message, Session } from "@opencode-ai/sdk/v2/client"
type Provider = {
id: string
@@ -21,19 +21,9 @@ type Context = {
modelLabel: string
limit: number | undefined
input: number
output: number
reasoning: number
cacheRead: number
cacheWrite: number
total: number
usage: number | null
}
type Metrics = {
totalCost: number
context: Context | undefined
}
const tokenTotal = (msg: AssistantMessage) => {
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
}
@@ -47,10 +37,9 @@ const lastAssistantWithTokens = (messages: Message[]) => {
}
}
const build = (messages: Message[] = [], providers: Provider[] = []): Metrics => {
const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0)
const build = (messages: Message[] = [], providers: Provider[] = []): Context | undefined => {
const message = lastAssistantWithTokens(messages)
if (!message) return { totalCost, context: undefined }
if (!message) return undefined
const provider = providers.find((item) => item.id === message.providerID)
const model = provider?.models[message.modelID]
@@ -58,25 +47,22 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Metrics =>
const total = tokenTotal(message)
return {
totalCost,
context: {
message,
provider,
model,
providerLabel: provider?.name ?? message.providerID,
modelLabel: model?.name ?? message.modelID,
limit,
input: message.tokens.input,
output: message.tokens.output,
reasoning: message.tokens.reasoning,
cacheRead: message.tokens.cache.read,
cacheWrite: message.tokens.cache.write,
total,
usage: limit ? Math.round((total / limit) * 100) : null,
},
message,
provider,
model,
providerLabel: provider?.name ?? message.providerID,
modelLabel: model?.name ?? message.modelID,
limit,
input: message.tokens.input,
usage: limit ? Math.round((total / limit) * 100) : null,
}
}
export function getSessionContextMetrics(messages: Message[] = [], providers: Provider[] = []) {
export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) {
return build(messages, providers)
}
export function getSessionTokenTotal(tokens: Session["tokens"] | undefined) {
if (!tokens) return undefined
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
}
@@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
import { useSessionLayout } from "@/pages/session/session-layout"
import { getSessionContextMetrics } from "./session-context-metrics"
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format"
@@ -134,12 +134,12 @@ export function SessionContextTab() {
}),
)
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
const ctx = createMemo(() => metrics().context)
const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
const tokens = createMemo(() => info()?.tokens)
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
const cost = createMemo(() => {
return usd().format(metrics().totalCost)
return usd().format(info()?.cost ?? 0)
})
const counts = createMemo(() => {
@@ -204,14 +204,14 @@ export function SessionContextTab() {
{ label: "context.stats.provider", value: providerLabel },
{ label: "context.stats.model", value: modelLabel },
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
{ label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
{ label: "context.stats.totalTokens", value: () => formatter().number(getSessionTokenTotal(tokens())) },
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
{ label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.output) },
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.reasoning) },
{ label: "context.stats.inputTokens", value: () => formatter().number(tokens()?.input) },
{ label: "context.stats.outputTokens", value: () => formatter().number(tokens()?.output) },
{ label: "context.stats.reasoningTokens", value: () => formatter().number(tokens()?.reasoning) },
{
label: "context.stats.cacheTokens",
value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`,
value: () => `${formatter().number(tokens()?.cache.read)} / ${formatter().number(tokens()?.cache.write)}`,
},
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
+31 -27
View File
@@ -1,7 +1,7 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo } from "solid-js"
import { batch, createEffect, createMemo, startTransition } from "solid-js"
import { createStore } from "solid-js/store"
import { useModels } from "@/context/models"
import { useProviders } from "@/hooks/use-providers"
@@ -294,19 +294,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
model.set({ providerID: entry.provider.id, modelID: entry.id })
},
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
batch(() => {
setStore("last", {
type: "model",
agent: agent.current()?.name,
model: item ?? null,
variant: selected(),
})
write({ model: item })
if (!item) return
models.setVisibility(item, true)
if (!options?.recent) return
models.recent.push(item)
})
startTransition(() =>
batch(() => {
setStore("last", {
type: "model",
agent: agent.current()?.name,
model: item ?? null,
variant: selected(),
})
write({ model: item })
if (!item) return
models.setVisibility(item, true)
if (!options?.recent) return
models.recent.push(item)
}),
)
},
visible(item: ModelKey) {
return models.visible(item)
@@ -335,19 +337,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return Object.keys(item.variants)
},
set(value: string | undefined) {
batch(() => {
const model = current()
setStore("last", {
type: "variant",
agent: agent.current()?.name,
model: model ? { providerID: model.provider.id, modelID: model.id } : null,
variant: value ?? null,
})
write({ variant: value ?? null })
if (model) {
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined)
}
})
startTransition(() =>
batch(() => {
const model = current()
setStore("last", {
type: "variant",
agent: agent.current()?.name,
model: model ? { providerID: model.provider.id, modelID: model.id } : null,
variant: value ?? null,
})
write({ variant: value ?? null })
if (model) {
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined)
}
}),
)
},
cycle() {
const items = this.list()
+8 -6
View File
@@ -138,12 +138,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const next = { type: "session" as const, ...tab }
const existing = store.find((item) => tabKey(item) === tabKey(next))
if (existing) return existing
setStore(
produce((tabs) => {
if (tabs.some((item) => tabKey(item) === tabKey(next))) return
tabs.push(next)
}),
)
void startTransition(() => {
setStore(
produce((tabs) => {
if (tabs.some((item) => tabKey(item) === tabKey(next))) return
tabs.push(next)
}),
)
})
return next
},
reorder(keys: string[]) {
-98
View File
@@ -1,98 +0,0 @@
# V2 CLI and TUI development guide
## Migration context
- The TUI is being ported from legacy APIs to the new V2 APIs. New and migrated TUI behavior should use `sdk.client.v2` and the location-scoped data in `packages/tui/src/context/data.tsx` instead of adding dependencies on legacy sync state.
- Preserve established TUI behavior unless the task intentionally changes it. When behavior, copy, keyboard interaction, or layout is unclear, compare the local V2 TUI with the latest released 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 --standalone
# 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`.
## Interactive debugging
- 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 --standalone` for most debugging so the TUI starts with a private V2 server instead of depending on the background service.
- 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 --standalone
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, omit `--standalone`. Service lifecycle commands are available through `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
```
## 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.
## 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 --standalone
```
- Use `--inspect-wait` or `--inspect-brk` when execution must pause until the 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.
@@ -31,11 +31,11 @@ function run(target) {
const envPath = process.env.OPENCODE_BIN_PATH
const scriptDir = path.dirname(fs.realpathSync(__filename))
const cached = path.join(scriptDir, ".opencode2")
const cached = path.join(scriptDir, ".lildax")
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] || os.platform()
const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] || os.arch()
const base = "@opencode-ai/cli-" + platform + "-" + arch
const binary = platform === "windows" ? "opencode2.exe" : "opencode2"
const binary = platform === "windows" ? "lildax.exe" : "lildax"
function supportsAvx2() {
if (arch !== "x64") return false
@@ -121,7 +121,7 @@ function findBinary(startDir) {
const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir))
if (!resolved) {
console.error(
"It seems that your package manager failed to install the right opencode2 CLI package. Try manually installing " +
"It seems that your package manager failed to install the right lildax CLI package. Try manually installing " +
names.map((name) => `"${name}"`).join(" or ") +
" package",
)
+1 -4
View File
@@ -5,7 +5,7 @@
"type": "module",
"license": "MIT",
"bin": {
"opencode2": "./bin/opencode2.cjs"
"lildax": "./bin/lildax.cjs"
},
"files": [
"bin"
@@ -25,15 +25,12 @@
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"effect": "catalog:",
"jsonc-parser": "3.3.1",
"semver": "catalog:",
"solid-js": "catalog:"
},
"devDependencies": {
"@opencode-ai/script": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/semver": "catalog:",
"@typescript/native-preview": "catalog:"
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ import pkg from "../package.json"
import { modelsData } from "./generate"
const dir = path.resolve(import.meta.dirname, "..")
const binary = "opencode2"
const binary = "lildax"
process.chdir(dir)
await rm("dist", { recursive: true, force: true })
+6 -7
View File
@@ -25,15 +25,14 @@ for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: "./dist" }
}
console.log("binaries", binaries)
const version = Object.values(binaries)[0]
const name = pkg.name
await $`mkdir -p ./dist/${name}/bin`
await $`cp ./bin/opencode2.cjs ./dist/${name}/bin/opencode2`
await Bun.file(`./dist/${name}/package.json`).write(
await $`mkdir -p ./dist/${pkg.name}/bin`
await $`cp ./bin/lildax.cjs ./dist/${pkg.name}/bin/lildax`
await Bun.file(`./dist/${pkg.name}/package.json`).write(
JSON.stringify(
{
name,
bin: { opencode2: "./bin/opencode2" },
name: pkg.name,
bin: { lildax: "./bin/lildax" },
version,
license: pkg.license,
repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
@@ -51,4 +50,4 @@ await Promise.all(
publish(`./dist/${name.replace("@opencode-ai/", "")}`, name, version),
),
)
await publish(`./dist/${name}`, name, version)
await publish(`./dist/${pkg.name}`, pkg.name, version)
-11
View File
@@ -5,16 +5,6 @@ declare const OPENCODE_CLI_NAME: string | undefined
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
description: "OpenCode 2.0 preview command line interface",
params: {
directory: Argument.string("directory").pipe(
Argument.withDescription("Directory to start OpenCode in"),
Argument.optional,
),
standalone: Flag.boolean("standalone").pipe(
Flag.withDescription("Run with a private server instead of the background service"),
Flag.withDefault(false),
),
},
commands: [
Spec.make("api", {
description: "Make a request to the running server",
@@ -56,7 +46,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
hostname: Flag.string("hostname").pipe(Flag.withDefault("127.0.0.1")),
port: Flag.integer("port").pipe(Flag.optional),
register: Flag.boolean("register").pipe(Flag.withDefault(false)),
stdio: Flag.boolean("stdio").pipe(Flag.withDefault(false)),
},
}),
],
+4 -18
View File
@@ -1,27 +1,13 @@
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Effect, Option } from "effect"
import { Effect } from "effect"
import { Daemon } from "../../services/daemon"
import { Standalone } from "../../services/standalone"
import { Updater } from "../../services/updater"
export default Runtime.handler(Commands, (input) =>
export default Runtime.handler(Commands, () =>
Effect.gen(function* () {
const directory = Option.getOrUndefined(input.directory)
if (directory !== undefined) process.chdir(directory)
const updater = yield* Updater.Service
yield* updater.check()
const daemon = yield* Daemon.Service
const transport = yield* (input.standalone ? Standalone.transport() : daemon.transport())
const transport = yield* daemon.transport()
const { runTui } = yield* Effect.promise(() => import("../../tui"))
yield* runTui(
transport,
input.standalone
? undefined
: async () => {
await Effect.runPromise(daemon.stop())
return Effect.runPromise(daemon.transport())
},
)
yield* runTui(transport)
}),
)
+6 -41
View File
@@ -6,12 +6,9 @@ import * as Effect from "effect/Effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { createServer } from "node:http"
import { createRoutes } from "@opencode-ai/server/routes"
import { ServerAuth } from "@opencode-ai/server/auth"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Daemon } from "../../services/daemon"
import { Updater } from "../../services/updater"
export default Runtime.handler(
Commands.commands.serve,
@@ -19,43 +16,15 @@ export default Runtime.handler(
return yield* Effect.scoped(
Effect.gen(function* () {
const daemon = yield* Daemon.Service
const standalonePassword = process.env.OPENCODE_SERVER_PASSWORD
if (input.stdio) delete process.env.OPENCODE_SERVER_PASSWORD
const password = input.stdio ? standalonePassword : yield* daemon.password()
if (!password) return yield* Effect.fail(new Error("Missing server password"))
const address = yield* listen(input.hostname, input.port, password)
yield* Effect.tryPromise(() =>
createOpencodeClient({
baseUrl: HttpServer.formatAddress(address),
headers: ServerAuth.headers({ password }),
}).v2.location.get(undefined, { throwOnError: true }),
)
const address = yield* listen(input.hostname, input.port, yield* daemon.password())
if (input.register) yield* daemon.register(address)
const url = HttpServer.formatAddress(address)
console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`)
const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.forkScoped)
return yield* (input.stdio ? waitForStdinClose() : Effect.never)
}).pipe(Effect.annotateLogs({ role: "server" })),
console.log(`server listening on ${HttpServer.formatAddress(address)}`)
return yield* Effect.never
}),
)
}),
)
function waitForStdinClose() {
return Effect.callback<void>((resume) => {
const close = () => resume(Effect.void)
process.stdin.once("end", close)
process.stdin.once("close", close)
process.stdin.resume()
if (process.stdin.readableEnded || process.stdin.destroyed) close()
return Effect.sync(() => {
process.stdin.off("end", close)
process.stdin.off("close", close)
process.stdin.pause()
})
})
}
function listen(hostname: string, port: Option.Option<number>, password: string) {
if (Option.isSome(port)) return bind(hostname, port.value, password)
const next = (port: number): ReturnType<typeof bind> =>
@@ -66,15 +35,11 @@ function listen(hostname: string, port: Option.Option<number>, password: string)
}
function bind(hostname: string, port: number, password: string) {
const server = createServer()
return Layer.build(
HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe(
Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })),
Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port, host: hostname })),
Layer.provide(Credential.defaultLayer),
Layer.provide(PermissionSaved.defaultLayer),
),
).pipe(
Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))),
Effect.map((context) => Context.get(context, HttpServer.HttpServer).address),
)
).pipe(Effect.map((context) => Context.get(context, HttpServer.HttpServer).address))
}
@@ -8,6 +8,6 @@ export default Runtime.handler(
Commands.commands.service.commands.status,
Effect.fn("cli.service.status")(function* () {
const url = yield* (yield* Daemon.Service).status()
process.stdout.write((url ? url : "stopped") + EOL)
process.stdout.write((url ? `running ${url}` : "stopped") + EOL)
}),
)
+3 -5
View File
@@ -2,8 +2,6 @@ import * as Effect from "effect/Effect"
import * as Command from "effect/unstable/cli/Command"
import { Spec } from "./spec"
import { Daemon } from "../services/daemon"
import { Updater } from "../services/updater"
import { Scope } from "effect"
export type Input<Value> =
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
@@ -12,11 +10,11 @@ export type Input<Value> =
? Input
: never
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service | Updater.Service | Scope.Scope>
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service>
type Loader<Node extends Spec.Any> = () => Promise<{
default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service | Updater.Service | Scope.Scope>
default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service>
}>
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service | Updater.Service | Scope.Scope>
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service>
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
? Loader<Node>
+1 -17
View File
@@ -2,21 +2,10 @@
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
import * as NodeServices from "@effect/platform-node/NodeServices"
import { NodeFileSystem } from "@effect/platform-node"
import * as Effect from "effect/Effect"
import { Layer, Logger, References } from "effect"
import { Commands } from "./commands/commands"
import { Runtime } from "./framework/runtime"
import { Daemon } from "./services/daemon"
import { Logging } from "@opencode-ai/core/observability/logging"
import { Updater } from "./services/updater"
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version"
const LoggingLayer = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe(
Layer.provide(NodeFileSystem.layer),
Layer.orDie,
Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())),
)
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
@@ -35,14 +24,9 @@ const Handlers = Runtime.handlers(Commands, {
serve: () => import("./commands/handlers/serve"),
})
Effect.logInfo("cli starting", { version: InstallationVersion, channel: InstallationChannel, local: InstallationLocal }).pipe(
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
Effect.annotateLogs({ role: "cli" }),
Runtime.run(Commands, Handlers, { version: "local" }).pipe(
Effect.provide(Daemon.defaultLayer),
Effect.provide(Updater.defaultLayer),
Effect.provide(LoggingLayer),
Effect.provide(NodeServices.layer),
Effect.scoped,
Effect.tap(() => Effect.sync(() => process.exit(0))),
NodeRuntime.runMain,
)
+12 -24
View File
@@ -1,5 +1,5 @@
import { Global } from "@opencode-ai/core/global"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { ServerAuth } from "@opencode-ai/server/auth"
import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
@@ -28,10 +28,6 @@ const Registration = Schema.Struct({
})
type Registration = typeof Registration.Type
const Config = Schema.Struct({
password: Schema.optional(Schema.String),
})
function sameRegistration(left: Registration, right: Registration) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
}
@@ -41,30 +37,22 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const directory = Global.Path.state
const file = path.join(directory, InstallationChannel === "local" ? "server-local.json" : "server.json")
const configFile = path.join(Global.Path.config, "service.json")
const legacyPasswordFile = path.join(directory, "password")
const file = path.join(directory, "server.json")
const passwordFile = path.join(directory, "password")
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration))
const decodeConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(Config))
const password = Effect.fn("cli.daemon.password")(function* (value?: string) {
const config = yield* fs
.readFileString(configFile)
.pipe(Effect.flatMap(decodeConfig), Effect.catch(() => Effect.succeed(undefined)))
if (value === undefined && config?.password) return config.password
const legacy = yield* fs
.readFileString(legacyPasswordFile)
.pipe(Effect.catch(() => Effect.succeed(undefined)))
const next = value ?? legacy ?? randomBytes(32).toString("base64url")
const existing = yield* fs.readFileString(passwordFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (value === undefined && existing) return existing
// Keep one private credential across server restarts so discovered clients
// can reconnect without exposing a password flag or environment variable.
const temp = configFile + ".tmp"
yield* fs.writeFileString(temp, JSON.stringify({ password: next }, null, 2) + "\n", { mode: 0o600 })
yield* fs.rename(temp, configFile)
if (legacy) yield* fs.remove(legacyPasswordFile).pipe(Effect.ignore)
return next
const generated = value ?? randomBytes(32).toString("base64url")
const temp = passwordFile + ".tmp"
yield* fs.makeDirectory(directory, { recursive: true })
yield* fs.writeFileString(temp, generated, { mode: 0o600 })
yield* fs.rename(temp, passwordFile)
return generated
})
const registration = Effect.fnUntraced(function* () {
@@ -123,7 +111,7 @@ export const layer = Layer.effect(
const existing = yield* healthy().pipe(Effect.option)
const found = Option.getOrUndefined(existing)
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
if (found?.version === InstallationVersion) return found.url
if (found?.version === InstallationVersion && compiled) return found.url
if (found) yield* stopProcess(found).pipe(Effect.ignore)
const entrypoint = compiled ? undefined : process.argv[1]
-41
View File
@@ -1,41 +0,0 @@
import { ServerAuth } from "@opencode-ai/server/auth"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Effect, Schema, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { randomBytes } from "node:crypto"
import path from "node:path"
const Ready = Schema.Struct({ url: Schema.String })
const decodeReady = Schema.decodeUnknownPromise(Schema.fromJsonString(Ready))
function command(password: string) {
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
const entrypoint = compiled ? [] : process.argv[1] ? [process.argv[1]] : []
if (!compiled && entrypoint.length === 0) throw new Error("Failed to resolve CLI entrypoint")
return ChildProcess.make(process.execPath, [...entrypoint, "serve", "--stdio", "--port", "0"], {
cwd: process.cwd(),
env: { OPENCODE_SERVER_PASSWORD: password },
extendEnv: true,
// The server treats EOF on this pipe as the end of its ownership lease.
// The OS closes it even when the TUI is killed before Effect finalizers run.
stdin: "pipe",
stderr: "ignore",
killSignal: "SIGTERM",
forceKillAfter: "3 seconds",
})
}
export const transport = Effect.fn("cli.standalone.transport")(
function* () {
const password = randomBytes(32).toString("base64url")
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const proc = yield* spawner.spawn(command(password))
const output = yield* proc.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.take(1), Stream.mkString)
if (!output) return yield* Effect.fail(new Error("Standalone server exited before reporting readiness"))
const ready = yield* Effect.tryPromise(() => decodeReady(output))
return { url: ready.url, headers: ServerAuth.headers({ password }), pid: proc.pid }
},
Effect.provide(CrossSpawnSpawner.defaultLayer),
)
export * as Standalone from "./standalone"
-33
View File
@@ -1,33 +0,0 @@
import { describe, expect, test } from "bun:test"
import { action, decodePolicy } from "./updater"
describe("updater", () => {
test("reads autoupdate from JSONC", () => {
expect(decodePolicy('{ // preference\n "autoupdate": "notify",\n}')).toBe("notify")
expect(decodePolicy('{ "autoupdate": false }')).toBe(false)
expect(decodePolicy('{ "autoupdate": "invalid" }')).toBeUndefined()
})
test("automatically updates patches and minors", () => {
expect(action("1.2.3", "1.2.4", true)).toBe("upgrade")
expect(action("1.2.3", "1.3.0", true)).toBe("upgrade")
expect(action("1.2.3", "1.2.4", "notify")).toBe("upgrade")
expect(action("1.2.3", "1.3.0", "notify")).toBe("upgrade")
})
test("skips when autoupdate is disabled", () => {
expect(action("1.2.3", "1.2.4", false)).toBe("none")
})
test("never automatically updates majors", () => {
expect(action("1.2.3", "2.0.0", true)).toBe("none")
})
test("reports up-to-date only when versions match", () => {
expect(action("1.2.3", "1.2.3", true)).toBe("none")
})
test("upgrades when latest is lower (rollback)", () => {
expect(action("1.2.4", "1.2.3", true)).toBe("upgrade")
})
})
-158
View File
@@ -1,158 +0,0 @@
import { Global } from "@opencode-ai/core/global"
import { Flag } from "@opencode-ai/core/flag/flag"
import { AppProcess } from "@opencode-ai/core/process"
import {
InstallationChannel,
InstallationLocal,
InstallationVersion,
} from "@opencode-ai/core/installation/version"
import { Context, Duration, Effect, FileSystem, Layer } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
import semver from "semver"
export type Policy = boolean | "notify"
export type Action = "none" | "upgrade"
type Method = "npm" | "pnpm" | "bun" | "yarn"
const packageName = "@opencode-ai/cli"
export interface Interface {
readonly check: () => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
export function decodePolicy(text: string): Policy | undefined {
// The CLI only projects this host-level preference instead of initializing
// the location-scoped server configuration graph.
const errors: ParseError[] = []
const input: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length || typeof input !== "object" || input === null || !("autoupdate" in input)) return
const value = input.autoupdate
if (typeof value === "boolean" || value === "notify") return value
}
export function action(current: string, latest: string, policy: Policy): Action {
if (policy === false) return "none"
if (!semver.valid(current) || !semver.valid(latest) || semver.eq(latest, current)) return "none"
// Major upgrades are never installed automatically.
if (semver.major(latest) !== semver.major(current)) return "none"
return "upgrade"
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const channel = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")
const readPolicy = Effect.fnUntraced(function* () {
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
fs
.readFileString(path.join(global.config, name))
.pipe(Effect.map(decodePolicy), Effect.catch(() => Effect.succeed(undefined))),
)
return values.findLast((value) => value !== undefined) ?? true
})
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
return yield* appProcess
.run(ChildProcess.make(command[0], command.slice(1)), {
timeout,
maxOutputBytes: 100_000,
maxErrorBytes: 100_000,
})
.pipe(
Effect.map((result) => ({
code: result.exitCode,
stdout: result.stdout.toString("utf8"),
stderr: result.stderr.toString("utf8"),
})),
Effect.catch(() => Effect.succeed({ code: 1, stdout: "", stderr: "" })),
)
})
const method = Effect.fnUntraced(function* () {
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", packageName] },
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", packageName] },
{ method: "bun", command: ["bun", "pm", "ls", "-g"] },
{ method: "yarn", command: ["yarn", "global", "list"] },
]
const results = yield* Effect.forEach(
checks,
(check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))),
{ concurrency: "unbounded" },
)
return results.find((result) => result.result.stdout.includes(packageName))?.check.method
})
const latest = Effect.fnUntraced(function* () {
const response = yield* Effect.tryPromise({
try: () =>
fetch(
`https://registry.npmjs.org/${encodeURIComponent(packageName)}/${encodeURIComponent(InstallationChannel)}`,
{ headers: { "User-Agent": `opencode/${InstallationVersion}` }, signal: AbortSignal.timeout(10_000) },
),
catch: (cause) => new Error("Failed to check for updates", { cause }),
})
if (!response.ok) return yield* Effect.fail(new Error(`Update check failed with status ${response.status}`))
const data = yield* Effect.tryPromise({
try: () => response.json(),
catch: (cause) => new Error("Failed to read update information", { cause }),
})
if (typeof data !== "object" || data === null || !("version" in data) || typeof data.version !== "string") {
return yield* Effect.fail(new Error("Update information did not include a version"))
}
return data.version
})
const upgrade = Effect.fnUntraced(function* (method: Method, version: string) {
const target = `${packageName}@${version}`
const commands: Record<Method, string[]> = {
npm: ["npm", "install", "--global", target],
pnpm: ["pnpm", "install", "--global", target],
bun: ["bun", "install", "--global", target],
yarn: ["yarn", "global", "add", target],
}
const result = yield* run(commands[method], "5 minutes")
if (result.code === 0) return
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
})
const check = Effect.fn("cli.updater.check")(function* () {
if (InstallationLocal || Flag.OPENCODE_DISABLE_AUTOUPDATE)
return yield* Effect.logInfo("update check skipped", {
reason: InstallationLocal ? "local-install" : "disabled",
version: InstallationVersion,
channel: InstallationChannel,
})
const policy = yield* readPolicy()
if (policy === false) return yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
return yield* Effect.gen(function* () {
const version = yield* latest()
yield* Effect.logInfo("update check", {
current: InstallationVersion,
latest: version,
})
const next = action(InstallationVersion, version, policy)
if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
const detected = yield* method()
if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
yield* upgrade(detected, version)
yield* Effect.logInfo("updated OpenCode", { from: InstallationVersion, to: version, method: detected })
})
}, Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })))
return Service.of({ check })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppProcess.defaultLayer), Layer.provide(Global.defaultLayer))
export * as Updater from "./updater"
+28 -38
View File
@@ -2,45 +2,35 @@ import { run } from "@opencode-ai/tui"
import { TuiConfig } from "@opencode-ai/tui/config"
import { Effect } from "effect"
import { Global } from "@opencode-ai/core/global"
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
type Transport = { url: string; headers: RequestInit["headers"] }
export function runTui(transport: Transport, reload?: () => Promise<Transport>) {
export function runTui(transport: { url: string; headers: RequestInit["headers"] }) {
const config = TuiConfig.resolve({}, { terminalSuspend: false })
let disposeSlots: (() => void) | undefined
return Effect.gen(function* () {
const options = { baseUrl: transport.url, headers: transport.headers }
const client = createOpencodeClient(options)
const directory = yield* Effect.tryPromise(() =>
client.v2.fs.list({ location: { directory: process.cwd() } }, { throwOnError: true }),
).pipe(
Effect.map((response) => response.data.location.directory),
Effect.catch(() =>
Effect.tryPromise(() => client.v2.location.get(undefined, { throwOnError: true })).pipe(
Effect.map((response) => response.data.directory),
),
),
)
return yield* run({
client: createOpencodeClient({ ...options, directory }),
reload: reload
? async () => {
const next = await reload()
return createOpencodeClient({ baseUrl: next.url, headers: next.headers, directory })
}
: undefined,
args: {},
config,
pluginHost: {
async start(input) {
disposeSlots = await loadBuiltinPlugins(input.api, input.runtime)
},
async dispose() {
disposeSlots?.()
},
},
})
return run({
...transport,
args: {},
config,
fetch: gracefulFetch,
pluginHost: {
async start() {},
async dispose() {},
},
}).pipe(Effect.provide(Global.defaultLayer))
}
const legacyDefaults: Record<string, unknown> = {
"/config/providers": { providers: [], default: {} },
"/provider": { all: [], default: {}, connected: [] },
"/agent": [],
"/config": {},
}
const gracefulFetch = Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await fetch(input, init)
if (response.status !== 404) return response
const fallback = legacyDefaults[new URL(input instanceof Request ? input.url : input).pathname]
if (fallback === undefined) return response
return Response.json(fallback)
},
{ preconnect: fetch.preconnect },
)
@@ -1,15 +0,0 @@
import { Effect } from "effect"
import path from "node:path"
import { Standalone } from "../../src/services/standalone"
process.argv[1] = path.join(import.meta.dir, "../../src/index.ts")
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const transport = yield* Standalone.transport()
console.log(`${transport.pid} ${transport.url}`)
return yield* Effect.never
}),
),
)
-65
View File
@@ -1,65 +0,0 @@
import { expect, test } from "bun:test"
import path from "node:path"
test("standalone server exits when its owner is killed", async () => {
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "fixture/standalone-owner.ts")], {
cwd: path.join(import.meta.dir, ".."),
env: process.env,
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
})
const line = await Promise.race([readLine(owner.stdout), Bun.sleep(10_000).then(() => undefined)])
const [rawPID, url] = line?.split(" ") ?? []
const pid = Number(rawPID)
try {
expect(pid).toBeGreaterThan(0)
expect(url).toStartWith("http://127.0.0.1:")
expect(running(pid)).toBe(true)
owner.kill("SIGKILL")
await owner.exited
expect(await waitForExit(pid)).toBe(true)
} finally {
owner.kill("SIGKILL")
if (running(pid)) process.kill(pid, "SIGKILL")
}
})
async function readLine(stream: ReadableStream<Uint8Array>) {
const reader = stream.getReader()
const decoder = new TextDecoder()
const chunks: string[] = []
while (true) {
const result = await reader.read()
if (result.done) break
chunks.push(decoder.decode(result.value, { stream: true }))
const output = chunks.join("")
const newline = output.indexOf("\n")
if (newline !== -1) {
reader.releaseLock()
return output.slice(0, newline)
}
}
reader.releaseLock()
return chunks.join("") + decoder.decode()
}
async function waitForExit(pid: number, attempts = 100): Promise<boolean> {
if (!running(pid)) return true
if (attempts === 0) return false
await Bun.sleep(50)
return waitForExit(pid, attempts - 1)
}
function running(pid: number) {
if (!Number.isSafeInteger(pid) || pid <= 0) return false
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
+56 -67
View File
@@ -106,25 +106,15 @@ const Endpoint3_5 = (raw: RawClient["server.session"]) => (input: Endpoint3_5Inp
Effect.mapError(mapClientError),
)
type Endpoint3_6Request = Parameters<RawClient["server.session"]["session.rename"]>[0]
type Endpoint3_6Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
type Endpoint3_6Input = {
readonly sessionID: Endpoint3_6Request["params"]["sessionID"]
readonly title: Endpoint3_6Request["payload"]["title"]
readonly id?: Endpoint3_6Request["payload"]["id"]
readonly prompt: Endpoint3_6Request["payload"]["prompt"]
readonly delivery?: Endpoint3_6Request["payload"]["delivery"]
readonly resume?: Endpoint3_6Request["payload"]["resume"]
}
const Endpoint3_6 = (raw: RawClient["server.session"]) => (input: Endpoint3_6Input) =>
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint3_7Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
type Endpoint3_7Input = {
readonly sessionID: Endpoint3_7Request["params"]["sessionID"]
readonly id?: Endpoint3_7Request["payload"]["id"]
readonly prompt: Endpoint3_7Request["payload"]["prompt"]
readonly delivery?: Endpoint3_7Request["payload"]["delivery"]
readonly resume?: Endpoint3_7Request["payload"]["resume"]
}
const Endpoint3_7 = (raw: RawClient["server.session"]) => (input: Endpoint3_7Input) =>
raw["session.prompt"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] },
@@ -133,23 +123,23 @@ const Endpoint3_7 = (raw: RawClient["server.session"]) => (input: Endpoint3_7Inp
Effect.map((value) => value.data),
)
type Endpoint3_8Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint3_8Input = { readonly sessionID: Endpoint3_8Request["params"]["sessionID"] }
const Endpoint3_8 = (raw: RawClient["server.session"]) => (input: Endpoint3_8Input) =>
type Endpoint3_7Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint3_7Input = { readonly sessionID: Endpoint3_7Request["params"]["sessionID"] }
const Endpoint3_7 = (raw: RawClient["server.session"]) => (input: Endpoint3_7Input) =>
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint3_9Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint3_9Input = { readonly sessionID: Endpoint3_9Request["params"]["sessionID"] }
const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Input) =>
type Endpoint3_8Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint3_8Input = { readonly sessionID: Endpoint3_8Request["params"]["sessionID"] }
const Endpoint3_8 = (raw: RawClient["server.session"]) => (input: Endpoint3_8Input) =>
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint3_10Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
type Endpoint3_10Input = {
readonly sessionID: Endpoint3_10Request["params"]["sessionID"]
readonly messageID: Endpoint3_10Request["payload"]["messageID"]
readonly files?: Endpoint3_10Request["payload"]["files"]
type Endpoint3_9Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
type Endpoint3_9Input = {
readonly sessionID: Endpoint3_9Request["params"]["sessionID"]
readonly messageID: Endpoint3_9Request["payload"]["messageID"]
readonly files?: Endpoint3_9Request["payload"]["files"]
}
const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) =>
const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Input) =>
raw["session.revert.stage"]({
params: { sessionID: input["sessionID"] },
payload: { messageID: input["messageID"], files: input["files"] },
@@ -158,42 +148,42 @@ const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10I
Effect.map((value) => value.data),
)
type Endpoint3_11Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
type Endpoint3_11Input = { readonly sessionID: Endpoint3_11Request["params"]["sessionID"] }
const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11Input) =>
type Endpoint3_10Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
type Endpoint3_10Input = { readonly sessionID: Endpoint3_10Request["params"]["sessionID"] }
const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) =>
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint3_12Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint3_12Input = { readonly sessionID: Endpoint3_12Request["params"]["sessionID"] }
const Endpoint3_12 = (raw: RawClient["server.session"]) => (input: Endpoint3_12Input) =>
type Endpoint3_11Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint3_11Input = { readonly sessionID: Endpoint3_11Request["params"]["sessionID"] }
const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11Input) =>
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint3_13Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint3_13Input = { readonly sessionID: Endpoint3_13Request["params"]["sessionID"] }
const Endpoint3_13 = (raw: RawClient["server.session"]) => (input: Endpoint3_13Input) =>
type Endpoint3_12Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint3_12Input = { readonly sessionID: Endpoint3_12Request["params"]["sessionID"] }
const Endpoint3_12 = (raw: RawClient["server.session"]) => (input: Endpoint3_12Input) =>
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint3_14Request = Parameters<RawClient["server.session"]["session.history"]>[0]
type Endpoint3_14Input = {
readonly sessionID: Endpoint3_14Request["params"]["sessionID"]
readonly limit?: Endpoint3_14Request["query"]["limit"]
readonly after?: Endpoint3_14Request["query"]["after"]
type Endpoint3_13Request = Parameters<RawClient["server.session"]["session.history"]>[0]
type Endpoint3_13Input = {
readonly sessionID: Endpoint3_13Request["params"]["sessionID"]
readonly limit?: Endpoint3_13Request["query"]["limit"]
readonly after?: Endpoint3_13Request["query"]["after"]
}
const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) =>
const Endpoint3_13 = (raw: RawClient["server.session"]) => (input: Endpoint3_13Input) =>
raw["session.history"]({
params: { sessionID: input["sessionID"] },
query: { limit: input["limit"], after: input["after"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint3_15Request = Parameters<RawClient["server.session"]["session.events"]>[0]
type Endpoint3_15Input = {
readonly sessionID: Endpoint3_15Request["params"]["sessionID"]
readonly after?: Endpoint3_15Request["query"]["after"]
type Endpoint3_14Request = Parameters<RawClient["server.session"]["session.events"]>[0]
type Endpoint3_14Input = {
readonly sessionID: Endpoint3_14Request["params"]["sessionID"]
readonly after?: Endpoint3_14Request["query"]["after"]
}
const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) =>
const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) =>
Stream.unwrap(
raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe(
Effect.mapError(mapClientError),
@@ -201,17 +191,17 @@ const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15I
),
)
type Endpoint3_16Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint3_16Input = { readonly sessionID: Endpoint3_16Request["params"]["sessionID"] }
const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16Input) =>
type Endpoint3_15Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint3_15Input = { readonly sessionID: Endpoint3_15Request["params"]["sessionID"] }
const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) =>
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint3_17Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint3_17Input = {
readonly sessionID: Endpoint3_17Request["params"]["sessionID"]
readonly messageID: Endpoint3_17Request["params"]["messageID"]
type Endpoint3_16Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint3_16Input = {
readonly sessionID: Endpoint3_16Request["params"]["sessionID"]
readonly messageID: Endpoint3_16Request["params"]["messageID"]
}
const Endpoint3_17 = (raw: RawClient["server.session"]) => (input: Endpoint3_17Input) =>
const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16Input) =>
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@@ -224,18 +214,17 @@ const adaptGroup3 = (raw: RawClient["server.session"]) => ({
get: Endpoint3_3(raw),
switchAgent: Endpoint3_4(raw),
switchModel: Endpoint3_5(raw),
rename: Endpoint3_6(raw),
prompt: Endpoint3_7(raw),
compact: Endpoint3_8(raw),
wait: Endpoint3_9(raw),
stage: Endpoint3_10(raw),
clear: Endpoint3_11(raw),
commit: Endpoint3_12(raw),
context: Endpoint3_13(raw),
history: Endpoint3_14(raw),
events: Endpoint3_15(raw),
interrupt: Endpoint3_16(raw),
message: Endpoint3_17(raw),
prompt: Endpoint3_6(raw),
compact: Endpoint3_7(raw),
wait: Endpoint3_8(raw),
stage: Endpoint3_9(raw),
clear: Endpoint3_10(raw),
commit: Endpoint3_11(raw),
context: Endpoint3_12(raw),
history: Endpoint3_13(raw),
events: Endpoint3_14(raw),
interrupt: Endpoint3_15(raw),
message: Endpoint3_16(raw),
})
type Endpoint4_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
+3 -17
View File
@@ -15,8 +15,6 @@ import type {
SessionsSwitchAgentOutput,
SessionsSwitchModelInput,
SessionsSwitchModelOutput,
SessionsRenameInput,
SessionsRenameOutput,
SessionsPromptInput,
SessionsPromptOutput,
SessionsCompactInput,
@@ -369,18 +367,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
rename: (input: SessionsRenameInput, requestOptions?: RequestOptions) =>
request<SessionsRenameOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/rename`,
body: { title: input["title"] },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
prompt: (input: SessionsPromptInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionsPromptOutput }>(
{
@@ -422,7 +408,7 @@ export function make(options: ClientOptions) {
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`,
body: { messageID: input["messageID"], files: input["files"] },
successStatus: 200,
declaredStatuses: [404, 409, 500, 400, 401],
declaredStatuses: [404, 500, 400, 401],
empty: false,
},
requestOptions,
@@ -433,7 +419,7 @@ export function make(options: ClientOptions) {
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`,
successStatus: 204,
declaredStatuses: [404, 409, 500, 400, 401],
declaredStatuses: [404, 500, 400, 401],
empty: true,
},
requestOptions,
@@ -444,7 +430,7 @@ export function make(options: ClientOptions) {
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`,
successStatus: 204,
declaredStatuses: [404, 409, 400, 401],
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
-43
View File
@@ -58,14 +58,6 @@ export type MessageNotFoundError = {
export const isMessageNotFoundError = (value: unknown): value is MessageNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "MessageNotFoundError"
export type SessionBusyError = {
readonly _tag: "SessionBusyError"
readonly sessionID: string
readonly message: string
}
export const isSessionBusyError = (value: unknown): value is SessionBusyError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SessionBusyError"
export type UnknownError = {
readonly _tag: "UnknownError"
readonly message: string
@@ -389,13 +381,6 @@ export type SessionsSwitchModelInput = {
export type SessionsSwitchModelOutput = void
export type SessionsRenameInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly title: { readonly title: string }["title"]
}
export type SessionsRenameOutput = void
export type SessionsPromptInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly id?: {
@@ -412,7 +397,6 @@ export type SessionsPromptInput = {
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly system?: string
}
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
@@ -431,7 +415,6 @@ export type SessionsPromptInput = {
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly system?: string
}
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
@@ -450,7 +433,6 @@ export type SessionsPromptInput = {
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly system?: string
}
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
@@ -469,7 +451,6 @@ export type SessionsPromptInput = {
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly system?: string
}
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
@@ -494,7 +475,6 @@ export type SessionsPromptOutput = {
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly system?: string
}
readonly delivery: "steer" | "queue"
readonly timeCreated: number
@@ -574,7 +554,6 @@ export type SessionsContextOutput = {
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly system?: string
readonly type: "user"
}
| {
@@ -744,14 +723,6 @@ export type SessionsHistoryOutput = {
readonly subdirectory?: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly type: "session.next.renamed"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly timestamp: number; readonly sessionID: string; readonly title: string }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
@@ -775,7 +746,6 @@ export type SessionsHistoryOutput = {
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly system?: string
}
readonly delivery: "steer" | "queue"
}
@@ -803,7 +773,6 @@ export type SessionsHistoryOutput = {
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly system?: string
}
readonly delivery: "steer" | "queue"
}
@@ -1212,14 +1181,6 @@ export type SessionsEventsOutput =
readonly subdirectory?: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.renamed"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly timestamp: number; readonly sessionID: string; readonly title: string }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
@@ -1243,7 +1204,6 @@ export type SessionsEventsOutput =
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly system?: string
}
readonly delivery: "steer" | "queue"
}
@@ -1271,7 +1231,6 @@ export type SessionsEventsOutput =
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly system?: string
}
readonly delivery: "steer" | "queue"
}
@@ -1673,7 +1632,6 @@ export type SessionsMessageOutput = {
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly system?: string
readonly type: "user"
}
| {
@@ -1846,7 +1804,6 @@ export type MessagesListOutput = {
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly system?: string
readonly type: "user"
}
| {
@@ -3,7 +3,7 @@ import { z } from "zod"
import { Resource } from "@opencode-ai/console-resource"
import { safeEqual } from "@opencode-ai/console-core/util/crypto.js"
const DISCORD_ALERT_ROLE_ID = "1511795723262365887"
const DISCORD_ALERT_ROLE_ID = "1520924666359713863"
const basePayload = z.object({
name: z.string().optional(),
@@ -201,7 +201,10 @@ export async function handler(
if (v === "$model") return headers.set(k, model)
if (v === "$request") return headers.set(k, requestId)
if (v === "$project") return headers.set(k, projectId)
if (v === "$workspace" && authInfo?.workspaceID) return headers.set(k, authInfo.workspaceID)
if (v === "$workspace") {
if (authInfo?.workspaceID) headers.set(k, authInfo.workspaceID)
return
}
headers.set(k, v)
})
headers.delete("host")
@@ -1,6 +1,7 @@
export * as MoveSession from "./move-session"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { makeGlobalNode } from "../effect/app-node"
import { EventV2 } from "../event"
import { Git } from "../git"
import { Location } from "../location"
@@ -146,3 +147,9 @@ export const defaultLayer = layer.pipe(
Layer.provide(ProjectV2.defaultLayer),
Layer.provide(SessionStore.defaultLayer),
)
export const node = makeGlobalNode({
service: Service,
layer,
deps: [Git.node, EventV2.node, ProjectV2.node, SessionStore.node],
})
+11 -12
View File
@@ -1,24 +1,23 @@
import { Layer } from "effect"
import { buildLocationServiceMap } from "../location-services"
import { LocationServiceMap } from "../location-service-map"
import { LayerNode } from "./layer-node"
import { makeGlobalNode } from "./app-node"
export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements?: readonly LayerNode.Replacement[]) {
const replacementMap = new Map(replacements?.map((item) => [item.source, item.replacement]))
export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements: LayerNode.Replacements = []) {
let allReplacements = replacements
if (!LayerNode.hasUnbound(root, LocationServiceMap.node)) {
// If the location service map is not needed, we shouldn't pull it
// in. Compile the graph normally
return LayerNode.compile(root, replacementMap)
// Only build the location service map if it's actually needed
if (LayerNode.hasUnbound(root, LocationServiceMap.node) && !hasReplacement(replacements, LocationServiceMap.node)) {
const locationMap = buildLocationServiceMap(replacements)
const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
allReplacements = replacements.concat([[LocationServiceMap.node, locationMapNode]])
}
const locationMap = buildLocationServiceMap(replacementMap)
const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
return LayerNode.compile(root, allReplacements)
}
const app = LayerNode.bind(root, LocationServiceMap.node, locationMapNode)
return LayerNode.compile(app, replacementMap)
function hasReplacement(replacements: LayerNode.Replacements, node: LayerNode.Node<unknown, unknown, any>) {
return replacements.some(([source]) => source.name === node.name)
}
export * as AppNodeBuilder from "./app-node-builder"
+1
View File
@@ -0,0 +1 @@
File to save in: ~/.local/share/opencode/worktree/012780/location-layer-tiers/packages/core/src/effect/
+73 -56
View File
@@ -111,20 +111,52 @@ export function group<const Items extends readonly AnyNode[]>(
return { kind: "group", name: "group", dependencies }
}
export type Replacement = {
readonly source: Layer.Any
readonly replacement: Layer.Any
}
export type Replacement = readonly [source: AnyNode, replacement: AnyNode | Layer.Any]
export type Replacements = readonly Replacement[]
type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<ReplacementError, SourceError>] extends [never]
? unknown
: { readonly "New replacement errors": Exclude<ReplacementError, SourceError> }
export function replace<A, E, R, E2>(
source: Layer.Layer<A, E, R>,
replacement: Layer.Layer<NoInfer<A>, E2, never> & CheckReplacementErrors<E, NoInfer<E2>>,
): Replacement {
return { source, replacement }
type CheckReplacement<Item> = Item extends readonly [Node<infer A, infer E, infer T>, infer Replacement]
? Replacement extends Node<NoInfer<A>, infer E2, T>
? CheckReplacementErrors<E, NoInfer<E2>>
: Replacement extends Layer.Layer<NoInfer<A>, infer E2, never>
? CheckReplacementErrors<E, NoInfer<E2>>
: { readonly "Invalid replacement": Replacement }
: { readonly "Invalid replacement": Item }
type CheckReplacements<Items extends Replacements> = {
readonly [K in keyof Items]: CheckReplacement<Items[K]>
}
type ValidReplacements<Items extends Replacements> = Items & CheckReplacements<Items>
function replacementNode(source: AnyNode, replacement: AnyNode | Layer.Any) {
const replacementNode = isNode(replacement)
? replacement
: make({
...nodeMakeIdentity(source),
layer: replacement as Layer.Layer<unknown, unknown>,
deps: [],
tag: source.tag,
})
if (source.name !== replacementNode.name) {
throw new Error(`Cannot replace ${source.name} with ${replacementNode.name}`)
}
if (source.tag !== replacementNode.tag) {
throw new Error(`Cannot replace ${source.name} across tags`)
}
return replacementNode
}
function nodeMakeIdentity(node: AnyNode): NodeIdentity {
if (node.service !== undefined) return { service: node.service }
return { name: node.name }
}
function isNode(input: Layer.Any | AnyNode): input is AnyNode {
return "kind" in input && "dependencies" in input
}
// Tree -----------------------------------------------------------------------
@@ -176,32 +208,38 @@ function walk<Result>(
return recur(root)
}
export function hoist<A, E, T extends Tag>(
export function hoist<A, E, T extends Tag, const Items extends Replacements = readonly []>(
root: Node<A, E, any>,
tag: T,
replacements?: ValidReplacements<Items>,
): {
readonly node: Node<A, E>
readonly hoisted: Node<unknown, E>
} {
const hoisted = new Map<string, AnyNode>()
const replacementMap = replacementMapFrom(replacements)
const node = walk<AnyNode>(root, (node, context) => {
if (node.kind === "group") {
return { ...node, dependencies: node.dependencies.map(context.visit) }
}
if (node.tag === tag) {
const existing = hoisted.get(node.name)
if (existing && existing !== node) {
throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`)
const node = walk<AnyNode>(
root,
(node, context) => {
if (node.kind === "group") {
return { ...node, dependencies: node.dependencies.map(context.visit) }
}
hoisted.set(node.name, node)
return group([])
}
if (node.kind === "unbound") {
return node
}
return { ...node, dependencies: node.dependencies.map(context.visit) }
})
if (node.tag === tag) {
const existing = hoisted.get(node.name)
if (existing && existing !== node) {
throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`)
}
hoisted.set(node.name, node)
return group([])
}
if (node.kind === "unbound") {
return node
}
return { ...node, dependencies: node.dependencies.map(context.visit) }
},
{ resolve: (node) => replacementMap.get(node.name) ?? node },
)
return {
node: node as Node<A, E>,
@@ -209,10 +247,11 @@ export function hoist<A, E, T extends Tag>(
}
}
export function compile<A, E>(
export function compile<A, E, const Items extends Replacements = readonly []>(
root: Node<A, E, any>,
replacements?: ReadonlyMap<Layer.Any, Layer.Any>,
replacements?: ValidReplacements<Items>,
): Layer.Layer<A, E> {
const replacementMap = replacementMapFrom(replacements)
const cache = new Map<AnyNode, RuntimeLayer>()
const compileNode = (node: AnyNode) =>
walk<RuntimeLayer>(
@@ -220,18 +259,22 @@ export function compile<A, E>(
(node, context) => {
if (node.kind === "unbound") throw new Error(`Unbound layer node: ${node.name}`)
const dependencies = node.dependencies.flatMap(flatten).map(context.visit)
const implementation = (replacements?.get(node.implementation!) ?? node.implementation!) as RuntimeLayer
const implementation = node.implementation! as RuntimeLayer
return dependencies.length === 0
? implementation
: implementation.pipe(Layer.provide(dependencies as [RuntimeLayer, ...RuntimeLayer[]]))
},
{ cache },
{ cache, resolve: (node) => replacementMap.get(node.name) ?? node },
)
const layers = flatten(root).map((node) => compileNode(node))
const layer = layers.reduce<RuntimeLayer>((result, layer) => layer.pipe(Layer.provideMerge(result)), Layer.empty)
return layer as Layer.Layer<A, E>
}
function replacementMapFrom(replacements?: Replacements) {
return new Map(replacements?.map(([source, replacement]) => [source.name, replacementNode(source, replacement)]))
}
export function hasUnbound(root: Node<unknown, unknown, any>, source: AnyNode): boolean {
if (source.kind !== "unbound") throw new Error(`Cannot check non-unbound layer node: ${source.name}`)
return walk<boolean>(root, (node, context) => {
@@ -240,32 +283,6 @@ export function hasUnbound(root: Node<unknown, unknown, any>, source: AnyNode):
})
}
export function bind<A, E, T extends Tag | undefined>(
root: Node<A, E, T>,
source: AnyNode,
replacement: AnyNode,
): Node<A, E, T> {
if (source.kind !== "unbound") throw new Error(`Cannot bind non-unbound layer node: ${source.name}`)
if (source.name !== replacement.name) {
throw new Error(`Cannot bind ${source.name} to ${replacement.name}`)
}
if (source.tag !== replacement.tag) {
throw new Error(`Cannot bind ${source.name} across tags`)
}
return walk<AnyNode>(
root,
(target, context) => {
if (target.kind === "unbound") return target
const dependencies: AnyNode[] = []
const clone = { ...target, dependencies }
context.cache.set(target, clone)
dependencies.push(...target.dependencies.map(context.visit))
return clone
},
{ detectCycles: false, resolve: (node) => (node === source ? replacement : node) },
) as Node<A, E, T>
}
function flatten(node: AnyNode): readonly AnyNode[] {
return node.kind === "group" ? node.dependencies.flatMap(flatten) : [node]
}
+6 -9
View File
@@ -26,7 +26,6 @@ import { Reference } from "./reference"
import { ReferenceGuidance } from "./reference/guidance"
import * as SessionRunnerLLM from "./session/runner/llm"
import { SessionRunnerModel } from "./session/runner/model"
import { SessionRuntime } from "./session/runtime"
import { SessionTodo } from "./session/todo"
import { SkillV2 } from "./skill"
import { SkillGuidance } from "./skill/guidance"
@@ -76,7 +75,6 @@ export const locationServices = LayerNode.group([
BuiltInTools.node,
SessionRunnerModel.node,
Snapshot.node,
SessionRuntime.node,
SessionRunnerLLM.node,
])
@@ -84,17 +82,16 @@ export type LocationServices = LayerNode.Output<typeof locationServices>
export type LocationError = LayerNode.Error<typeof locationServices>
export function buildLocationServiceMap(
replacements?: ReadonlyMap<Layer.Any, Layer.Any>,
replacements: LayerNode.Replacements = [],
): Layer.Layer<LocationServiceMap.Service> {
return Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
(ref: Location.Ref) => {
const location = LayerNode.hoist(
LayerNode.bind(locationServices, Location.node, Location.boundNode(ref)),
Node.tags.values.global,
)
return LayerNode.compile(location.node, replacements).pipe(
const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
const location = LayerNode.hoist(locationServices, Node.tags.values.global, allReplacements)
return LayerNode.compile(location.node).pipe(
Layer.fresh,
Layer.tap(() =>
Effect.logInfo("booting location services", {
@@ -102,7 +99,7 @@ export function buildLocationServiceMap(
workspaceID: ref.workspaceID,
}),
),
Layer.provide(LayerNode.compile(location.hoisted, replacements)),
Layer.provide(LayerNode.compile(location.hoisted)),
)
},
{ idleTimeToLive: "60 minutes" },
-8
View File
@@ -13,10 +13,7 @@ import { Integration } from "./integration"
import { KeyedMutex } from "./effect/keyed-mutex"
import { PluginHost } from "./plugin/host"
import { Reference } from "./reference"
import { SessionV2 } from "./session"
import { SessionRuntime } from "./session/runtime"
import { SkillV2 } from "./skill"
import { Location } from "./location"
import { State } from "./state"
export const ID = Plugin.ID
@@ -152,8 +149,6 @@ export const locationLayer = layer.pipe(
Layer.provideMerge(CommandV2.locationLayer),
Layer.provideMerge(Integration.locationLayer),
Layer.provideMerge(Reference.locationLayer),
Layer.provideMerge(SessionV2.defaultLayer),
Layer.provideMerge(SessionRuntime.layer),
Layer.provideMerge(SkillV2.locationLayer),
)
@@ -168,9 +163,6 @@ export const node = makeLocationNode({
CommandV2.node,
Integration.node,
Reference.node,
SessionV2.node,
SessionRuntime.node,
SkillV2.node,
Location.node,
],
})
-61
View File
@@ -13,11 +13,7 @@ import { PluginV2 } from "../plugin"
import { ProviderV2 } from "../provider"
import { Reference } from "../reference"
import type { DeepMutable } from "../schema"
import { SessionV2 } from "../session"
import { SessionMessage } from "../session/message"
import { SessionRuntime } from "../session/runtime"
import { SkillV2 } from "../skill"
import { Location } from "../location"
const mutable = <T>(value: T) => value as DeepMutable<T>
@@ -28,10 +24,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
const commands = yield* CommandV2.Service
const integration = yield* Integration.Service
const reference = yield* Reference.Service
const session = yield* SessionV2.Service
const sessionRuntime = yield* SessionRuntime.Service
const skill = yield* SkillV2.Service
const location = yield* Location.Service
return {
options: {},
@@ -212,60 +205,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
}),
),
},
session: {
create: (input) =>
session.create(
input.parentID
? {
id: input.id ? SessionV2.ID.make(input.id) : undefined,
parentID: SessionV2.ID.make(input.parentID),
title: input.title,
agent: input.agent ? AgentV2.ID.make(input.agent) : undefined,
model: input.model
? {
id: ModelV2.ID.make(input.model.id),
providerID: ProviderV2.ID.make(input.model.providerID),
variant: input.model.variant ? ModelV2.VariantID.make(input.model.variant) : undefined,
}
: undefined,
}
: {
id: input.id ? SessionV2.ID.make(input.id) : undefined,
location: { directory: location.directory, workspaceID: location.workspaceID },
title: input.title,
agent: input.agent ? AgentV2.ID.make(input.agent) : undefined,
model: input.model
? {
id: ModelV2.ID.make(input.model.id),
providerID: ProviderV2.ID.make(input.model.providerID),
variant: input.model.variant ? ModelV2.VariantID.make(input.model.variant) : undefined,
}
: undefined,
},
),
get: (sessionID) => session.get(SessionV2.ID.make(sessionID)),
messages: (input) =>
session.messages({
sessionID: SessionV2.ID.make(input.sessionID),
limit: input.limit,
order: input.order,
cursor: input.cursor
? { id: SessionMessage.ID.make(input.cursor.id), direction: input.cursor.direction }
: undefined,
}),
context: (sessionID) => session.context(SessionV2.ID.make(sessionID)),
prompt: (input) =>
sessionRuntime.prompt({
id: input.id ? SessionMessage.ID.make(input.id) : undefined,
sessionID: SessionV2.ID.make(input.sessionID),
prompt: input.prompt,
delivery: input.delivery,
resume: input.resume,
}),
resume: (sessionID) => sessionRuntime.resume(SessionV2.ID.make(sessionID)),
wait: (sessionID) => sessionRuntime.wait(SessionV2.ID.make(sessionID)),
interrupt: (sessionID) => sessionRuntime.interrupt(SessionV2.ID.make(sessionID)),
},
skill: {
reload: skill.reload,
transform: (callback) =>
-10
View File
@@ -81,16 +81,6 @@ export function fromPromise(plugin: Plugin) {
transform: transform(host.reference),
reload: () => run(host.reference.reload()),
},
session: {
create: (input) => run(host.session.create(input)),
get: (sessionID) => run(host.session.get(sessionID)),
messages: (input) => run(host.session.messages(input)),
context: (sessionID) => run(host.session.context(sessionID)),
prompt: (input) => run(host.session.prompt(input)),
resume: (sessionID) => run(host.session.resume(sessionID)),
wait: (sessionID) => run(host.session.wait(sessionID)),
interrupt: (sessionID) => run(host.session.interrupt(sessionID)),
},
skill: {
transform: transform(host.skill),
reload: () => run(host.skill.reload()),
@@ -0,0 +1,7 @@
export * as PublicEventManifest from "./public-event-manifest"
import { Event } from "@opencode-ai/schema/event"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
export const Definitions = EventManifest.ServerDefinitions
export const Latest = Event.latest(Definitions)
+56 -65
View File
@@ -25,9 +25,10 @@ import { ProjectTable } from "./project/sql"
import path from "path"
import { fromRow } from "./session/info"
import { SessionRunner } from "./session/runner/index"
import { SessionRuntimeCoordinator } from "./session/runtime-coordinator"
import { SessionStore } from "./session/store"
import { SessionExecution } from "./session/execution"
import { makeGlobalNode } from "./effect/app-node"
import { LocationServiceMap } from "./location-service-map"
import { MessageDecodeError } from "./session/error"
import { SessionEvent } from "./session/event"
import { SessionInput } from "./session/input"
@@ -75,14 +76,12 @@ const ListAllInput = Schema.Struct(ListInputBase)
export const ListInput = Schema.Union([ListDirectoryInput, ListProjectInput, ListAllInput])
export type ListInput = typeof ListInput.Type
type CreateBaseInput = {
type CreateInput = {
id?: SessionSchema.ID
title?: string
agent?: AgentV2.ID
model?: ModelV2.Ref
location: Location.Ref
}
type CreateInput = CreateBaseInput &
({ location: Location.Ref; parentID?: never } | { parentID: SessionSchema.ID; location?: never })
type CompactInput = {
sessionID: SessionSchema.ID
@@ -96,7 +95,7 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Ses
export class OperationUnavailableError extends Schema.TaggedErrorClass<OperationUnavailableError>()(
"Session.OperationUnavailableError",
{
operation: Schema.Literals(["move", "shell", "skill", "switchAgent", "compact"]),
operation: Schema.Literals(["move", "shell", "skill", "switchAgent", "compact", "wait"]),
},
) {}
@@ -106,9 +105,6 @@ export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictE
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
}) {}
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
sessionID: SessionSchema.ID,
}) {}
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
@@ -116,7 +112,7 @@ export type Error = NotFoundError | MessageDecodeError | OperationUnavailableErr
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<SessionSchema.Info[]>
readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info, NotFoundError>
readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info>
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
readonly messages: (input: {
sessionID: SessionSchema.ID
@@ -148,7 +144,6 @@ export interface Interface {
sessionID: SessionSchema.ID
model: ModelV2.Ref
}) => Effect.Effect<void, NotFoundError>
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
readonly prompt: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
@@ -169,7 +164,7 @@ export interface Interface {
resume?: boolean
}) => Effect.Effect<void, OperationUnavailableError>
readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
@@ -178,9 +173,9 @@ export interface Interface {
sessionID: SessionSchema.ID
messageID: SessionMessage.ID
files?: boolean
}) => Effect.Effect<Revert.State, NotFoundError | MessageNotFoundError | BusyError | Snapshot.Error>
readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError | Snapshot.Error>
readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError>
}) => Effect.Effect<Revert.State, NotFoundError | MessageNotFoundError | Snapshot.Error>
readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | Snapshot.Error>
readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
}
}
@@ -193,8 +188,9 @@ export const layer = Layer.effect(
const db = database.db
const events = yield* EventV2.Service
const projects = yield* ProjectV2.Service
const runtimeCoordinator = yield* SessionRuntimeCoordinator.Service
const execution = yield* SessionExecution.Service
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap.Service
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
@@ -213,12 +209,7 @@ export const layer = Layer.effect(
const sessionID = input.id ?? SessionSchema.ID.create()
const recorded = yield* store.get(sessionID)
if (recorded) return recorded
const parent = input.parentID ? yield* store.get(input.parentID) : undefined
if (input.parentID && parent === undefined) return yield* new NotFoundError({ sessionID: input.parentID })
const location = parent?.location ?? input.location
if (location === undefined)
return yield* Effect.die(new Error("V2Session.create requires either location or an existing parentID"))
const project = yield* projects.resolve(location.directory)
const project = yield* projects.resolve(input.location.directory)
yield* db
.insert(ProjectTable)
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
@@ -231,11 +222,10 @@ export const layer = Layer.effect(
slug: Slug.create(),
version: InstallationVersion,
projectID: project.id,
parentID: input.parentID,
directory: location.directory,
path: path.relative(project.directory, location.directory).replaceAll("\\", "/"),
workspaceID: location.workspaceID ? WorkspaceV2.ID.make(location.workspaceID) : undefined,
title: input.title ?? `New session - ${new Date(now).toISOString()}`,
directory: input.location.directory,
path: path.relative(project.directory, input.location.directory).replaceAll("\\", "/"),
workspaceID: input.location.workspaceID ? WorkspaceV2.ID.make(input.location.workspaceID) : undefined,
title: `New session - ${new Date(now).toISOString()}`,
agent: input.agent,
model: input.model
? {
@@ -248,22 +238,24 @@ export const layer = Layer.effect(
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: now, updated: now },
})
const projected = yield* events.publish(SessionV1.Event.Created, { sessionID, info }, { location }).pipe(
Effect.as({ type: "created" } as const),
Effect.catchDefect((defect) => {
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
return Effect.die(defect)
}
// Concurrent creation lost the projection race. The existing Session identity wins.
return store
.get(sessionID)
.pipe(
Effect.flatMap((session) =>
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
),
)
}),
)
const projected = yield* events
.publish(SessionV1.Event.Created, { sessionID, info }, { location: input.location })
.pipe(
Effect.as({ type: "created" } as const),
Effect.catchDefect((defect) => {
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
return Effect.die(defect)
}
// Concurrent creation lost the projection race. The existing Session identity wins.
return store
.get(sessionID)
.pipe(
Effect.flatMap((session) =>
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
),
)
}),
)
if (projected.type === "existing") return projected.session
// TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
return yield* result.get(sessionID).pipe(Effect.orDie)
@@ -387,8 +379,7 @@ export const layer = Layer.effect(
)
if (!SessionInput.equivalent(admitted, expected))
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
if (input.resume !== false)
return yield* Effect.die("SessionV2.prompt with resume moved to SessionRuntime.Service")
if (input.resume !== false) yield* execution.wake(admitted.sessionID)
return admitted
}),
),
@@ -423,40 +414,41 @@ export const layer = Layer.effect(
model: input.model,
})
}),
rename: Effect.fn("V2Session.rename")(function* (input) {
yield* result.get(input.sessionID)
yield* events.publish(SessionEvent.Renamed, {
sessionID: input.sessionID,
timestamp: yield* DateTime.now,
title: input.title,
})
}),
compact: Effect.fn("V2Session.compact")(function* (input) {
yield* result.get(input.sessionID)
return yield* new OperationUnavailableError({ operation: "compact" })
}),
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
yield* result.get(sessionID)
return yield* Effect.die("SessionV2.wait moved to SessionRuntime.Service")
return yield* new OperationUnavailableError({ operation: "wait" })
}),
active: runtimeCoordinator.active,
active: execution.active,
resume: Effect.fn("V2Session.resume")(function* (sessionID) {
yield* result.get(sessionID)
return yield* Effect.die("SessionV2.resume moved to SessionRuntime.Service")
yield* execution.resume(sessionID)
}),
interrupt: Effect.fn("V2Session.interrupt")(() => Effect.die("SessionV2.interrupt moved to SessionRuntime.Service")),
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
Effect.uninterruptible(execution.interrupt(sessionID)),
),
revert: {
stage: Effect.fn("V2Session.revert.stage")(function* (input) {
yield* result.get(input.sessionID)
return yield* Effect.die("SessionV2.revert.stage moved to SessionRuntime.Service")
const session = yield* result.get(input.sessionID)
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
Effect.provideService(Database.Service, database),
Effect.provideService(EventV2.Service, events),
Effect.provide(locations.get(session.location)),
)
}),
clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) {
yield* result.get(sessionID)
return yield* Effect.die("SessionV2.revert.clear moved to SessionRuntime.Service")
const session = yield* result.get(sessionID)
yield* SessionRevert.clear(session).pipe(
Effect.provideService(EventV2.Service, events),
Effect.provide(locations.get(session.location)),
)
}),
commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) {
const session = yield* result.get(sessionID)
return yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
}),
},
})
@@ -471,7 +463,6 @@ export const defaultLayer = layer.pipe(
Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.provide(SessionRuntimeCoordinator.defaultLayer),
Layer.orDie,
)
@@ -479,7 +470,6 @@ const resolvePrompt = (input: PromptInput.Prompt) =>
Prompt.make({
text: input.text,
agents: input.agents,
system: input.system,
files: input.files?.map((file) => {
const dataMime = file.uri.match(/^data:([^;,]+)[;,]/i)?.[1]
const target = URL.canParse(file.uri) ? new URL(file.uri).pathname : (file.name ?? file.uri)
@@ -497,8 +487,9 @@ export const node = makeGlobalNode({
Database.node,
EventV2.node,
ProjectV2.node,
SessionRuntimeCoordinator.node,
SessionExecution.node,
SessionStore.node,
LocationServiceMap.node,
SessionProjector.node,
],
})
-3
View File
@@ -15,8 +15,6 @@ export interface Interface {
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
}
/** Routes execution from a Session ID to the runner owned by that Session's Location. */
@@ -32,6 +30,5 @@ export const noopLayer = Layer.succeed(
resume: () => Effect.void,
wake: () => Effect.void,
interrupt: () => Effect.void,
awaitIdle: () => Effect.void,
}),
)
@@ -33,7 +33,6 @@ export const layer = Layer.effect(
interrupt: coordinator.interrupt,
resume: coordinator.run,
wake: coordinator.wake,
awaitIdle: coordinator.awaitIdle,
})
}),
)
@@ -123,7 +123,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
)
},
"session.next.moved": () => Effect.void,
"session.next.renamed": () => Effect.void,
"session.next.prompted": (event) => {
return adapter.appendMessage(
SessionMessage.User.make({
@@ -133,7 +132,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
text: event.data.prompt.text,
files: event.data.prompt.files,
agents: event.data.prompt.agents,
system: event.data.prompt.system,
time: { created: event.data.timestamp },
}),
)
-8
View File
@@ -347,14 +347,6 @@ export const layer = Layer.effectDiscard(
yield* run(db, event)
}),
)
yield* events.project(SessionEvent.Renamed, (event) =>
db
.update(SessionTable)
.set({ title: event.data.title, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie),
)
yield* events.project(SessionEvent.Prompted, (event) =>
Effect.gen(function* () {
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
+1 -13
View File
@@ -12,8 +12,6 @@ export interface Coordinator<Key, E> {
readonly wake: (key: Key) => Effect.Effect<void>
/** Stops active execution and waits for its cleanup. */
readonly interrupt: (key: Key) => Effect.Effect<void>
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
readonly awaitIdle: (key: Key) => Effect.Effect<void>
}
type Entry<E> = {
@@ -102,15 +100,5 @@ export const make = <Key, E>(options: {
return Fiber.interrupt(entry.owner)
})
// Each successful drain reuses its entry.done across coalesced wakes, so one await
// already spans steered and queued continuation. Re-check after it settles to cover a
// fresh wake (or a failure/stopping successor) that installs a new entry.
const awaitIdle = (key: Key): Effect.Effect<void> =>
Effect.suspend(() => {
const entry = active.get(key)
if (entry === undefined) return Effect.void
return Deferred.await(entry.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key)))
})
return { active: Effect.sync(() => new Set(active.keys())), run, wake, interrupt, awaitIdle }
return { active: Effect.sync(() => new Set(active.keys())), run, wake, interrupt }
})
+8 -10
View File
@@ -194,16 +194,13 @@ export const layer = Layer.effect(
const model = yield* models.resolve(session)
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
const context = entries.map((entry) => entry.message)
// Mirror V1 (session/llm/request.ts): append the current turn's per-request system string after the
// agent prompt and durable baseline. The current turn's user prompt is the latest user message in context.
const turnSystem = context.findLast((message) => message.type === "user")?.system
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agent.info?.permissions)
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
const request = LLM.request({
model,
providerOptions: { openai: { promptCacheKey } },
system: [agent.info?.system, system.baseline, turnSystem]
system: [agent.info?.system, system.baseline]
.filter((part): part is string => part !== undefined && part.length > 0)
.map(SystemPart.make),
messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
@@ -292,18 +289,19 @@ export const layer = Layer.effect(
}
if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers)
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
const toolsInterrupted = settled._tag === "Failure" && Cause.hasInterrupts(settled.cause)
if (settled._tag === "Failure" && isQuestionRejected(settled.cause)) {
yield* FiberSet.clear(toolFibers)
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
return yield* Effect.interrupt
}
if (streamInterrupted || toolsInterrupted) {
if (
(stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) ||
(settled._tag === "Failure" && Cause.hasInterrupts(settled.cause))
) {
yield* FiberSet.clear(toolFibers)
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
if (publisher.hasActiveAssistant())
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
}
if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) {
const failure = Cause.squash(settled.cause)
@@ -311,7 +309,7 @@ export const layer = Layer.effect(
yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
}
const stepSettlement = publisher.stepSettlement()
if (stepSettlement && !streamInterrupted && !toolsInterrupted && !publisher.hasProviderError()) {
if (stepSettlement && !publisher.hasProviderError()) {
const endSnapshot = yield* snapshots.capture()
const files =
startSnapshot && endSnapshot
@@ -1,47 +0,0 @@
export * as SessionRuntimeCoordinator from "./runtime-coordinator"
import { Context, Effect, Layer } from "effect"
import { makeGlobalNode } from "../effect/app-node"
import { SessionRunner } from "./runner"
import { SessionRunCoordinator } from "./run-coordinator"
import { SessionSchema } from "./schema"
type Drain = (force: boolean) => Effect.Effect<void, SessionRunner.RunError>
export interface Interface {
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
readonly run: (sessionID: SessionSchema.ID, drain: Drain) => Effect.Effect<void, SessionRunner.RunError>
readonly wake: (sessionID: SessionSchema.ID, drain: Drain) => Effect.Effect<void>
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
readonly wait: (sessionID: SessionSchema.ID) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRuntimeCoordinator") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const drains = new Map<SessionSchema.ID, Drain>()
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({
drain: Effect.fnUntraced(function* (sessionID, force) {
const drain = drains.get(sessionID)
if (!drain) return yield* Effect.die(`No SessionRuntime drain registered for ${sessionID}`)
return yield* drain(force)
}),
})
return Service.of({
active: coordinator.active,
run: (sessionID, drain) =>
Effect.sync(() => drains.set(sessionID, drain)).pipe(Effect.andThen(coordinator.run(sessionID))),
wake: (sessionID, drain) =>
Effect.sync(() => drains.set(sessionID, drain)).pipe(Effect.andThen(coordinator.wake(sessionID))),
interrupt: coordinator.interrupt,
wait: coordinator.awaitIdle,
})
}),
)
export const defaultLayer = layer
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
-165
View File
@@ -1,165 +0,0 @@
export * as SessionRuntime from "./runtime"
import { Context, Effect, Layer } from "effect"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { Location } from "../location"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { SessionMessage } from "./message"
import { Prompt } from "./prompt"
import { SessionInput } from "./input"
import { SessionRevert } from "./revert"
import { SessionRunner } from "./runner"
import * as SessionRunnerLLM from "./runner/llm"
import { SessionRuntimeCoordinator } from "./runtime-coordinator"
import { SessionSchema } from "./schema"
import { Snapshot } from "../snapshot"
import { FSUtil } from "../fs-util"
import { makeLocationNode } from "../effect/app-node"
import { SessionV2 } from "../session"
import {
BusyError,
MessageNotFoundError,
NotFoundError,
PromptConflictError,
type RevertState,
} from "../session"
export interface Interface {
readonly prompt: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
prompt: PromptInput.Prompt
delivery?: SessionInput.Delivery
resume?: boolean
}) => Effect.Effect<SessionInput.Admitted, NotFoundError | PromptConflictError>
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
readonly revert: {
readonly stage: (input: {
sessionID: SessionSchema.ID
messageID: SessionMessage.ID
files?: boolean
}) => Effect.Effect<RevertState, NotFoundError | MessageNotFoundError | BusyError | Snapshot.Error>
readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError | Snapshot.Error>
readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError>
}
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRuntime") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const database = yield* Database.Service
const db = database.db
const events = yield* EventV2.Service
const location = yield* Location.Service
const sessions = yield* SessionV2.Service
const runner = yield* SessionRunner.Service
const snapshot = yield* Snapshot.Service
const coordinator = yield* SessionRuntimeCoordinator.Service
const local = Effect.fn("SessionRuntime.local")(function* (sessionID: SessionSchema.ID) {
const session = yield* sessions.get(sessionID)
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
return yield* new NotFoundError({ sessionID })
return session
})
const drain = (sessionID: SessionSchema.ID) =>
Effect.fnUntraced(function* (force: boolean) {
yield* local(sessionID).pipe(Effect.orDie)
return yield* runner.run({ sessionID, force })
})
return Service.of({
prompt: Effect.fn("SessionRuntime.prompt")((input) =>
Effect.uninterruptible(
Effect.gen(function* () {
yield* local(input.sessionID)
const prompt = resolvePrompt(input.prompt)
const messageID = input.id ?? SessionMessage.ID.create()
const delivery = input.delivery ?? "steer"
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
const admitted = yield* SessionInput.admit(db, events, {
id: messageID,
sessionID: input.sessionID,
prompt,
delivery,
}).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionInput.LifecycleConflict
? new PromptConflictError({ sessionID: input.sessionID, messageID })
: Effect.die(defect),
),
)
if (!SessionInput.equivalent(admitted, expected))
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
if (input.resume !== false) yield* coordinator.wake(admitted.sessionID, drain(admitted.sessionID))
return admitted
}),
),
),
wait: Effect.fn("SessionRuntime.wait")(function* (sessionID) {
yield* local(sessionID)
yield* coordinator.wait(sessionID)
}),
active: coordinator.active,
resume: Effect.fn("SessionRuntime.resume")(function* (sessionID) {
yield* local(sessionID)
yield* coordinator.run(sessionID, drain(sessionID))
}),
interrupt: Effect.fn("SessionRuntime.interrupt")(function* (sessionID) {
yield* local(sessionID)
yield* Effect.uninterruptible(coordinator.interrupt(sessionID))
}),
revert: {
stage: Effect.fn("SessionRuntime.revert.stage")(function* (input) {
const session = yield* local(input.sessionID)
if ((yield* coordinator.active).has(input.sessionID)) return yield* new BusyError({ sessionID: input.sessionID })
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
Effect.provideService(Database.Service, database),
Effect.provideService(EventV2.Service, events),
Effect.provideService(Snapshot.Service, snapshot),
)
}),
clear: Effect.fn("SessionRuntime.revert.clear")(function* (sessionID) {
const session = yield* local(sessionID)
if ((yield* coordinator.active).has(sessionID)) return yield* new BusyError({ sessionID })
return yield* SessionRevert.clear(session).pipe(
Effect.provideService(EventV2.Service, events),
Effect.provideService(Snapshot.Service, snapshot),
)
}),
commit: Effect.fn("SessionRuntime.revert.commit")(function* (sessionID) {
const session = yield* local(sessionID)
if ((yield* coordinator.active).has(sessionID)) return yield* new BusyError({ sessionID })
return yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
}),
},
})
}),
)
const resolvePrompt = (input: PromptInput.Prompt) =>
Prompt.make({
text: input.text,
agents: input.agents,
files: input.files?.map((file) => {
const dataMime = file.uri.match(/^data:([^;,]+)[;,]/i)?.[1]
const target = URL.canParse(file.uri) ? new URL(file.uri).pathname : (file.name ?? file.uri)
return {
...file,
mime: dataMime ?? (target.endsWith("/") ? "application/x-directory" : FSUtil.mimeType(target)),
}
}),
})
export const node = makeLocationNode({
service: Service,
layer,
deps: [Database.node, EventV2.node, Location.node, SessionV2.node, SessionRunnerLLM.node, Snapshot.node, SessionRuntimeCoordinator.node],
})
+2
View File
@@ -196,6 +196,8 @@ export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node, Config.node] })
export const nodeWithoutConfig = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node] })
/** Runs retention scanning once globally rather than once per active Location. */
export const cleanupLayer = Layer.effectDiscard(
Effect.gen(function* () {
+8
View File
@@ -4,11 +4,13 @@ import { ToolFailure } from "@opencode-ai/llm"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation"
import { Patch } from "../patch"
import { PermissionV2 } from "../permission"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
@@ -194,6 +196,12 @@ export const layer = Layer.effectDiscard(
}),
)
export const node = makeLocationNode({
name: "tool/apply-patch",
layer,
deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node],
})
function patchFile(change: Prepared): typeof FileDiff.Info.Type {
const counts = diffLines(change.before, change.after).reduce(
(result, item) => ({
+8
View File
@@ -5,11 +5,13 @@ import { ToolFailure } from "@opencode-ai/llm"
import { Duration, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Config } from "../config"
import { makeLocationNode } from "../effect/app-node"
import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation"
import { AppProcess } from "../process"
import { PermissionV2 } from "../permission"
import { PositiveInt } from "../schema"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
@@ -193,3 +195,9 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
export const node = makeLocationNode({
name: "tool/bash",
layer,
deps: [ToolRegistry.node, LocationMutation.node, FSUtil.node, AppProcess.node, Config.node, PermissionV2.node],
})
+8
View File
@@ -10,10 +10,12 @@ import { ToolFailure } from "@opencode-ai/llm"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
@@ -213,3 +215,9 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
export const node = makeLocationNode({
name: "tool/edit",
layer,
deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node],
})
+8
View File
@@ -2,8 +2,10 @@ export * as QuestionTool from "./question"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { PermissionV2 } from "../permission"
import { QuestionV2 } from "../question"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
@@ -84,3 +86,9 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
export const node = makeLocationNode({
name: "tool/question",
layer,
deps: [ToolRegistry.node, PermissionV2.node, QuestionV2.node],
})
+8
View File
@@ -2,12 +2,14 @@ export * as ReadTool from "./read"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { FileSystem } from "../filesystem"
import { Image } from "../image"
import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission"
import { AbsolutePath } from "../schema"
import { ReadToolFileSystem } from "./read-filesystem"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
@@ -107,3 +109,9 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
export const node = makeLocationNode({
name: "tool/read",
layer,
deps: [ToolRegistry.node, ReadToolFileSystem.node, LocationMutation.node, Image.node, PermissionV2.node],
})
+1 -1
View File
@@ -2,7 +2,7 @@ export * as ToolRegistry from "./registry"
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/llm"
import { Context, Effect, Layer, Scope } from "effect"
import type { AgentV2 } from "../agent"
import { AgentV2 } from "../agent"
import { PermissionV2 } from "../permission"
import { SessionMessage } from "../session/message"
import { SessionSchema } from "../session/schema"
+8
View File
@@ -3,9 +3,11 @@ export * as SkillTool from "./skill"
import path from "path"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { FSUtil } from "../fs-util"
import { SkillV2 } from "../skill"
import { PermissionV2 } from "../permission"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
@@ -99,3 +101,9 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
export const node = makeLocationNode({
name: "tool/skill",
layer,
deps: [ToolRegistry.node, FSUtil.node, SkillV2.node, PermissionV2.node],
})
-195
View File
@@ -1,195 +0,0 @@
export * as SubagentTool from "./subagent"
import { ToolFailure } from "@opencode-ai/llm"
import { DateTime, Effect, Layer, Schema, Scope } from "effect"
import { AgentV2 } from "../agent"
import { BackgroundJob } from "../background-job"
import { EventV2 } from "../event"
import { LocationServiceMap } from "../location-service-map"
import { SessionV2 } from "../session"
import { SessionRuntime } from "../session/runtime"
import { SessionEvent } from "../session/event"
import { SessionMessage } from "../session/message"
import { SessionSchema } from "../session/schema"
import { makeGlobalNode } from "../effect/app-node"
import { ApplicationTools } from "./application-tools"
import { Tool } from "./tool"
export const name = "subagent"
const NO_TEXT = "Subagent completed without a text response."
const BACKGROUND_STARTED =
"The subagent is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll, or proactively check on its progress."
export const Input = Schema.Struct({
agent: Schema.String.annotate({ description: "The configured agent to run as the subagent" }),
description: Schema.String.annotate({ description: "A short description of the subagent's task" }),
prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }),
background: Schema.Boolean.pipe(Schema.optional).annotate({
description:
"Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT poll its progress.",
}),
})
export const Output = Schema.Struct({
sessionID: SessionSchema.ID,
status: Schema.Literals(["completed", "running"]),
output: Schema.String,
})
export const description = [
"Spawn a subagent: a child session running a configured agent with fresh context.",
"Foreground (default) runs the subagent to completion and returns its final response.",
"Background mode (background=true) launches it asynchronously and returns immediately; you are notified when it finishes.",
"Use background only for independent work that can run while you continue elsewhere.",
].join("\n")
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* ApplicationTools.Service
const sessions = yield* SessionV2.Service
const jobs = yield* BackgroundJob.Service
const events = yield* EventV2.Service
const locations = yield* LocationServiceMap.Service
const scope = yield* Scope.Scope
// Concatenate the child's final completed assistant text. Distinguishes "completed with no
// text" (generic string) from "failed" (the run effect fails, surfaced as a job error).
const latestAssistantText = Effect.fn("SubagentTool.latestAssistantText")(function* (sessionID: SessionSchema.ID) {
const messages = yield* sessions.messages({ sessionID, order: "desc", limit: 20 })
const assistant = messages.find(
(message) =>
message.type === "assistant" && message.time.completed !== undefined && message.error === undefined,
)
if (assistant === undefined || assistant.type !== "assistant") return NO_TEXT
const text = assistant.content
.filter((part): part is Extract<typeof part, { type: "text" }> => part.type === "text")
.map((part) => part.text)
.join("")
return text.length > 0 ? text : NO_TEXT
})
const injectCompletion = Effect.fn("SubagentTool.injectCompletion")(function* (
parentID: SessionSchema.ID,
childID: SessionSchema.ID,
description: string,
state: "completed" | "error" | "cancelled",
text: string,
) {
yield* events.publish(SessionEvent.Synthetic, {
sessionID: parentID,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
text: `<subagent id="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
})
})
const injectWhenDone = Effect.fn("SubagentTool.injectWhenDone")(function* (
parentID: SessionSchema.ID,
childID: SessionSchema.ID,
description: string,
) {
yield* jobs.wait({ id: childID }).pipe(
Effect.flatMap((result) => {
if (result.info?.status === "completed")
return injectCompletion(parentID, childID, description, "completed", result.info.output ?? NO_TEXT)
if (result.info?.status === "error")
return injectCompletion(parentID, childID, description, "error", result.info.error ?? "Subagent failed")
if (result.info?.status === "cancelled")
return injectCompletion(parentID, childID, description, "cancelled", "Subagent cancelled")
return Effect.void
}),
Effect.forkIn(scope, { startImmediately: true }),
)
})
yield* tools
.register({
[name]: Tool.make({
description,
input: Input,
output: Output,
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
execute: (input, context) =>
Effect.gen(function* () {
const parent = yield* sessions
.get(context.sessionID)
.pipe(
Effect.mapError(() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` })),
)
const locationLayer = locations.get(parent.location)
const agents = yield* AgentV2.Service.pipe(Effect.provide(locationLayer))
const runtime = yield* SessionRuntime.Service.pipe(Effect.provide(locationLayer))
const agent = yield* agents.resolve(input.agent)
if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` })
if (agent.mode === "primary")
return yield* new ToolFailure({ message: `Agent ${input.agent} cannot run as a subagent` })
// Model selection is policy/config/session state, not an LLM-facing tool argument.
const model = agent.model ?? parent.model
const child = yield* sessions
.create({
parentID: context.sessionID,
title: input.description,
agent: AgentV2.ID.make(input.agent),
model,
// TODO(opencode kkdvxn): derive restricted subagent permissions from the parent
// session (V1 deriveSubagentSessionPermission). MVP uses the agent's own permissions.
})
.pipe(
Effect.mapError(() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` })),
)
const background = input.background === true
const run = Effect.gen(function* () {
// The child session owns its agent/model (set at create); prompt only admits input.
yield* runtime.prompt({ sessionID: child.id, prompt: { text: input.prompt }, resume: false })
yield* runtime.resume(child.id)
return yield* latestAssistantText(child.id)
})
const info = yield* jobs.start({
id: child.id,
type: name,
title: input.description,
metadata: {},
onPromote: injectWhenDone(context.sessionID, child.id, input.description),
run,
})
if (background) {
if ((yield* jobs.promote(info.id)) === undefined)
yield* injectWhenDone(context.sessionID, child.id, input.description)
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
}
const result = yield* Effect.raceFirst(
jobs.wait({ id: child.id }).pipe(Effect.map((waited) => waited.info)),
jobs.waitForPromotion(child.id),
).pipe(
Effect.onInterrupt(() =>
Effect.all([runtime.interrupt(child.id), jobs.cancel(child.id)], { discard: true }),
),
)
if (result?.metadata?.background === true)
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
if (result?.status === "error")
return yield* new ToolFailure({ message: result.error ?? "Subagent failed" })
if (result?.status === "cancelled") return yield* new ToolFailure({ message: "Subagent cancelled" })
return { sessionID: child.id, status: "completed" as const, output: result?.output ?? NO_TEXT }
}),
}),
})
.pipe(Effect.orDie)
}),
)
// Registered at the app root via ApplicationTools, not as a Location node: SessionV2 sits above
// LocationServiceMap, so a location-scoped subagent node would create a static dependency cycle.
// Agent lookup is resolved through the parent Session's location when the tool executes.
export const node = makeGlobalNode({
name: "subagent-tool",
layer,
deps: [ApplicationTools.node, SessionV2.node, BackgroundJob.node, EventV2.node, LocationServiceMap.node],
})
+8
View File
@@ -2,8 +2,10 @@ export * as TodoWriteTool from "./todowrite"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { PermissionV2 } from "../permission"
import { SessionTodo } from "../session/todo"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
@@ -52,3 +54,9 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
export const node = makeLocationNode({
name: "tool/todowrite",
layer,
deps: [ToolRegistry.node, PermissionV2.node, SessionTodo.node],
})
+9
View File
@@ -5,8 +5,11 @@ import { Duration, Effect, Layer, Schema } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Parser } from "htmlparser2"
import TurndownService from "turndown"
import { makeLocationNode } from "../effect/app-node"
import { LayerNodePlatform } from "../effect/app-node-platform"
import { PermissionV2 } from "../permission"
import { collectBoundedResponseBody } from "./http-body"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
@@ -177,6 +180,12 @@ export const layer = Layer.effectDiscard(
}),
)
export const node = makeLocationNode({
name: "tool/webfetch",
layer,
deps: [ToolRegistry.node, PermissionV2.node, LayerNodePlatform.httpClient],
})
export function extractTextFromHTML(html: string) {
let text = ""
let skipDepth = 0
+11
View File
@@ -3,6 +3,8 @@ export * as WebSearchTool from "./websearch"
import { ToolFailure } from "@opencode-ai/llm"
import { Context, Duration, Effect, Layer, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { makeLocationNode } from "../effect/app-node"
import { LayerNodePlatform } from "../effect/app-node-platform"
import { truthy } from "../flag/flag"
import { InstallationVersion } from "../installation/version"
import { PositiveInt } from "../schema"
@@ -11,6 +13,7 @@ import { Tool } from "./tool"
import { Tools } from "./tools"
import { collectBoundedResponseBody } from "./http-body"
import { checksum } from "../util/encode"
import { ToolRegistry } from "./registry"
export const name = "websearch"
export const NO_RESULTS = "No search results found. Please try a different query."
@@ -80,6 +83,8 @@ export const defaultConfigLayer = Layer.sync(ConfigService, () =>
}),
)
export const configNode = makeLocationNode({ service: ConfigService, layer: defaultConfigLayer, deps: [] })
export function selectProvider(
sessionID: string,
flags: Pick<Config, "enableExa" | "enableParallel"> = { enableExa: false, enableParallel: false },
@@ -247,3 +252,9 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
export const node = makeLocationNode({
name: "tool/websearch",
layer,
deps: [ToolRegistry.node, PermissionV2.node, LayerNodePlatform.httpClient, configNode],
})
+8
View File
@@ -8,9 +8,11 @@ export * as WriteTool from "./write"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { FileMutation } from "../file-mutation"
import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
@@ -91,3 +93,9 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
export const node = makeLocationNode({
name: "tool/write",
layer,
deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, PermissionV2.node],
})
+2 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Scope } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -8,7 +9,7 @@ import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
import { agentHost, host } from "./plugin/host"
const it = testEffect(AgentV2.locationLayer)
const it = testEffect(AppNodeBuilder.build(AgentV2.node))
describe("AgentV2", () => {
it.effect("starts without agents", () =>
+7 -11
View File
@@ -1,7 +1,8 @@
import { describe, expect } from "bun:test"
import { Tool } from "@opencode-ai/core/tool/tool"
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { AgentV2 } from "@opencode-ai/core/agent"
@@ -9,19 +10,14 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { Tools } from "@opencode-ai/core/tool/tools"
import { Deferred, Effect, Exit, Fiber, Layer, Schema, Scope } from "effect"
import { Deferred, Effect, Exit, Fiber, Schema, Scope } from "effect"
import { testEffect } from "./lib/effect"
const permission = Layer.mock(PermissionV2.Service, {
assert: () => Effect.void,
})
const applications = ApplicationTools.layer
const registry = ToolRegistry.layer.pipe(
Layer.provide(permission),
Layer.provide(applications),
Layer.provide(ToolOutputStore.defaultLayer),
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, ToolRegistry.node, ToolRegistry.toolsNode]), [
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
]),
)
const it = testEffect(Layer.mergeAll(applications, registry))
const sessionID = SessionV2.ID.make("ses_application_tool")
const agent = AgentV2.ID.make("build")
+6 -3
View File
@@ -1,8 +1,11 @@
import { describe, expect } from "bun:test"
import { BackgroundJob } from "@opencode-ai/core/background-job"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Deferred, Effect, Exit, Scope } from "effect"
import { it } from "./lib/effect"
const jobsLayer = LayerNode.compile(BackgroundJob.node)
describe("BackgroundJob", () => {
it.live("tracks process-local work through explicit observation", () =>
Effect.gen(function* () {
@@ -25,7 +28,7 @@ describe("BackgroundJob", () => {
timedOut: false,
info: { status: "completed", output: "done" },
})
}).pipe(Effect.provide(BackgroundJob.layer)),
}).pipe(Effect.provide(jobsLayer)),
)
it.live("publishes jobs before starting immediately settling work", () =>
@@ -55,7 +58,7 @@ describe("BackgroundJob", () => {
})
})
})
}).pipe(Effect.provide(BackgroundJob.layer)),
}).pipe(Effect.provide(jobsLayer)),
)
it.live("increments pending work before starting immediately settling extensions", () =>
@@ -80,7 +83,7 @@ describe("BackgroundJob", () => {
})
}),
)
}).pipe(Effect.provide(BackgroundJob.layer)),
}).pipe(Effect.provide(jobsLayer)),
)
it.live("interrupts live work without promising settlement after the owning process-local scope closes", () =>
+14 -18
View File
@@ -3,6 +3,8 @@ import { Effect, Fiber, Layer, Stream } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
@@ -21,13 +23,11 @@ const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
)
const it = testEffect(
Catalog.locationLayer.pipe(
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer),
Layer.provideMerge(Credential.defaultLayer),
),
const catalogLayer = AppNodeBuilder.build(
LayerNode.group([Catalog.node, EventV2.node, Credential.node, Integration.node, Policy.node]),
[[Location.node, locationLayer]],
)
const it = testEffect(catalogLayer)
describe("CatalogV2", () => {
it.effect("publishes an updated event after catalog changes", () =>
@@ -47,11 +47,8 @@ describe("CatalogV2", () => {
it.effect("derives availability from active credentials without changing provider state", () => {
const integrationID = Integration.ID.make("test")
const layer = Catalog.locationLayer.pipe(
Layer.fresh,
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer),
Layer.provideMerge(Credential.defaultLayer.pipe(Layer.fresh)),
const localCatalogLayer = Layer.fresh(
AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node]), [[Location.node, locationLayer]]),
)
return Effect.gen(function* () {
@@ -73,17 +70,16 @@ describe("CatalogV2", () => {
})
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
}).pipe(Effect.provide(layer))
}).pipe(Effect.provide(localCatalogLayer))
})
it.effect("derives availability from a provider's integration", () => {
const integrationID = Integration.ID.make("gateway")
const providerID = ProviderV2.ID.make("remote")
const layer = Catalog.locationLayer.pipe(
Layer.fresh,
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer),
Layer.provideMerge(Credential.defaultLayer.pipe(Layer.fresh)),
const localCatalogLayer = Layer.fresh(
AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node, Integration.node]), [
[Location.node, locationLayer],
]),
)
return Effect.gen(function* () {
@@ -102,7 +98,7 @@ describe("CatalogV2", () => {
})
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([providerID])
}).pipe(Effect.provide(layer))
}).pipe(Effect.provide(localCatalogLayer))
})
it.effect("projects environment connections without a catalog plugin", () =>
+2 -1
View File
@@ -1,11 +1,12 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { CommandV2 } from "@opencode-ai/core/command"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "./lib/effect"
const it = testEffect(CommandV2.locationLayer)
const it = testEffect(AppNodeBuilder.build(CommandV2.node))
describe("CommandV2", () => {
it.effect("applies command transforms and preserves later overrides", () =>
+4 -2
View File
@@ -1,10 +1,12 @@
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect, Layer, Schema } from "effect"
import { Effect, Schema } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -12,7 +14,7 @@ import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { agentHost, host } from "../plugin/host"
const it = testEffect(Layer.mergeAll(AgentV2.locationLayer, FSUtil.defaultLayer))
const it = testEffect(AppNodeBuilder.build(LayerNode.group([AgentV2.node, FSUtil.node])))
const decode = Schema.decodeUnknownSync(Config.Info)
describe("ConfigAgentPlugin.Plugin", () => {
+4 -2
View File
@@ -1,10 +1,12 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { Effect, Schema } from "effect"
import { CommandV2 } from "@opencode-ai/core/command"
import { Config } from "@opencode-ai/core/config"
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
@@ -13,7 +15,7 @@ import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { host } from "../plugin/host"
const it = testEffect(Layer.mergeAll(CommandV2.locationLayer, FSUtil.defaultLayer))
const it = testEffect(AppNodeBuilder.build(LayerNode.group([CommandV2.node, FSUtil.node])))
const decode = Schema.decodeUnknownSync(Config.Info)
describe("ConfigCommandPlugin.Plugin", () => {
+12 -12
View File
@@ -5,6 +5,8 @@ import { Effect, Layer, Schema } from "effect"
import { FastCheck } from "effect/testing"
import { Config } from "@opencode-ai/core/config"
import { ConfigProvider } from "@opencode-ai/core/config/provider"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { FSUtil } from "@opencode-ai/core/fs-util"
@@ -25,21 +27,19 @@ function testLayer(
projectDirectory = directory,
vcs?: Project.Vcs,
) {
return Config.locationLayer.pipe(
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Global.layerWith({ config: globalDirectory })),
Layer.provide(
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(projectDirectory), vcs },
),
),
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(projectDirectory), vcs },
),
),
)
return AppNodeBuilder.build(LayerNode.group([Config.node, Policy.node]), [
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: globalDirectory })],
])
}
const provider = {
+2 -1
View File
@@ -1,10 +1,11 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Credential } from "@opencode-ai/core/credential"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Integration } from "@opencode-ai/core/integration"
import { testEffect } from "./lib/effect"
const it = testEffect(Credential.defaultLayer)
const it = testEffect(LayerNode.compile(Credential.node))
describe("Credential", () => {
it.effect("stores, updates, lists, and removes credentials", () =>
@@ -15,6 +15,8 @@ import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migrat
import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent"
import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera"
import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
@@ -268,7 +270,6 @@ describe("DatabaseMigration", () => {
yield* DatabaseMigration.applyOnly(db, [simplifySessionInputMigration])
const database = Layer.succeed(Database.Service, { db })
const events = EventV2.layer.pipe(Layer.provide(database))
yield* EventV2.Service.use((service) =>
service.publish(SessionV1.Event.Updated, {
sessionID: SessionSchema.ID.make("session"),
@@ -284,7 +285,7 @@ describe("DatabaseMigration", () => {
}),
).pipe(
Effect.provide(
Layer.merge(events, SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))),
AppNodeBuilder.build(LayerNode.group([EventV2.node, SessionProjector.node]), [[Database.node, database]]),
),
)
@@ -56,16 +56,23 @@ const checkError: Layer.Layer<B, LayerError, never> = closedWithError
void checkClosed
void checkError
LayerNode.replace(aLayer, Layer.succeed(A, A.of({})))
LayerNode.compile(a, [[a, Layer.succeed(A, A.of({}))]])
LayerNode.compile(a, [[a, make({ service: A, layer: Layer.succeed(A, A.of({})), deps: [] })]])
// @ts-expect-error Replacement must provide A
LayerNode.replace(aLayer, Layer.succeed(B, B.of({})))
LayerNode.compile(a, [[a, Layer.succeed(B, B.of({}))]])
// @ts-expect-error Node replacement must provide A
const invalidNodeReplacement = () => LayerNode.compile(a, [[a, b]])
void invalidNodeReplacement
// @ts-expect-error Replacement cannot introduce a new error
LayerNode.replace(aLayer, Layer.effect(A, Effect.fail(new OtherError())))
LayerNode.compile(a, [[a, Layer.effect(A, Effect.fail(new OtherError()))]])
// @ts-expect-error Replacement must be closed
LayerNode.replace(bLayer, bLayer)
const invalidNodeErrorReplacement = () =>
// @ts-expect-error Node replacement cannot introduce a new error
LayerNode.compile(a, [[a, make({ service: A, layer: Layer.effect(A, Effect.fail(new OtherError())), deps: [] })]])
void invalidNodeErrorReplacement
class TagA extends Context.Service<TagA, {}>()("test/TagA") {}
class TagB extends Context.Service<TagB, {}>()("test/TagB") {}
@@ -13,7 +13,7 @@ class App extends Context.Service<App, { readonly run: Effect.Effect<string[]> }
const tags = LayerNode.tags({ app: [] })
const make = tags.make("app")
const build = <A, E>(root: LayerNode.Node<A, E, any>, replacements?: readonly LayerNode.Replacement[]) =>
LayerNode.compile(root, new Map(replacements?.map((item) => [item.source, item.replacement]))) as Layer.Layer<A, E>
LayerNode.compile(root, replacements) as Layer.Layer<A, E>
const valueLayer = Layer.succeed(Value, Value.of({ value: "production" }))
const greetingLayer = Layer.effect(
Greeting,
@@ -63,21 +63,20 @@ describe("layer node", () => {
expect(await Effect.runPromise(program)).toEqual(["first", "second"])
})
test("requires unbound nodes to be bound before compilation", async () => {
test("requires unbound nodes to be replaced before compilation", async () => {
const unbound = LayerNode.unbound(Value, tags.values.app)
const greeting = make({ service: Greeting, layer: greetingLayer, deps: [unbound] })
const tree = LayerNode.group([greeting])
expect(() => LayerNode.compile(tree)).toThrow("Unbound layer node: test/LayerNodeValue")
const bound = LayerNode.bind(tree, unbound, value)
const layer = LayerNode.compile(bound) as Layer.Layer<Greeting>
const layer = LayerNode.compile(tree, [[unbound, value]]) as Layer.Layer<Greeting>
const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(layer))
expect(await Effect.runPromise(program)).toBe("hello production")
})
test("replaces a layer by identity", async () => {
test("replaces a node with a closed layer", async () => {
const replacement = Layer.succeed(Value, Value.of({ value: "simulation" }))
const program = Effect.map(Greeting, (item) => item.value).pipe(
Effect.provide(build(LayerNode.group([greeting]), [LayerNode.replace(valueLayer, replacement)])),
Effect.provide(build(LayerNode.group([greeting]), [[value, replacement]])),
)
expect(await Effect.runPromise(program)).toBe("hello simulation")
})
@@ -94,7 +93,7 @@ describe("layer node", () => {
const left = make({ service: Left, layer: leftLayer, deps: [value] })
const right = make({ service: Right, layer: rightLayer, deps: [value] })
const replacement = Layer.succeed(Value, Value.of({ value: "replaced" }))
const layer = build(LayerNode.group([left, right]), [LayerNode.replace(valueLayer, replacement)])
const layer = build(LayerNode.group([left, right]), [[value, replacement]])
const program = Effect.gen(function* () {
return [(yield* Left).value, (yield* Right).value]
}).pipe(Effect.provide(layer))
@@ -103,22 +102,47 @@ describe("layer node", () => {
test("does not acquire an unused replacement", async () => {
let acquisitions = 0
const other = Layer.succeed(Value, Value.of({ value: "other" }))
const other = make({ service: Left, layer: Layer.succeed(Left, Left.of({ value: "other" })), deps: [] })
const replacement = Layer.effect(
Value,
Left,
Effect.sync(() => {
acquisitions++
return Value.of({ value: "replacement" })
return Left.of({ value: "replacement" })
}),
)
await Effect.runPromise(
Effect.map(Greeting, (item) => item.value).pipe(
Effect.provide(build(LayerNode.group([greeting]), [LayerNode.replace(other, replacement)])),
Effect.provide(build(LayerNode.group([greeting]), [[other, replacement]])),
),
)
expect(acquisitions).toBe(0)
})
test("replaces a node without acquiring its dependencies", async () => {
let acquisitions = 0
const dependencyLayer = Layer.effect(
Value,
Effect.sync(() => {
acquisitions++
return Value.of({ value: "dependency" })
}),
)
const dependency = make({ service: Value, layer: dependencyLayer, deps: [] })
const original = make({ service: Greeting, layer: greetingLayer, deps: [dependency] })
const replacement = make({
service: Greeting,
layer: Layer.succeed(Greeting, Greeting.of({ value: "replacement" })),
deps: [],
})
const program = Effect.map(Greeting, (item) => item.value).pipe(
Effect.provide(build(LayerNode.group([original]), [[original, replacement]])),
)
expect(await Effect.runPromise(program)).toBe("replacement")
expect(acquisitions).toBe(0)
})
test("hoists and compiles tagged graphs", async () => {
const tags = LayerNode.tags({ location: ["global"], global: [] })
const global = tags.make("global")
@@ -1,8 +1,8 @@
import { describe, expect, test } from "bun:test"
import { Context, Effect, Layer, LayerMap, Option } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Node } from "@opencode-ai/core/effect/app-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationError, LocationServices } from "@opencode-ai/core/location-services"
@@ -31,7 +31,7 @@ describe("node build", () => {
expect(await Effect.runPromise(program)).toBe("plain")
})
test("detects cycles through a bound location service map", () => {
test("detects cycles through a replaced location service map", async () => {
const a = Node.makeGlobalNode({
service: CycleA,
layer: Layer.effect(CycleA, Effect.as(LocationServiceMap.Service, CycleA.of({}))),
@@ -45,26 +45,28 @@ describe("node build", () => {
),
deps: [a],
})
const mapEffect = Effect.gen(function* () {
const service = yield* CycleB
return yield* LayerMap.make(
(ref: Location.Ref) =>
Layer.succeed(
Location.Service,
Location.Service.of({
directory: ref.directory,
workspaceID: ref.workspaceID,
project: { id: Project.ID.global, directory: service.directory },
}),
),
{ idleTimeToLive: "1 minute" },
)
}) as unknown as Effect.Effect<LayerMap.LayerMap<Location.Ref, LocationServices, LocationError>, never, CycleB>
const mapLayer = Layer.effect(LocationServiceMap.Service, mapEffect)
const mapLayer = Layer.effect(
LocationServiceMap.Service,
Effect.gen(function* () {
const service = yield* CycleB
return yield* LayerMap.make(
(ref: Location.Ref) =>
Layer.succeed(
Location.Service,
Location.Service.of({
directory: ref.directory,
workspaceID: ref.workspaceID,
project: { id: Project.ID.global, directory: service.directory },
}),
),
{ idleTimeToLive: "1 minute" },
)
}) as unknown as Effect.Effect<LayerMap.LayerMap<Location.Ref, LocationServices, LocationError>, never, CycleB>,
)
const map = Node.makeGlobalNode({ service: LocationServiceMap.Service, layer: mapLayer, deps: [b] })
const graph = LayerNode.bind(LayerNode.group([a]), LocationServiceMap.node, map)
expect(() => AppNodeBuilder.build(graph)).toThrow("Cycle detected in layer tree")
expect(() => AppNodeBuilder.build(LayerNode.group([a]), [[LocationServiceMap.node, map]])).toThrow(
"Cycle detected in layer tree",
)
})
test("shares top-level project with location services", async () => {
@@ -81,10 +83,10 @@ describe("node build", () => {
})
}),
)
const layer = AppNodeBuilder.build(LayerNode.group([Project.node, LocationServiceMap.node]), [
LayerNode.replace(Project.layer, projectLayer),
])
const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) })
const layer = AppNodeBuilder.build(LayerNode.group([Project.node, LocationServiceMap.node]), [
[Project.node, projectLayer],
])
const program = Effect.gen(function* () {
yield* Project.Service
const locations = yield* LocationServiceMap.Service
+8 -5
View File
@@ -6,6 +6,8 @@ import { Session } from "@opencode-ai/schema/session"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import { SessionV1 } from "@opencode-ai/schema/session-v1"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -76,9 +78,10 @@ const durableData = (sessionID: Session.ID, text: string) => ({
messageID: SessionV1.MessageID.ascending(`msg_${text}`),
})
const eventLayer = Layer.mergeAll(EventV2.layerWith().pipe(Layer.provide(Database.defaultLayer)), Database.defaultLayer)
const it = testEffect(eventLayer.pipe(Layer.provideMerge(locationLayer)))
const itWithoutLocation = testEffect(eventLayer)
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, Location.node]), [[Location.node, locationLayer]]),
)
const itWithoutLocation = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node])))
describe("EventV2", () => {
it.effect("publishes events with the current location", () =>
@@ -462,7 +465,7 @@ describe("EventV2", () => {
pause
? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead)))
: Effect.void,
}).pipe(Layer.provide(Database.defaultLayer))
}).pipe(Layer.provide(LayerNode.compile(Database.node)))
yield* Effect.gen(function* () {
const events = yield* EventV2.Service
@@ -477,7 +480,7 @@ describe("EventV2", () => {
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
[0, durableData(aggregateID, "during handoff")],
])
}).pipe(Effect.provide(Layer.mergeAll(Database.defaultLayer, eventLayer)))
}).pipe(Effect.provide(Layer.merge(LayerNode.compile(Database.node), eventLayer)))
}),
)
+10 -5
View File
@@ -2,6 +2,8 @@ import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
@@ -11,14 +13,17 @@ import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
function provide(directory: string, filesystem = FSUtil.defaultLayer) {
function provide(directory: string, filesystemLayer = LayerNode.compile(FSUtil.node)) {
const activeLocation = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
)
const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
const mutation = FileMutation.layer.pipe(Layer.provide(filesystem))
return Effect.provide(Layer.mergeAll(resolution, mutation))
return Effect.provide(
AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
[Location.node, activeLocation],
[FSUtil.node, filesystemLayer],
]),
)
}
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
@@ -359,5 +364,5 @@ function instrumentWrites(run: <E>(write: Effect.Effect<void, E>, target: string
run(filesystem.writeFileString(target, content, options), target),
})
}),
).pipe(Layer.provide(FSUtil.defaultLayer))
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
}
@@ -1,11 +1,12 @@
import { describe, test, expect } from "bun:test"
import { Effect, Layer, FileSystem } from "effect"
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, FileSystem } from "effect"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { testEffect } from "../lib/effect"
import path from "path"
const live = Layer.merge(FSUtil.defaultLayer, NodeFileSystem.layer)
const live = LayerNode.compile(LayerNode.group([FSUtil.node, LayerNodePlatform.filesystem]))
const { effect: it } = testEffect(live)
describe("FSUtil", () => {
+9 -11
View File
@@ -4,10 +4,11 @@ import fs from "fs/promises"
import path from "path"
import { ConfigProvider, Deferred, Duration, Effect, Fiber, Layer, Option, Stream } from "effect"
import { Config } from "@opencode-ai/core/config"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Git } from "@opencode-ai/core/git"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
@@ -18,7 +19,7 @@ const describeWatcher = Watcher.hasNativeBinding() && !process.env.CI ? describe
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
const it = testEffect(Layer.mergeAll(FSUtil.defaultLayer, EventV2.defaultLayer))
const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, EventV2.node])))
const configLayer = Layer.succeed(
Config.Service,
@@ -40,10 +41,10 @@ function provide(directory: string, vcs?: Location.Interface["vcs"]) {
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
)
return Effect.provide(
Watcher.layer.pipe(
Layer.provide(configLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(locationLayer),
AppNodeBuilder.build(Watcher.node, [
[Config.node, configLayer],
[Location.node, locationLayer],
]).pipe(
Layer.provide(flagsLayer),
),
)
@@ -196,7 +197,7 @@ describeWatcher("Watcher", () => {
yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "gone")).pipe(
Effect.provideService(EventV2.Service, events),
)
}).pipe(Effect.provide(Layer.mergeAll(FSUtil.defaultLayer, EventV2.defaultLayer))),
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([FSUtil.node, EventV2.node])))),
)
it.live("ignores .git/index changes", () =>
@@ -228,10 +229,7 @@ describeWatcher("Watcher", () => {
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
expect(
yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
).toEqual({
file: head,
event: "change",
})
).toMatchObject({ file: head })
}),
{ git: true },
),
@@ -1,7 +1,7 @@
import fs from "fs/promises"
import os from "os"
import { Effect, Layer } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Effect } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { Global } from "@opencode-ai/core/global"
@@ -30,7 +30,7 @@ const testGlobal = Global.layerWith({
log: os.tmpdir(),
})
const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(FSUtil.defaultLayer))
const testLayer = AppNodeBuilder.build(EffectFlock.node, [[Global.node, testGlobal]])
async function job() {
if (msg.ready) await fs.writeFile(msg.ready, String(process.pid))
+82 -61
View File
@@ -2,6 +2,8 @@ import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import fs from "fs/promises"
import path from "path"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { InstructionContext } from "@opencode-ai/core/instruction-context"
@@ -15,6 +17,15 @@ import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
const instructionLayer = (
input: { config: string; locationServiceLayer: Layer.Layer<Location.Service>; filesystemLayer?: Layer.Layer<FSUtil.Service> },
) =>
AppNodeBuilder.build(LayerNode.group([SystemContextRegistry.node, InstructionContext.node]), [
[Global.node, Global.layerWith({ config: input.config })],
[Location.node, input.locationServiceLayer],
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
])
describe("InstructionContext", () => {
it.live("loads global and upward project AGENTS.md files as one aggregate context", () =>
Effect.acquireRelease(
@@ -41,19 +52,19 @@ describe("InstructionContext", () => {
const load = SystemContextRegistry.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
Effect.provide(FSUtil.defaultLayer),
Effect.provide(Global.layerWith({ config: global })),
Effect.provide(
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(project) },
instructionLayer({
config: global,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(project) },
),
),
),
),
}),
),
)
@@ -107,14 +118,14 @@ describe("InstructionContext", () => {
yield* Effect.promise(() => fs.writeFile(file, ""))
const context = yield* SystemContextRegistry.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
Effect.provide(FSUtil.defaultLayer),
Effect.provide(Global.layerWith({ config: path.join(tmp.path, "global") })),
Effect.provide(
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
),
instructionLayer({
config: path.join(tmp.path, "global"),
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
),
}),
),
)
@@ -133,14 +144,18 @@ describe("InstructionContext", () => {
FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }),
),
),
).pipe(Layer.provide(FSUtil.defaultLayer))
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const context = yield* SystemContextRegistry.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
Effect.provide(failingFS),
Effect.provide(Global.layerWith({ config: "/global" })),
Effect.provide(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))),
instructionLayer({
config: "/global",
filesystemLayer: failingFS,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
),
}),
),
)
@@ -169,14 +184,18 @@ describe("InstructionContext", () => {
}),
),
),
).pipe(Layer.provide(FSUtil.defaultLayer))
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const context = yield* SystemContextRegistry.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
Effect.provide(racingFS),
Effect.provide(Global.layerWith({ config: "/global" })),
Effect.provide(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))),
instructionLayer({
config: "/global",
filesystemLayer: racingFS,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
),
}),
),
)
@@ -208,20 +227,21 @@ describe("InstructionContext", () => {
}),
),
),
).pipe(Layer.provide(FSUtil.defaultLayer))
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
yield* SystemContextRegistry.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
Effect.provide(observingFS),
Effect.provide(Global.layerWith({ config: "/global" })),
Effect.provide(
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make("/repo/") }, { projectDirectory: AbsolutePath.make("/repo") }),
instructionLayer({
config: "/global",
filesystemLayer: observingFS,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make("/repo/") }, { projectDirectory: AbsolutePath.make("/repo") }),
),
),
),
}),
),
)
@@ -241,18 +261,20 @@ describe("InstructionContext", () => {
yield* SystemContextRegistry.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
Effect.provide(
Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
instructionLayer({
config: "/global",
filesystemLayer: Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))),
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
),
).pipe(Layer.provide(FSUtil.defaultLayer)),
),
Effect.provide(Global.layerWith({ config: "/global" })),
Effect.provide(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))),
}),
),
Effect.ensuring(
Effect.sync(() => {
@@ -271,23 +293,22 @@ describe("InstructionContext", () => {
let scanned = false
yield* SystemContextRegistry.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
Effect.provide(
Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
instructionLayer({
config: "/global",
filesystemLayer: Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))),
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make("/outside") }, { projectDirectory: AbsolutePath.make("/repo") }),
),
),
).pipe(Layer.provide(FSUtil.defaultLayer)),
),
Effect.provide(Global.layerWith({ config: "/global" })),
Effect.provide(
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make("/outside") }, { projectDirectory: AbsolutePath.make("/repo") }),
),
),
}),
),
)
+5 -14
View File
@@ -2,7 +2,6 @@ import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Exit, Layer } from "effect"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Location } from "@opencode-ai/core/location"
@@ -13,20 +12,12 @@ import { it } from "./lib/effect"
const provide = (directory: string) =>
Effect.provide(
LayerNode.compile(
LayerNode.bind(
FileSystem.node,
LayerNode.compile(FileSystem.node, [
[
Location.node,
makeLocationNode({
service: Location.Service,
layer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
),
deps: [],
}),
),
),
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
],
]),
)
const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
+5 -20
View File
@@ -1,12 +1,14 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { DateTime, Effect, Equal, Hash, Layer, Schema } from "effect"
import { DateTime, Effect, Equal, Hash, Schema } from "effect"
import { Tool } from "@opencode-ai/core/tool/tool"
import { define } from "@opencode-ai/plugin/v2/effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Location } from "@opencode-ai/core/location"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ModelV2 } from "@opencode-ai/core/model"
@@ -30,25 +32,8 @@ import { Reference } from "../src/reference"
import { ToolRegistry } from "../src/tool/registry"
import { ApplicationTools } from "../src/tool/application-tools"
const applicationTools = ApplicationTools.layer
const it = testEffect(
Layer.merge(
Layer.mergeAll(applicationTools, Database.defaultLayer, EventV2.defaultLayer),
locationServiceMapLayer.pipe(
Layer.provide(applicationTools),
Layer.provide(
Layer.mergeAll(
Project.defaultLayer,
EventV2.defaultLayer,
Credential.defaultLayer.pipe(Layer.fresh),
Npm.defaultLayer,
ModelsDev.defaultLayer,
FSUtil.defaultLayer,
Global.defaultLayer,
),
),
),
),
AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, Database.node, EventV2.node, LocationServiceMap.node])),
)
describe("LocationServiceMap", () => {
+5 -14
View File
@@ -2,7 +2,6 @@ import fs from "fs/promises"
import path from "path"
import { describe, expect, test } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
@@ -13,20 +12,12 @@ import { it } from "./lib/effect"
function provide(directory: string) {
return Effect.provide(
LayerNode.compile(
LayerNode.bind(
LocationMutation.node,
LayerNode.compile(LocationMutation.node, [
[
Location.node,
makeLocationNode({
service: Location.Service,
layer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
),
deps: [],
}),
),
),
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
],
]),
)
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -21,7 +22,7 @@ const projectLayer = Layer.succeed(
commit: () => Effect.void,
}),
)
const it = testEffect(Location.layer(ref).pipe(Layer.provide(projectLayer)))
const it = testEffect(AppNodeBuilder.build(Location.boundNode(ref), [[Project.node, projectLayer]]))
describe("Location", () => {
it.effect("resolves the current project and vcs information", () =>
+8 -7
View File
@@ -1,11 +1,12 @@
import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test"
import { Effect, Layer, Ref } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { EventV2 } from "@opencode-ai/core/event"
import { it } from "./lib/effect"
import { readFile, rm, writeFile, utimes, mkdir } from "fs/promises"
import path from "path"
@@ -87,13 +88,13 @@ const makeMockClient = (state: Ref.Ref<MockState>) =>
)
const buildLayer = (state: Ref.Ref<MockState>) =>
// Layer.fresh is required: ModelsDev.layer is a module-level Layer constant,
// Layer.fresh is required because the ModelsDev implementation is a module-level Layer constant,
// and Effect.provide uses a process-global MemoMap by default — without fresh,
// every test would reuse the cachedInvalidateWithTTL state from the first run.
Layer.fresh(ModelsDev.layer).pipe(
Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.fresh(
AppNodeBuilder.build(ModelsDev.node, [
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
]),
)
const writeCacheText = (text: string, mtimeMs?: number) =>
+13 -36
View File
@@ -3,57 +3,34 @@ import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { eq } from "drizzle-orm"
import { Effect, Layer } from "effect"
import { Effect } from "effect"
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { Database } from "@opencode-ai/core/database/database"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Git } from "@opencode-ai/core/git"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const project = Project.layer.pipe(
Layer.provide(Database.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(ProjectDirectories.defaultLayer),
)
const sessions = SessionV2.layer.pipe(
Layer.provide(locationServiceMapLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(project),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(SessionExecution.noopLayer),
)
const layer = MoveSession.layer.pipe(
Layer.provide(Database.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(project),
Layer.provide(SessionStore.defaultLayer),
)
const it = testEffect(
Layer.mergeAll(
layer,
Database.defaultLayer,
EventV2.defaultLayer,
ProjectDirectories.defaultLayer,
project,
SessionProjector.defaultLayer,
SessionStore.defaultLayer,
sessions,
AppNodeBuilder.build(
LayerNode.group([
MoveSession.node,
Database.node,
EventV2.node,
ProjectDirectories.node,
Project.node,
SessionProjector.node,
SessionStore.node,
]),
),
)
+3 -10
View File
@@ -1,12 +1,10 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect, test } from "bun:test"
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, Layer, Option } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Effect, Option } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/core/global"
import { Npm } from "@opencode-ai/core/npm"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { tmpdir } from "./fixture/tmpdir"
const win = process.platform === "win32"
@@ -21,12 +19,7 @@ const writePackage = (dir: string, pkg: Record<string, unknown>) =>
)
const npmLayer = (cache: string) =>
Npm.layer.pipe(
Layer.provide(EffectFlock.layer),
Layer.provide(FSUtil.layer),
Layer.provide(Global.layerWith({ cache, state: path.join(cache, "state") })),
Layer.provide(NodeFileSystem.layer),
)
AppNodeBuilder.build(Npm.node, [[Global.node, Global.layerWith({ cache, state: path.join(cache, "state") })]])
describe("Npm.sanitize", () => {
test("keeps normal scoped package specs unchanged", () => {
+7 -19
View File
@@ -2,6 +2,8 @@ import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { PermissionV2 } from "@opencode-ai/core/permission"
@@ -11,9 +13,7 @@ import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionStore } from "@opencode-ai/core/session/store"
import { eq } from "drizzle-orm"
import { location } from "./fixture/location"
@@ -23,24 +23,12 @@ const current = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
)
const sessions = SessionV2.layer.pipe(
Layer.provide(locationServiceMapLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(SessionExecution.noopLayer),
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, EventV2.node, SessionStore.node, PermissionSaved.node, AgentV2.node, PermissionV2.node]),
[[Location.node, current]],
),
)
const layer = PermissionV2.locationLayer.pipe(
Layer.provideMerge(Database.defaultLayer),
Layer.provideMerge(SessionStore.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(current),
Layer.provideMerge(sessions),
Layer.provideMerge(SessionExecution.noopLayer),
Layer.provideMerge(PermissionSaved.defaultLayer),
)
const it = testEffect(layer)
function setup(rules: PermissionV2.Ruleset = []) {
return Effect.gen(function* () {
+3 -5
View File
@@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { CommandV2 } from "@opencode-ai/core/command"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { CommandPlugin } from "@opencode-ai/core/plugin/command"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -10,12 +11,9 @@ import { host } from "./host"
const directory = AbsolutePath.make("/repo/packages/app")
const project = AbsolutePath.make("/repo")
const locationLayer = Layer.succeed(Location.Service, Location.Service.of(location({ directory }, { projectDirectory: project })))
const it = testEffect(
CommandV2.locationLayer.pipe(
Layer.provide(
Layer.succeed(Location.Service, Location.Service.of(location({ directory }, { projectDirectory: project }))),
),
),
AppNodeBuilder.build(CommandV2.node, [[Location.node, locationLayer]]),
)
describe("CommandPlugin.Plugin", () => {
+59 -27
View File
@@ -1,37 +1,69 @@
import { AgentV2 } from "@opencode-ai/core/agent"
import { AISDK } from "@opencode-ai/core/aisdk"
import { Catalog } from "@opencode-ai/core/catalog"
import { CommandV2 } from "@opencode-ai/core/command"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { Npm } from "@opencode-ai/core/npm"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { Reference } from "@opencode-ai/core/reference"
import { SkillV2 } from "@opencode-ai/core/skill"
import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { HttpClient } from "effect/unstable/http"
import { tempLocationLayer } from "../fixture/location"
export const PluginTestLayer = Layer.mergeAll(FileSystem.locationLayer, PluginV2.locationLayer).pipe(
Layer.provideMerge(
Layer.mergeAll(
Credential.defaultLayer,
EventV2.defaultLayer,
FetchHttpClient.layer,
FSUtil.defaultLayer,
Global.defaultLayer,
Layer.succeed(
Npm.Service,
Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
install: () => Effect.void,
which: () => Effect.succeed(undefined),
}),
),
RepositoryCache.defaultLayer,
SkillDiscovery.defaultLayer,
Ripgrep.defaultLayer,
tempLocationLayer,
),
),
type PluginTestServices =
| FileSystem.Service
| FSUtil.Service
| Location.Service
| Npm.Service
| Credential.Service
| EventV2.Service
| AgentV2.Service
| AISDK.Service
| Catalog.Service
| CommandV2.Service
| Integration.Service
| Reference.Service
| SkillV2.Service
| PluginV2.Service
| HttpClient.HttpClient
const npmLayer = Layer.succeed(
Npm.Service,
Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
install: () => Effect.void,
which: () => Effect.succeed(undefined),
}),
)
export const PluginTestLayer = AppNodeBuilder.build(
LayerNode.group([
FileSystem.node,
FSUtil.node,
Location.node,
Npm.node,
Credential.node,
LayerNodePlatform.httpClient,
PluginV2.node,
AgentV2.node,
AISDK.node,
Catalog.node,
CommandV2.node,
Integration.node,
Reference.node,
SkillV2.node,
]),
[
[Location.node, tempLocationLayer],
[Npm.node, npmLayer],
],
) as Layer.Layer<PluginTestServices>
+6 -11
View File
@@ -3,30 +3,25 @@ import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Location } from "@opencode-ai/core/location"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev"
import { Policy } from "@opencode-ai/core/policy"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { catalogHost, host, integrationHost } from "./host"
const events = EventV2.defaultLayer
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })),
)
const policy = Policy.layer.pipe(Layer.provide(locationLayer))
const connections = Credential.defaultLayer.pipe(Layer.fresh)
const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections))
const catalog = Catalog.layer.pipe(
Layer.provide(Layer.mergeAll(events, locationLayer, policy, connections, integrations)),
)
const layer = Layer.mergeAll(catalog.pipe(Layer.provide(connections)), integrations, connections, events, locationLayer)
const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.node, EventV2.node]), [
[Location.node, locationLayer],
])
const it = testEffect(layer)
describe("ModelsDevPlugin", () => {
@@ -65,7 +60,7 @@ describe("ModelsDevPlugin", () => {
connections: [],
}),
])
}).pipe(Effect.provide(ModelsDev.defaultLayer)),
}).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))),
(previous) =>
Effect.sync(() => {
Flag.OPENCODE_MODELS_PATH = previous.path
@@ -6,6 +6,7 @@ import os from "os"
import path from "path"
import { fileURLToPath } from "url"
import { AISDK } from "@opencode-ai/core/aisdk"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
@@ -17,7 +18,7 @@ import { PluginTestLayer } from "./fixture"
const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
const fixtureProviderPath = fileURLToPath(fixtureProvider)
const it = testEffect(PluginTestLayer)
const itWithAISDK = testEffect(AISDK.locationLayer.pipe(Layer.provideMerge(PluginTestLayer)))
const itWithAISDK = testEffect(Layer.mergeAll(PluginTestLayer, AppNodeBuilder.build(AISDK.node)))
function npmEntrypoint(entrypoint?: string) {
return Npm.Service.of({
+3 -15
View File
@@ -1,11 +1,9 @@
import { describe, expect } from "bun:test"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { EventV2 } from "@opencode-ai/core/event"
import { Integration } from "@opencode-ai/core/integration"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { Policy } from "@opencode-ai/core/policy"
import { VariantPlugin } from "@opencode-ai/core/plugin/variant"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -14,21 +12,11 @@ import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { catalogHost, host } from "./host"
const events = EventV2.defaultLayer
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })),
)
const connections = Credential.defaultLayer.pipe(Layer.fresh)
const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections))
const catalog = Catalog.layer.pipe(
Layer.provide(
Layer.mergeAll(events, locationLayer, Policy.layer.pipe(Layer.provide(locationLayer)), connections, integrations),
),
)
const it = testEffect(
Layer.mergeAll(catalog.pipe(Layer.provide(connections)), integrations, connections, events, locationLayer),
)
const it = testEffect(AppNodeBuilder.build(Catalog.node, [[Location.node, locationLayer]]))
describe("VariantPlugin", () => {
it.effect("adds GLM 5.2 variants after catalog sources", () =>
+4 -5
View File
@@ -1,5 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { Policy } from "@opencode-ai/core/policy"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -7,11 +8,9 @@ import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const it = testEffect(
Policy.locationLayer.pipe(
Layer.provide(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
),
),
AppNodeBuilder.build(Policy.node, [
[Location.node, Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") })))],
]),
)
describe("Policy", () => {
+4 -10
View File
@@ -3,9 +3,10 @@ import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { eq } from "drizzle-orm"
import { Effect, Fiber, Layer, Stream } from "effect"
import { Effect, Fiber, Stream } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Git } from "@opencode-ai/core/git"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
@@ -16,15 +17,8 @@ import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const copyLayer = ProjectCopy.layer.pipe(
Layer.provide(Database.defaultLayer),
Layer.provide(ProjectDirectories.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer),
)
const it = testEffect(
Layer.mergeAll(copyLayer, Database.defaultLayer, EventV2.defaultLayer, ProjectDirectories.defaultLayer),
AppNodeBuilder.build(LayerNode.group([ProjectCopy.node, Database.node, EventV2.node, ProjectDirectories.node])),
)
function abs(input: string) {
+12 -11
View File
@@ -1,6 +1,8 @@
import { describe, expect } from "bun:test"
import { Cause, Deferred, Effect, Exit, Layer, Queue } from "effect"
import { Config } from "@opencode-ai/core/config"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { Pty } from "@opencode-ai/core/pty"
@@ -17,11 +19,10 @@ const locationLayer = Layer.succeed(
)
const configLayer = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const it = testEffect(
Pty.layer.pipe(
Layer.provide(configLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer),
),
AppNodeBuilder.build(LayerNode.group([Pty.node, EventV2.node]), [
[Config.node, configLayer],
[Location.node, locationLayer],
]),
)
const ptyTest = process.platform === "win32" ? it.live.skip : it.live
@@ -205,8 +206,9 @@ describe("pty", () => {
const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash")
const configuredIt = testEffect(
Pty.layer.pipe(
Layer.provide(
AppNodeBuilder.build(LayerNode.group([Pty.node, EventV2.node]), [
[
Config.node,
Layer.mock(Config.Service)({
entries: () =>
Effect.succeed(
@@ -215,10 +217,9 @@ const configuredIt = testEffect(
: [],
),
}),
),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer),
),
],
[Location.node, locationLayer],
]),
)
const configuredTest = process.platform === "win32" ? configuredIt.live.skip : configuredIt.live
+5 -2
View File
@@ -1,12 +1,15 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { PtyID } from "@opencode-ai/core/pty/schema"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { testEffect } from "../lib/effect"
const it = testEffect(PtyTicket.layer)
const itExpiring = testEffect(Layer.effect(PtyTicket.Service, PtyTicket.make(5)))
const it = testEffect(LayerNode.compile(PtyTicket.node))
const itExpiring = testEffect(
LayerNode.compile(PtyTicket.node, [[PtyTicket.node, Layer.effect(PtyTicket.Service, PtyTicket.make(5))]]),
)
describe("PTY websocket tickets", () => {
it.live("consumes tickets once", () =>

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