Compare commits

...

17 Commits

Author SHA1 Message Date
Shoubhit Dash 1af665b1c7 refactor(core): move shell tool config into state 2026-08-19 21:38:20 +05:30
Shoubhit Dash 5d453752f0 refactor(core): move location watcher config into state (#43464) 2026-08-19 21:19:32 +05:30
Kit Langton 16b77e5b50 fix(tui): soften jump to latest action (#43457) 2026-08-19 11:41:57 -04:00
Dax c1583ddcbb docs: move CPU profiling to dev skill (#43448) 2026-08-19 11:29:06 -04:00
Kit Langton d8debaa449 fix(tui): stabilize plugin reload generations (#43447) 2026-08-19 15:26:54 +00:00
Shoubhit Dash ace822308f refactor(core): move compaction config into state (#43442) 2026-08-19 20:43:01 +05:30
opencode-agent[bot] 728053b645 fix(tui): avoid retrying broken plugin setup (#43441)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-08-19 20:41:13 +05:30
opencode-agent[bot] bfe9917ee7 fix(core): lower AI SDK system updates (#43440)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-19 10:03:03 -05:00
opencode-agent[bot] 1556b74082 fix(core): preserve Vertex billing labels (#43449)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-19 09:57:47 -05:00
Dax 7700faad81 feat(cli): capture CPU profiles with SIGPROF (#43446) 2026-08-19 10:45:05 -04:00
Kit Langton 241a88a5d9 fix(tui): open subagent panel from footer (#43325) 2026-08-19 10:33:44 -04:00
Major Hayden 3edcb3ca2b fix(core): route Vertex Gemini through native provider (#43433)
Signed-off-by: Major Hayden <major@mhtx.net>
2026-08-19 08:59:11 -05:00
opencode-agent[bot] dcc7de2e47 test(app): stabilize reconnect offset timing (#43438)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-08-19 19:27:34 +05:30
Shoubhit Dash c40c306170 refactor(core): move shell config into state (#43430) 2026-08-19 19:23:22 +05:30
Shoubhit Dash a207253242 fix(core): await snapshot state readiness (#43435) 2026-08-19 19:23:10 +05:30
Shoubhit Dash 1e867c228a refactor(core): move snapshot config into state (#43425) 2026-08-19 18:34:54 +05:30
Shoubhit Dash 8a402d3f03 refactor(core): move tool output config into state (#43422) 2026-08-19 18:25:22 +05:30
61 changed files with 1599 additions and 404 deletions
+27
View File
@@ -185,6 +185,33 @@ pmap -x <pid> | sort -k3 -nr | head -25
Heap serialization itself can temporarily increase RSS and allocator high-water marks, so record `ps`/`smaps_rollup` both before and after capture. Large anonymous mappings with a comparatively small live heap require native-allocation or allocator investigation; they cannot be explained from JavaScript retainer paths alone.
## CPU profiles
The CLI installs a `SIGPROF` listener on non-Windows processes in `packages/cli/src/cpu-profile.ts`. One signal starts a ten-second CPU profile and stops it automatically; additional signals are ignored while a profile is active. There is no CPU profile CLI flag or environment variable.
1. Get the PID from the health endpoint. For shared-service performance, target the server PID returned here rather than the short wrapper or TUI process:
```bash
opencode2 api get /api/health
```
Use `bun dev api get /api/health` instead when targeting the local/dev channel.
2. Start the capture:
```bash
kill -PROF <server-pid>
```
3. Wait for `CPU profile written` in the channel's log before opening the file. Profiles are written to the same log directory as `cpu-<pid>-<timestamp>.cpuprofile`; the log's `path=` field is authoritative:
```bash
grep 'CPU profile' ~/.local/share/opencode/log/opencode.log | tail
find ~/.local/share/opencode/log -maxdepth 1 -name 'cpu-<server-pid>-*.cpuprofile' -printf '%T@ %s %p\n' | sort -nr | head
```
Use `opencode-local.log` for a local/dev process. Load the completed `.cpuprofile` in Chrome DevTools or another V8 CPU profile viewer and inspect the hottest functions, call stacks, and self time during the controlled workload.
## 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:
@@ -62,13 +62,16 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
})
test("keeps checking until stale reset-delay callbacks can no longer win", async () => {
const route = document.createElement("section")
const viewport = document.createElement("div")
const targetWindow = new Window()
const mutations = controlledMutations(targetWindow)
const animation = controlledAnimationFrames(targetWindow)
const route = targetWindow.document.createElement("section")
const viewport = targetWindow.document.createElement("div")
route.append(viewport)
document.body.append(route)
targetWindow.document.body.append(route)
const instance = {
scrollElement: viewport,
targetWindow: window,
targetWindow,
scrollOffset: 79_400,
options: {
horizontal: false,
@@ -83,20 +86,23 @@ test("keeps checking until stale reset-delay callbacks can no longer win", async
instance.scrollOffset = offset
})
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
await frames(1)
expect(instance.scrollOffset).toBe(0)
try {
mutations.remove(route)
mutations.append(targetWindow.document.body, route)
animation.run(16)
expect(instance.scrollOffset).toBe(0)
instance.scrollOffset = 79_400
await new Promise((resolve) => setTimeout(resolve, 25))
await frames(3)
instance.scrollOffset = 79_400
animation.run(32)
animation.run(48)
expect(instance.scrollOffset).toBe(0)
expect(calls).toEqual([0, 0])
cleanup?.()
route.remove()
expect(instance.scrollOffset).toBe(0)
expect(calls).toEqual([0, 0])
expect(animation.pending()).toBe(0)
} finally {
cleanup?.()
await targetWindow.happyDOM.close()
}
})
test.each([
@@ -235,3 +241,29 @@ function controlledMutations(targetWindow: Window) {
},
}
}
function controlledAnimationFrames(targetWindow: Window) {
let time = 0
let id = 0
const callbacks = new Map<number, FrameRequestCallback>()
Object.defineProperty(targetWindow.performance, "now", { value: () => time })
Object.defineProperty(targetWindow, "requestAnimationFrame", {
value: (callback: FrameRequestCallback) => {
id += 1
callbacks.set(id, callback)
return id
},
})
Object.defineProperty(targetWindow, "cancelAnimationFrame", {
value: (frame: number) => callbacks.delete(frame),
})
return {
run(at: number) {
time = at
const pending = [...callbacks.values()]
callbacks.clear()
pending.forEach((callback) => callback(at))
},
pending: () => callbacks.size,
}
}
+2 -3
View File
@@ -1,6 +1,5 @@
import { Argument, Command, Flag } from "effect/unstable/cli"
import { Argument, Flag } from "effect/unstable/cli"
import { Spec } from "../framework/spec"
import { GlobalFlags } from "./global-flags"
declare const OPENCODE_CLI_NAME: string | undefined
@@ -343,4 +342,4 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
],
})
export const Commands = { ...Root, spec: Root.spec.pipe(Command.withGlobalFlags(GlobalFlags.all)) }
export const Commands = Root
-12
View File
@@ -1,12 +0,0 @@
export * as GlobalFlags from "./global-flags"
import { Flag, GlobalFlag } from "effect/unstable/cli"
export const CpuProfile = GlobalFlag.setting("cpu-profile")({
flag: Flag.string("cpu-profile").pipe(
Flag.withDescription("Write a CPU profile to this path when the process stops"),
Flag.optional,
),
})
export const all = [CpuProfile] as const
+28 -2
View File
@@ -1,10 +1,36 @@
export * as CpuProfile from "./cpu-profile"
import { Effect, FileSystem } from "effect"
import { Global } from "@opencode-ai/util/global"
import { Effect, FileSystem, Queue } from "effect"
import { Session } from "node:inspector"
import path from "node:path"
export function run<A, E, R>(file: string, effect: Effect.Effect<A, E, R>) {
export const listen = Effect.gen(function* () {
const global = yield* Global.Service
if (process.platform === "win32") return
const signals = yield* Queue.dropping<void>(1)
yield* Effect.acquireRelease(
Effect.sync(() => {
const handler = () => Queue.offerUnsafe(signals, undefined)
process.on("SIGPROF", handler)
return handler
}),
(handler) => Effect.sync(() => process.off("SIGPROF", handler)),
)
yield* Effect.gen(function* () {
yield* Queue.take(signals)
const file = path.join(
global.log,
`cpu-${process.pid}-${new Date().toISOString().replace(/[:.]/g, "")}.cpuprofile`,
)
yield* run(file, Effect.sleep("10 seconds")).pipe(
Effect.catchCause((cause) => Effect.logError("Failed to capture CPU profile", { path: file, cause })),
)
yield* Queue.poll(signals)
}).pipe(Effect.forever, Effect.forkScoped({ startImmediately: true }))
})
function run<A, E, R>(file: string, effect: Effect.Effect<A, E, R>) {
const target = path.resolve(file)
return Effect.acquireUseRelease(
Effect.gen(function* () {
+2 -19
View File
@@ -1,13 +1,10 @@
import { Effect, FileSystem, Option, Scope } from "effect"
import { Effect, FileSystem, Scope } from "effect"
import { Command } from "effect/unstable/cli"
import { Spec } from "./spec"
import { Global } from "@opencode-ai/util/global"
import { Updater } from "../services/updater"
import { Config } from "../config"
import { Npm } from "@opencode-ai/util/npm"
import { GlobalFlags } from "../commands/global-flags"
import { CpuProfile } from "../cpu-profile"
import path from "node:path"
export type Input<Value> =
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
@@ -90,21 +87,7 @@ function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): Provided
Command.withHandler((input) =>
Effect.gen(function* () {
const module = yield* Effect.promise(handler.load)
const cpuProfile = Option.getOrUndefined(yield* GlobalFlags.CpuProfile)
if (!cpuProfile) return yield* module.default(input)
const target = path.resolve(cpuProfile)
const previous = process.env.OPENCODE_CPU_PROFILE
process.env.OPENCODE_CPU_PROFILE = target
return yield* (
node.name === "serve" ? CpuProfile.run(target, module.default(input)) : module.default(input)
).pipe(
Effect.ensuring(
Effect.sync(() => {
if (previous === undefined) delete process.env.OPENCODE_CPU_PROFILE
else process.env.OPENCODE_CPU_PROFILE = previous
}),
),
)
return yield* module.default(input)
}),
),
)
+2
View File
@@ -13,6 +13,7 @@ import { AppProcess } from "@opencode-ai/util/process"
import { Config } from "./config"
import { Npm } from "@opencode-ai/util/npm"
import { Heap } from "./heap"
import { CpuProfile } from "./cpu-profile"
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
@@ -61,6 +62,7 @@ const Handlers = Runtime.handlers(Commands, {
Effect.gen(function* () {
yield* Heap.listen
yield* CpuProfile.listen
const runFork = Effect.runForkWith(yield* Effect.context<never>())
const uncaughtException = (cause: Error, origin: "uncaughtException" | "unhandledRejection") => {
runFork(Effect.logError("uncaught exception", { cause, origin }))
@@ -110,7 +110,6 @@ export const options = Effect.fnUntraced(function* (input: { readonly checkVersi
...selfCommand(),
"serve",
"--service",
...(process.env.OPENCODE_CPU_PROFILE ? ["--cpu-profile", process.env.OPENCODE_CPU_PROFILE] : []),
],
}
})
+18
View File
@@ -0,0 +1,18 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/util/global"
import { expect, test } from "bun:test"
import { Effect } from "effect"
import { CpuProfile } from "../src/cpu-profile"
test("subscribes and unsubscribes SIGPROF with the CLI scope", async () => {
const listeners = process.listenerCount("SIGPROF")
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
yield* CpuProfile.listen
expect(process.listenerCount("SIGPROF")).toBe(listeners + (process.platform === "win32" ? 0 : 1))
}),
).pipe(Effect.provideService(Global.Service, Global.make()), Effect.provide(NodeFileSystem.layer)),
)
expect(process.listenerCount("SIGPROF")).toBe(listeners)
})
-23
View File
@@ -19,29 +19,6 @@ test("managed service ports are stable per installation channel", () => {
expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b"))
})
test("managed service forwards the CPU profile path to the server", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-profile-"))
const profile = path.join(root, "server.cpuprofile")
try {
const previous = process.env.OPENCODE_CPU_PROFILE
process.env.OPENCODE_CPU_PROFILE = profile
try {
const options = await Effect.runPromise(
ServiceConfig.options().pipe(
Effect.provide(Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })),
Effect.provide(NodeFileSystem.layer),
),
)
expect(options.command.slice(-2)).toEqual(["--cpu-profile", profile])
} finally {
if (previous === undefined) delete process.env.OPENCODE_CPU_PROFILE
else process.env.OPENCODE_CPU_PROFILE = previous
}
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
test("local channel stores service config with the local service filename", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-"))
try {
+18 -1
View File
@@ -51,6 +51,22 @@ export function map(input: MapInput): Mapping | undefined {
...mapGoogleOptions(input.settings),
},
}
case "@ai-sdk/google-vertex":
return {
package: "@opencode-ai/ai/providers/google-vertex",
settings: {
...baseSettings,
...(typeof input.settings.accessToken === "string" ? { accessToken: input.settings.accessToken } : {}),
...mapAPIKey(input.settings),
...(typeof input.settings.location === "string" ? { location: input.settings.location } : {}),
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
...mapGoogleOptions(
input.settings,
isStringRecord(input.settings.labels) ? { labels: input.settings.labels } : {},
),
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
case "@ai-sdk/google-vertex/anthropic":
return {
package: "@opencode-ai/ai/providers/google-vertex/messages",
@@ -229,7 +245,7 @@ function mapAPIKey(settings: Readonly<Record<string, unknown>>) {
return typeof settings.apiKey === "string" ? { apiKey: settings.apiKey } : {}
}
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>, extra: Readonly<Record<string, unknown>> = {}) {
const input = settings.thinkingConfig
const thinkingConfig = {
...(isRecord(input) && typeof input.thinkingBudget === "number" ? { thinkingBudget: input.thinkingBudget } : {}),
@@ -243,6 +259,7 @@ function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
...(Array.isArray(settings.safetySettings) ? { safetySettings: settings.safetySettings } : {}),
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
...(Object.keys(thinkingConfig).length > 0 ? { thinkingConfig } : {}),
...extra,
}
if (Object.keys(options).length === 0) return {}
return { providerOptions: { gemini: options } }
+14 -1
View File
@@ -460,7 +460,20 @@ function prompt(request: LLMRequest): LanguageModelV3Prompt {
function message(input: LLMRequest["messages"][number]): LanguageModelV3Message[] {
switch (input.role) {
case "system":
return [{ role: "system", content: input.content.flatMap(text).join("\n\n") }]
// The initial privileged prompt lives in `request.system` and is prepended above. A system message here is a
// chronological instruction update, but opaque AI SDK providers do not uniformly allow the system role after
// conversation history, so preserve its position using the safe wrapped-user fallback.
return [
{
role: "user",
content: [
{
type: "text",
text: ProviderShared.wrapSystemUpdate(input.content.filter((part) => part.type === "text")),
},
],
},
]
case "user":
return [{ role: "user", content: input.content.flatMap(userPart) }]
case "assistant":
+11 -28
View File
@@ -8,10 +8,8 @@ import { MCP } from "./mcp/index.js"
import { Bus } from "./bus.js"
import { AppProcess } from "@opencode-ai/util/process"
import { ChildProcess } from "effect/unstable/process"
import { Config } from "./config.js"
import { Location } from "./location.js"
import { ShellSelect } from "./shell/select.js"
import { Global } from "@opencode-ai/util/global"
export const Info = Command.Info
export type Info = Command.Info
@@ -53,16 +51,15 @@ export interface Interface extends State.Transformable<Draft> {
export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
export const layer = (options?: ShellSelect.Options) =>
const layer = () =>
Layer.effect(
Service,
Effect.gen(function* () {
const mcp = yield* MCP.Service
const bus = yield* Bus.Service
const processes = yield* AppProcess.Service
const config = yield* Config.Service
const location = yield* Location.Service
const global = yield* Global.Service
const shell = yield* ShellSelect.Service
const state = State.create<Data, Draft>({
name: "command",
initial: () => ({ commands: new Map() }),
@@ -109,11 +106,9 @@ export const layer = (options?: ShellSelect.Options) =>
const command = staticCommand(input.name)
if (command)
return yield* evaluateTemplate(input.name, command.template, input.arguments ?? "", {
config,
location,
processes,
shell: options,
bin: global.bin,
shell,
})
const prompt = (yield* mcp.prompts()).find(
@@ -163,11 +158,9 @@ function evaluateTemplate(
template: string,
input: string,
services: {
readonly config: Config.Interface
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell?: ShellSelect.Options
readonly bin: string
readonly shell: ShellSelect.Interface
},
) {
return Effect.gen(function* () {
@@ -197,20 +190,14 @@ const evaluateShell = Effect.fnUntraced(function* (
command: string,
text: string,
services: {
readonly config: Config.Interface
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell?: ShellSelect.Options
readonly bin: string
readonly shell: ShellSelect.Interface
},
) {
const matches = Array.from(text.matchAll(shellRegex))
if (matches.length === 0) return text
const shell = ShellSelect.preferred(
Config.latest(yield* services.config.entries(), "shell"),
services.shell,
services.bin,
)
const shell = yield* services.shell.preferred()
const outputs = yield* Effect.forEach(
matches,
(match) => {
@@ -267,12 +254,8 @@ const placeholderRegex = /\$(\d+)/g
const quoteTrimRegex = /^["']|["']$/g
const shellRegex = /!`([^`]+)`/g
export function configured(options?: ShellSelect.Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [MCP.node, Bus.node, AppProcess.node, Config.node, Location.node, Global.node],
})
}
export const node = configured()
export const node = makeLocationNode({
service: Service,
layer: layer(),
deps: [MCP.node, Bus.node, AppProcess.node, Location.node, ShellSelect.node],
})
@@ -0,0 +1,37 @@
export * as ConfigCompactionPlugin from "./compaction.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { SessionCompaction } from "../../session/compaction.js"
export const Plugin = define({
id: "opencode.config.compaction",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const compaction = yield* SessionCompaction.Service
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(compaction.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.entries = yield* config.entries()
yield* compaction.transform((draft) => {
for (const entry of loaded.entries) {
if (entry.type !== "document" || !entry.info.compaction) continue
draft.configure({
...(entry.info.compaction.auto === undefined ? {} : { auto: entry.info.compaction.auto }),
...(entry.info.compaction.buffer === undefined ? {} : { buffer: entry.info.compaction.buffer }),
...(entry.info.compaction.keep?.tokens === undefined
? {}
: { tokens: entry.info.compaction.keep.tokens }),
})
}
})
}),
})
@@ -0,0 +1,31 @@
export * as ConfigLocationWatcherPlugin from "./location-watcher.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { LocationWatcherPolicy } from "../../filesystem/location-watcher-policy.js"
export const Plugin = define({
id: "opencode.config.location-watcher",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const policy = yield* LocationWatcherPolicy.Service
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(policy.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.entries = yield* config.entries()
yield* policy.transform((draft) => {
for (const entry of loaded.entries) {
if (entry.type !== "document" || !entry.info.watcher?.ignore) continue
draft.add(entry.info.watcher.ignore)
}
})
}),
})
+35
View File
@@ -0,0 +1,35 @@
export * as ConfigShellPlugin from "./shell.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { ShellPolicy } from "../../shell/policy.js"
import { ShellSelect } from "../../shell/select.js"
export const Plugin = define({
id: "opencode.config.shell",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const shell = yield* ShellSelect.Service
const policy = yield* ShellPolicy.Service
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(Effect.all([shell.reload(), policy.reload()], { concurrency: "unbounded", discard: true })),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.entries = yield* config.entries()
yield* shell.transform((draft) => {
const configured = Config.latest(loaded.entries, "shell")
if (configured) draft.configure(configured)
})
yield* policy.transform((draft) => {
const configured = Config.latest(loaded.entries, "experimental")?.portable_shell_scanner
if (configured !== undefined) draft.configure(configured)
})
}),
})
@@ -0,0 +1,30 @@
export * as ConfigSnapshotPlugin from "./snapshot.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { Snapshot } from "../../snapshot.js"
export const Plugin = define({
id: "opencode.config.snapshot",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const snapshot = yield* Snapshot.Service
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(snapshot.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.entries = yield* config.entries()
yield* snapshot.transform((draft) => {
const configured = Config.latest(loaded.entries, "snapshots")
if (configured === undefined) return
draft.configure(configured)
})
}),
})
@@ -0,0 +1,33 @@
export * as ConfigToolOutputPlugin from "./tool-output.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { ToolOutput } from "../../tool-output.js"
export const Plugin = define({
id: "opencode.config.tool-output",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const output = yield* ToolOutput.Service
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(output.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.entries = yield* config.entries()
yield* output.transform((draft) => {
const configured = Config.latest(loaded.entries, "tool_output")
if (!configured) return
draft.configure({
...(configured.max_lines === undefined ? {} : { maxLines: configured.max_lines }),
...(configured.max_bytes === undefined ? {} : { maxBytes: configured.max_bytes }),
})
})
}),
})
@@ -0,0 +1,65 @@
export * as LocationWatcherPolicy from "./location-watcher-policy.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Scope } from "effect"
import { State } from "../state.js"
type Data = {
ignore: string[]
}
export type Draft = {
add: (ignore: readonly string[]) => void
list: () => readonly string[]
}
export interface Interface extends State.Transformable<Draft> {
readonly current: () => readonly string[]
readonly observe: (
listener: (ignore: readonly string[]) => Effect.Effect<void>,
) => Effect.Effect<State.Registration, never, Scope.Scope>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationWatcherPolicy") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
let current: readonly string[] = []
const listeners = new Set<(ignore: readonly string[]) => Effect.Effect<void>>()
const state = State.create<Data, Draft>({
name: "location-watcher-policy",
initial: () => ({ ignore: [] }),
draft: (draft) => ({
add: (ignore) => draft.ignore.push(...ignore),
list: () => draft.ignore,
}),
finalize: (draft) =>
Effect.sync(() => {
current = [...draft.list()]
}).pipe(Effect.andThen(Effect.forEach(listeners, (listener) => listener(current), { discard: true }))),
})
const observe = Effect.fn("LocationWatcherPolicy.observe")(function* (
listener: (ignore: readonly string[]) => Effect.Effect<void>,
) {
const scope = yield* Scope.Scope
let active = true
const dispose = Effect.sync(() => {
if (!active) return
active = false
listeners.delete(listener)
})
listeners.add(listener)
yield* Scope.addFinalizer(scope, dispose)
return { dispose }
})
return Service.of({
transform: state.transform,
reload: state.reload,
current: () => current,
observe,
})
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [] })
@@ -1,15 +1,15 @@
export * as LocationWatcher from "./location-watcher.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Stream } from "effect"
import { Cause, Context, Effect, Exit, Layer, Scope, Semaphore, Stream } from "effect"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Document } from "@opencode-ai/schema/config"
import path from "path"
import { Config } from "../config.js"
import { Bus } from "../bus.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "../git.js"
import { Location } from "../location.js"
import { PluginSupervisor } from "../plugin/supervisor.js"
import { LocationWatcherPolicy } from "./location-watcher-policy.js"
import { Watcher } from "./watcher.js"
export interface Interface {}
@@ -24,42 +24,86 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const configService = yield* Config.Service
const plugins = yield* PluginSupervisor.Service
const policy = yield* LocationWatcherPolicy.Service
const publish = (update: { type: "create" | "update" | "delete"; path: string }) =>
bus.publish(FileSystem.Event.Changed, {
file: update.path,
event: update.type === "create" ? "add" : update.type === "update" ? "change" : "unlink",
})
yield* Effect.gen(function* () {
const config = (yield* configService.entries())
.filter((entry): entry is Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
if (location.vcs?.type === "git") {
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
const vcs = resolved
? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved)))
: undefined
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
const updates = yield* watcher.subscribe({ path: path.join(vcs, "HEAD"), type: "file" })
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
const target = yield* Effect.cached(
Effect.gen(function* () {
if (location.vcs?.type === "git") {
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
const vcs = resolved
? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved)))
: undefined
if (vcs) return { path: path.join(vcs, "HEAD"), aliases: [".git", vcs, ...(resolved ? [resolved] : [])] }
}
}
if (location.vcs?.type === "hg") {
const store = location.vcs.store
const vcs = yield* fs.realPath(store).pipe(Effect.catch(() => Effect.succeed(store)))
if (!config.includes(".hg") && !config.includes(vcs)) {
const updates = yield* watcher.subscribe({ path: path.join(vcs, "branch"), type: "file" })
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
if (location.vcs?.type === "hg") {
const store = location.vcs.store
const vcs = yield* fs.realPath(store).pipe(Effect.catch(() => Effect.succeed(store)))
return { path: path.join(vcs, "branch"), aliases: [".hg", vcs] }
}
}
}).pipe(
Effect.withSpan("LocationWatcher.start", { attributes: { directory: location.directory } }),
Effect.catchCause((cause) => Effect.logError("failed to init location watcher service", { cause })),
Effect.forkScoped,
}).pipe(
Effect.withSpan("LocationWatcher.target", { attributes: { directory: location.directory } }),
Effect.catchCause((cause) =>
Effect.logError("failed to resolve location watcher target", { cause }).pipe(Effect.as(undefined)),
),
),
)
const lock = Semaphore.makeUnsafe(1)
let requested = 0
let stopped = false
let active: { path: string; scope: Scope.Closeable } | undefined
const reconcile = (ignore: readonly string[]) => {
const request = ++requested
return lock.withPermit(
Effect.gen(function* () {
if (stopped || request !== requested) return
const resolved = yield* target
if (stopped || request !== requested) return
const next = resolved && !resolved.aliases.some((alias) => ignore.includes(alias)) ? resolved.path : undefined
if (active?.path === next) return
if (active) yield* Scope.close(active.scope, Exit.void)
active = undefined
if (!next) return
const scope = yield* Scope.make()
active = { path: next, scope }
yield* Effect.gen(function* () {
const updates = yield* watcher.subscribe({ path: next, type: "file" })
yield* Stream.runForEach(updates, publish)
}).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause),
(cause) => Effect.logError("location watcher subscription failed", { path: next, cause }),
),
Effect.forkIn(scope, { startImmediately: true }),
)
}).pipe(Effect.withSpan("LocationWatcher.reconcile", { attributes: { directory: location.directory } })),
)
}
yield* Effect.addFinalizer(() =>
lock.withPermit(
Effect.gen(function* () {
stopped = true
requested++
if (active) yield* Scope.close(active.scope, Exit.void)
active = undefined
}),
),
)
yield* policy.observe(reconcile)
yield* Effect.gen(function* () {
yield* plugins.flush
yield* reconcile(policy.current())
}).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause),
(cause) => Effect.logError("failed to start location watcher", { cause }),
),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({})
}),
)
@@ -67,5 +111,13 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Watcher.node, FSUtil.node, Location.node, Config.node, Git.node, Bus.node],
deps: [
Watcher.node,
FSUtil.node,
Location.node,
Git.node,
Bus.node,
PluginSupervisor.node,
LocationWatcherPolicy.node,
],
})
+4
View File
@@ -29,6 +29,8 @@ import { PluginSupervisor } from "./plugin/supervisor.js"
import { Worktree } from "./worktree.js"
import { Pty } from "./pty.js"
import { Shell } from "./shell.js"
import { ShellPolicy } from "./shell/policy.js"
import { ShellSelect } from "./shell/select.js"
import { Reference } from "./reference.js"
import { WebSearch } from "./websearch.js"
import { ReferenceInstructions } from "./reference/instructions.js"
@@ -71,6 +73,8 @@ const locationServiceNodes = [
Worktree.refreshNode,
FileSystemSearch.node,
FileSystem.node,
ShellPolicy.node,
ShellSelect.node,
Pty.node,
Shell.node,
Skill.node,
+34
View File
@@ -13,14 +13,19 @@ import { Config } from "../config.js"
import { Credential } from "../credential.js"
import { ConfigAgentPlugin } from "../config/plugin/agent.js"
import { ConfigCommandPlugin } from "../config/plugin/command.js"
import { ConfigCompactionPlugin } from "../config/plugin/compaction.js"
import { ConfigFormatterPlugin } from "../config/plugin/formatter.js"
import { ConfigImagePlugin } from "../config/plugin/image.js"
import { ConfigInstructionPlugin } from "../config/plugin/instruction.js"
import { ConfigLocationWatcherPlugin } from "../config/plugin/location-watcher.js"
import { ConfigMCPPlugin } from "../config/plugin/mcp.js"
import { ConfigProviderPlugin } from "../config/plugin/provider.js"
import { ConfigPolicyPlugin } from "../config/plugin/policy.js"
import { ConfigReferencePlugin } from "../config/plugin/reference.js"
import { ConfigShellPlugin } from "../config/plugin/shell.js"
import { ConfigSnapshotPlugin } from "../config/plugin/snapshot.js"
import { ConfigSkillPlugin } from "../config/plugin/skill.js"
import { ConfigToolOutputPlugin } from "../config/plugin/tool-output.js"
import { ConfigPluginSource } from "../config/plugin/source.js"
import { ConfigWebSearchPlugin } from "../config/plugin/websearch.js"
import { Bus } from "../bus.js"
@@ -29,6 +34,7 @@ import { FileMutation } from "../file-mutation.js"
import { Formatter } from "../formatter.js"
import { Form } from "../form.js"
import { FileSystem } from "../filesystem.js"
import { LocationWatcherPolicy } from "../filesystem/location-watcher-policy.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Image } from "../image.js"
@@ -44,8 +50,12 @@ import { Permission } from "../permission.js"
import { Reference } from "../reference.js"
import { WebSearch } from "../websearch.js"
import { Ripgrep } from "../ripgrep.js"
import { SessionCompaction } from "../session/compaction.js"
import { SessionInstructions } from "../session/instructions.js"
import { Shell } from "../shell.js"
import { ShellPolicy } from "../shell/policy.js"
import { ShellSelect } from "../shell/select.js"
import { Snapshot } from "../snapshot.js"
import { Skill } from "../skill.js"
import { SkillDiscovery } from "../skill/discovery.js"
import { Watcher } from "../filesystem/watcher.js"
@@ -60,6 +70,7 @@ import { ShellTool } from "../tool/plugin/shell.js"
import { SkillTool } from "../tool/plugin/skill.js"
import { SubagentTool } from "../tool/plugin/subagent.js"
import { Tool } from "../tool.js"
import { ToolOutput } from "../tool-output.js"
import { WebFetchTool } from "../tool/plugin/webfetch.js"
import { WebSearchTool } from "../tool/plugin/websearch.js"
import { WellKnown } from "../wellknown.js"
@@ -90,6 +101,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const environment = yield* Environment.Service
const mutation = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const locationWatcherPolicy = yield* LocationWatcherPolicy.Service
const filesystem = yield* FileSystem.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
@@ -110,11 +122,16 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const reference = yield* Reference.Service
const websearch = yield* WebSearch.Service
const ripgrep = yield* Ripgrep.Service
const compaction = yield* SessionCompaction.Service
const instructions = yield* SessionInstructions.Service
const shell = yield* Shell.Service
const shellPolicy = yield* ShellPolicy.Service
const shellSelect = yield* ShellSelect.Service
const snapshot = yield* Snapshot.Service
const skill = yield* Skill.Service
const skillDiscovery = yield* SkillDiscovery.Service
const tools = yield* Tool.Service
const toolOutput = yield* ToolOutput.Service
const watcher = yield* Watcher.Service
const wellknown = yield* WellKnown.Service
return Context.mergeAll(
@@ -129,6 +146,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Environment.Service, environment),
Context.make(FileMutation.Service, mutation),
Context.make(Formatter.Service, formatter),
Context.make(LocationWatcherPolicy.Service, locationWatcherPolicy),
Context.make(FileSystem.Service, filesystem),
Context.make(FSUtil.Service, fs),
Context.make(Global.Service, global),
@@ -149,11 +167,16 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Reference.Service, reference),
Context.make(WebSearch.Service, websearch),
Context.make(Ripgrep.Service, ripgrep),
Context.make(SessionCompaction.Service, compaction),
Context.make(SessionInstructions.Service, instructions),
Context.make(Shell.Service, shell),
Context.make(ShellPolicy.Service, shellPolicy),
Context.make(ShellSelect.Service, shellSelect),
Context.make(Snapshot.Service, snapshot),
Context.make(Skill.Service, skill),
Context.make(SkillDiscovery.Service, skillDiscovery),
Context.make(Tool.Service, tools),
Context.make(ToolOutput.Service, toolOutput),
Context.make(Watcher.Service, watcher),
Context.make(WellKnown.Service, wellknown),
)
@@ -175,6 +198,7 @@ export const requirements = LayerNode.group([
Environment.node,
FileMutation.node,
Formatter.node,
LocationWatcherPolicy.node,
FileSystem.node,
FSUtil.node,
Global.node,
@@ -195,11 +219,16 @@ export const requirements = LayerNode.group([
Reference.node,
WebSearch.node,
Ripgrep.node,
SessionCompaction.node,
SessionInstructions.node,
Shell.node,
ShellPolicy.node,
ShellSelect.node,
Snapshot.node,
Skill.node,
SkillDiscovery.node,
Tool.node,
ToolOutput.node,
Watcher.node,
WellKnown.node,
])
@@ -238,8 +267,13 @@ const post = [
ConfigReferencePlugin.Plugin,
ConfigAgentPlugin.Plugin,
ConfigCommandPlugin.Plugin,
ConfigCompactionPlugin.Plugin,
ConfigFormatterPlugin.Plugin,
ConfigImagePlugin.Plugin,
ConfigLocationWatcherPlugin.Plugin,
ConfigShellPlugin.Plugin,
ConfigSnapshotPlugin.Plugin,
ConfigToolOutputPlugin.Plugin,
ConfigSkillPlugin.Plugin,
ConfigProviderPlugin.Plugin,
ConfigWebSearchPlugin.Plugin,
+8 -16
View File
@@ -4,12 +4,10 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import type { Disp, Proc } from "#pty"
import { Context, Effect, Layer, Schema, Types } from "effect"
import { Pty } from "@opencode-ai/schema/pty"
import { Config } from "./config.js"
import { Bus } from "./bus.js"
import { Location } from "./location.js"
import { PtyID } from "./pty/schema.js"
import { ShellSelect } from "./shell/select.js"
import { Global } from "@opencode-ai/util/global"
import { lazy } from "./util/lazy.js"
const BUFFER_LIMIT = 1024 * 1024 * 2
@@ -90,14 +88,13 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Pty") {}
export const layer = (options?: ShellSelect.Options) =>
const layer = () =>
Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const location = yield* Location.Service
const config = yield* Config.Service
const global = yield* Global.Service
const shell = yield* ShellSelect.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<PtyID, Active>()
@@ -167,8 +164,7 @@ export const layer = (options?: ShellSelect.Options) =>
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
const id = PtyID.ascending()
const command =
input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"), options, global.bin)
const command = input.command || (yield* shell.preferred())
const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
const cwd = input.cwd || location.directory
const env = {
@@ -317,12 +313,8 @@ export const layer = (options?: ShellSelect.Options) =>
}),
)
export function configured(options?: ShellSelect.Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [Bus.node, Location.node, Config.node, Global.node],
})
}
export const node = configured()
export const node = makeLocationNode({
service: Service,
layer: layer(),
deps: [Bus.node, Location.node, ShellSelect.node],
})
+20 -10
View File
@@ -628,7 +628,11 @@ const layer = Layer.effect(
}),
command: Effect.fn("Session.command")(function* (input) {
const session = yield* result.get(input.sessionID)
const commands = yield* Command.Service.pipe(Effect.provide(locations.get(session.location)))
const commands = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Command.Service
}).pipe(Effect.provide(locations.get(session.location)))
const command = yield* commands.get(input.command)
if (!command)
return yield* new Command.NotFoundError({
@@ -667,6 +671,8 @@ const layer = Layer.effect(
activeShells.add(input.sessionID)
yield* execution.awaitIdle(input.sessionID)
const started = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
const shell = yield* Shell.Service
return yield* shell
.create({
@@ -905,19 +911,23 @@ const layer = Layer.effect(
const session = yield* result.get(input.sessionID)
if ((yield* execution.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(Bus.Service, bus),
Effect.provide(locations.get(session.location)),
)
return yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
Effect.provideService(Database.Service, database),
Effect.provideService(Bus.Service, bus),
)
}).pipe(Effect.provide(locations.get(session.location)))
}),
clear: Effect.fn("Session.revert.clear")(function* (sessionID) {
const session = yield* result.get(sessionID)
if ((yield* execution.active).has(sessionID)) return yield* new BusyError({ sessionID })
const revert = yield* SessionRevert.clear(session).pipe(
Effect.provideService(Bus.Service, bus),
Effect.provide(locations.get(session.location)),
)
const revert = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* SessionRevert.clear(session).pipe(Effect.provideService(Bus.Service, bus))
}).pipe(Effect.provide(locations.get(session.location)))
yield* execution.wake(sessionID)
return revert
}),
+28 -25
View File
@@ -3,9 +3,7 @@ export * as SessionCompaction from "./compaction.js"
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest, type LanguageModel } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Context, Effect, Layer, Stream } from "effect"
import { Config } from "../config.js"
import { Bus } from "../bus.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { llmClient } from "../effect/app-node-platform.js"
@@ -24,6 +22,7 @@ import type { Info, Ref } from "../model.js"
import { SessionUsage } from "./usage.js"
import { PluginHooks } from "../plugin/hooks.js"
import { Agent } from "../agent.js"
import { State } from "../state.js"
const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 15_000
@@ -61,10 +60,14 @@ Rules:
- Preserve exact file paths, symbols, commands, error strings, URLs, and identifiers when known.
- Do not mention the summary process or that context was compacted.`
type Settings = {
readonly auto: boolean
readonly buffer: number
readonly tokens: number
export type Settings = {
auto: boolean
buffer: number
tokens: number
}
export type Draft = {
configure: (settings: Partial<Settings>) => void
}
type Dependencies = {
@@ -74,7 +77,6 @@ type Dependencies = {
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
}
readonly models: SessionRunnerModel.Interface
readonly config: Settings
readonly hooks: PluginHooks.Interface
}
@@ -111,7 +113,7 @@ export type Outcome =
| Pick<SessionMessage.CompactionCompleted, "status">
| Pick<SessionMessage.CompactionFailed, "status" | "error">
export interface Interface {
export interface Interface extends State.Transformable<Draft> {
readonly required: (input: RequiredInput) => boolean
readonly compact: (input: AutoInput) => Effect.Effect<Outcome>
readonly compactManual: (input: ManualInput) => Effect.Effect<Outcome>
@@ -165,17 +167,6 @@ const serialize = (message: SessionMessage.Info) => {
return ""
}
const settings = (documents: readonly Entry[]) => {
const configured = documents
.filter((entry): entry is Document => entry.type === "document")
.flatMap((entry) => (entry.info.compaction ? [entry.info.compaction] : []))
return {
auto: configured.findLast((value) => value.auto !== undefined)?.auto ?? true,
buffer: configured.findLast((value) => value.buffer !== undefined)?.buffer ?? DEFAULT_BUFFER,
tokens: configured.findLast((value) => value.keep?.tokens !== undefined)?.keep?.tokens ?? DEFAULT_KEEP_TOKENS,
}
}
const select = (
messages: readonly SessionMessage.Info[],
tokens: number,
@@ -240,7 +231,17 @@ const planContent = (messages: readonly SessionMessage.Info[], tokens: number) =
}
const make = (dependencies: Dependencies) => {
const config = dependencies.config
const state = State.create<Settings, Draft>({
name: "session-compaction",
initial: () => ({ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS }),
draft: (draft) => ({
configure: (settings) => {
if (settings.auto !== undefined) draft.auto = settings.auto
if (settings.buffer !== undefined) draft.buffer = settings.buffer
if (settings.tokens !== undefined) draft.tokens = settings.tokens
},
}),
})
const failed = Effect.fnUntraced(function* (input: {
readonly sessionID: SessionSchema.ID
readonly reason: SessionMessage.Compaction["reason"]
@@ -350,7 +351,7 @@ const make = (dependencies: Dependencies) => {
return { status: "completed" as const }
})
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput) {
const content = planContent(input.messages, config.tokens)
const content = planContent(input.messages, state.get().tokens)
if (content)
return yield* execute({
session: input.session,
@@ -368,6 +369,7 @@ const make = (dependencies: Dependencies) => {
})
})
const required = (input: RequiredInput) => {
const config = state.get()
if (!config.auto) return false
const context = input.model.route.defaults.limits?.context
if (context === undefined || context <= 0) return false
@@ -388,7 +390,7 @@ const make = (dependencies: Dependencies) => {
return used >= promptCeiling
}
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
const content = planContent(input.messages, config.tokens)
const content = planContent(input.messages, state.get().tokens)
if (!content)
return yield* failed({
sessionID: input.session.id,
@@ -419,6 +421,8 @@ const make = (dependencies: Dependencies) => {
})
})
return Service.of({
transform: state.transform,
reload: state.reload,
required,
compact,
compactManual,
@@ -430,16 +434,15 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const bus = yield* Bus.Service
const llm = yield* LLMClient.Service
const config = yield* Config.Service
const models = yield* SessionRunnerModel.Service
const app = yield* App.Metadata
const hooks = yield* PluginHooks.Service
return make({ bus, llm, models, config: settings(yield* config.entries()), app, hooks })
return make({ bus, llm, models, app, hooks })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, llmClient, Config.node, SessionRunnerModel.node, App.node, PluginHooks.node],
deps: [Bus.node, llmClient, SessionRunnerModel.node, App.node, PluginHooks.node],
})
+4
View File
@@ -34,6 +34,7 @@ import { toSessionError } from "../to-session-error.js"
import { SessionRunnerRetry } from "./retry.js"
import { SessionUsage } from "../usage.js"
import { ToolOutput } from "../../tool-output.js"
import { PluginSupervisor } from "../../plugin/supervisor.js"
/** How one model call ended: settled, awaiting retry/recovery, or restarted by compaction. */
type CallOutcome = Data.TaggedEnum<{
@@ -114,6 +115,7 @@ const layer = Layer.effect(
const snapshots = yield* Snapshot.Service
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
const plugins = yield* PluginSupervisor.Service
const title = yield* SessionTitle.Service
const toolOutput = yield* ToolOutput.Service
// Title generation starts once input is visible and must not delay model execution.
@@ -135,6 +137,7 @@ const layer = Layer.effect(
const promotable = input.promotable ?? "input"
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
return { type: "complete" as const }
yield* plugins.flush
yield* settleStaleToolCalls(input.sessionID)
while (true) {
if (yield* runPendingCompaction(input.sessionID, promotable)) {
@@ -646,6 +649,7 @@ export const node = makeLocationNode({
SessionModelTransport.node,
SessionStore.node,
SessionCompaction.node,
PluginSupervisor.node,
SessionTitle.node,
Snapshot.node,
ToolOutput.node,
+17 -27
View File
@@ -7,7 +7,6 @@ import { produce } from "immer"
import { Shell } from "@opencode-ai/schema/shell"
import { AppProcess } from "@opencode-ai/util/process"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Config } from "./config.js"
import { Bus } from "./bus.js"
import { Environment } from "./environment/index.js"
import { Location } from "./location.js"
@@ -68,14 +67,14 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Shell") {}
export const layer = (options?: ShellSelect.Options) =>
const layer = () =>
Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const location = yield* Location.Service
const config = yield* Config.Service
const global = yield* Global.Service
const shell = yield* ShellSelect.Service
const environment = yield* Environment.Service
const hooks = yield* PluginHooks.Service
const environments = yield* SessionEnvironment.Service
@@ -146,12 +145,7 @@ export const layer = (options?: ShellSelect.Options) =>
return session.info
})
const resolve = () =>
config
.entries()
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options, global.bin)))
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
const name = () => shell.preferred().pipe(Effect.map(ShellSelect.name))
const output = Effect.fnUntraced(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id)
@@ -196,7 +190,7 @@ export const layer = (options?: ShellSelect.Options) =>
command: input.command,
cwd: input.cwd ?? location.directory,
timeout: input.timeout,
shell: yield* resolve(),
shell: yield* shell.preferred(),
env: {
...(sessionEnvironment ?? process.env),
TERM: "xterm-256color",
@@ -353,20 +347,16 @@ export const layer = (options?: ShellSelect.Options) =>
}),
)
export function configured(options?: ShellSelect.Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [
Bus.node,
Location.node,
Config.node,
Global.node,
Environment.node,
PluginHooks.node,
SessionEnvironment.node,
],
})
}
export const node = configured()
export const node = makeLocationNode({
service: Service,
layer: layer(),
deps: [
Bus.node,
Location.node,
Global.node,
ShellSelect.node,
Environment.node,
PluginHooks.node,
SessionEnvironment.node,
],
})
+38
View File
@@ -0,0 +1,38 @@
export * as ShellPolicy from "./policy.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Layer } from "effect"
import { State } from "../state.js"
type Data = {
portableScanner: boolean
}
export type Draft = {
configure: (portableScanner: boolean) => void
}
export interface Interface extends State.Transformable<Draft> {
readonly portableScanner: () => boolean
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ShellPolicy") {}
const layer = Layer.sync(Service, () => {
const state = State.create<Data, Draft>({
name: "shell-policy",
initial: () => ({ portableScanner: false }),
draft: (draft) => ({
configure: (portableScanner) => {
draft.portableScanner = portableScanner
},
}),
})
return Service.of({
transform: state.transform,
reload: state.reload,
portableScanner: () => state.get().portableScanner,
})
})
export const node = makeLocationNode({ service: Service, layer, deps: [] })
+46 -1
View File
@@ -3,8 +3,11 @@ export * as ShellSelect from "./select.js"
import path from "path"
import { readFile } from "fs/promises"
import { statSync } from "fs"
import { Schema } from "effect"
import { Context, Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { State } from "../state.js"
import { which } from "../util/which.js"
const META: Record<string, { deny?: boolean; login?: boolean; ps?: boolean }> = {
@@ -30,6 +33,20 @@ export const Options = Schema.Struct({
})
export type Options = typeof Options.Type
type Data = {
shell?: string
}
export type Draft = {
configure: (shell: string) => void
}
export interface Interface extends State.Transformable<Draft> {
readonly preferred: () => Effect.Effect<string>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ShellSelect") {}
function stat(file: string) {
return statSync(file, { throwIfNoEntry: false }) ?? undefined
}
@@ -181,3 +198,31 @@ export async function list(options?: Options, bin?: string): Promise<Item[]> {
const shells = process.platform === "win32" ? win(options, bin) : await unix()
return shells.filter((shell) => resolve(shell, options, bin)).map((shell) => info(shell, options, bin))
}
const layer = (options?: Options) =>
Layer.effect(
Service,
Effect.gen(function* () {
const global = yield* Global.Service
const state = State.create<Data, Draft>({
name: "shell-select",
initial: () => ({}),
draft: (draft) => ({
configure: (shell) => {
draft.shell = shell
},
}),
})
return Service.of({
transform: state.transform,
reload: state.reload,
preferred: () => Effect.sync(() => preferred(state.get().shell, options, global.bin)),
})
}),
)
export function configured(options?: Options) {
return makeLocationNode({ service: Service, layer: layer(options), deps: [Global.node] })
}
export const node = configured()
+22 -11
View File
@@ -3,7 +3,6 @@ export * as Snapshot from "./snapshot.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { Context, Effect, Fiber, Layer, Schema, Scope } from "effect"
import { Config } from "./config.js"
import { File } from "./file.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "./git.js"
@@ -12,6 +11,7 @@ import { Location } from "./location.js"
import { AbsolutePath, RelativePath } from "./schema.js"
import { ID } from "@opencode-ai/schema/snapshot"
import { Hash } from "@opencode-ai/util/hash"
import { State } from "./state.js"
export { ID }
@@ -36,7 +36,11 @@ export interface RestoreInput {
readonly files: ReadonlyMap<RelativePath, ID>
}
export interface Interface {
export type Draft = {
configure: (enabled: boolean) => void
}
export interface Interface extends State.Transformable<Draft> {
/**
* Capture the current Location-scoped filesystem state as a content-addressed
* tree. Returns `undefined` when snapshots are disabled, unsupported, or the
@@ -68,12 +72,20 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Sn
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const global = yield* Global.Service
const location = yield* Location.Service
const lifetime = yield* Scope.Scope
const state = State.create<{ enabled: boolean }, Draft>({
name: "snapshot",
initial: () => ({ enabled: true }),
draft: (draft) => ({
configure: (enabled) => {
draft.enabled = enabled
},
}),
})
// Cache a scope-owned fiber so caller cancellation stops waiting without poisoning shared initialization.
const repositoryFiber = yield* Effect.cached(
Effect.gen(function* () {
@@ -100,13 +112,10 @@ const layer = Layer.effect(
return RelativePath.make(relative.replaceAll("\\", "/") || ".")
})
const enabled = Effect.fnUntraced(function* () {
if (location.vcs?.type !== "git") return false
return Config.latest(yield* config.entries(), "snapshots") !== false
})
const enabled = () => location.vcs?.type === "git" && state.get().enabled
const capture = Effect.fn("Snapshot.capture")(function* () {
if (!(yield* enabled())) return undefined
if (!enabled()) return undefined
return yield* Effect.gen(function* () {
const repo = yield* repository
return ID.make(
@@ -170,26 +179,28 @@ const layer = Layer.effect(
})
const restore = Effect.fn("Snapshot.restore")(function* (input: RestoreInput) {
if (!(yield* enabled())) return yield* new Error({ operation: "restore", message: "Snapshots are disabled" })
if (!enabled()) return yield* new Error({ operation: "restore", message: "Snapshots are disabled" })
const repo = yield* repository.pipe(Effect.mapError((cause) => failure("restore", cause)))
yield* git.tree
.restore({ repository: repo.snapshotRepository, files: yield* plan(repo.worktree, input) })
.pipe(Effect.mapError((cause) => failure("restore", cause)))
})
return Service.of({ capture, files, diff, restore })
return Service.of({ transform: state.transform, reload: state.reload, capture, files, diff, restore })
}).pipe(Effect.withSpan("Snapshot.boot")),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Config.node, FSUtil.node, Git.node, Global.node, Location.node],
deps: [FSUtil.node, Git.node, Global.node, Location.node],
})
export const noopLayer = Layer.succeed(
Service,
Service.of({
transform: () => Effect.succeed({ dispose: Effect.void }),
reload: () => Effect.void,
capture: () => Effect.succeed(undefined),
files: () => Effect.succeed([]),
diff: () => Effect.succeed([]),
+34 -11
View File
@@ -6,8 +6,8 @@ import { Context, Duration, Effect, Layer, Option, Schedule } from "effect"
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Config } from "./config.js"
import { Identifier } from "./id/id.js"
import { State } from "./state.js"
export const MAX_LINES = 2_000
export const MAX_BYTES = 50 * 1024 // 50 KiB
@@ -16,7 +16,17 @@ export const DIRECTORY = "tool-output"
type Result = Tool.Result
export interface Interface {
export type Limits = {
maxLines: number
maxBytes: number
}
export type Draft = {
configure: (limits: Partial<Limits>) => void
}
export interface Interface extends State.Transformable<Draft> {
readonly limits: () => Readonly<Limits>
readonly truncate: (result: Result) => Effect.Effect<Result>
readonly cleanup: () => Effect.Effect<void>
}
@@ -46,31 +56,38 @@ const cleanup = Effect.fn("ToolOutput.cleanup")(function* (fs: FSUtil.Interface,
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const directory = path.join(global.data, DIRECTORY)
const state = State.create<Limits, Draft>({
name: "tool-output",
initial: () => ({ maxLines: MAX_LINES, maxBytes: MAX_BYTES }),
draft: (draft) => ({
configure: (limits) => {
if (limits.maxLines !== undefined) draft.maxLines = limits.maxLines
if (limits.maxBytes !== undefined) draft.maxBytes = limits.maxBytes
},
}),
})
const truncate = Effect.fnUntraced(function* (result: Result) {
if (result.metadata?.truncated !== undefined) return result
const content =
typeof result.content === "string" ? [{ type: "text" as const, text: result.content }] : (result.content ?? [])
const text = content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n")
const configured = Config.latest(yield* config.entries(), "tool_output")
const maxLines = configured?.max_lines ?? MAX_LINES
const maxBytes = configured?.max_bytes ?? MAX_BYTES
const limits = state.get()
const lines = text.split("\n")
if (text.endsWith("\n")) lines.pop()
const totalBytes = Buffer.byteLength(text, "utf-8")
if (lines.length <= maxLines && totalBytes <= maxBytes)
if (lines.length <= limits.maxLines && totalBytes <= limits.maxBytes)
return { ...result, metadata: { ...result.metadata, truncated: false } }
const kept: string[] = []
let bytes = 0
let hitBytes = false
for (const line of lines.slice(0, maxLines)) {
for (const line of lines.slice(0, limits.maxLines)) {
const size = Buffer.byteLength(line, "utf-8") + (kept.length > 0 ? 1 : 0)
if (bytes + size > maxBytes) {
if (bytes + size > limits.maxBytes) {
hitBytes = true
break
}
@@ -113,7 +130,13 @@ const layer = Layer.effect(
}
})
return Service.of({ truncate, cleanup: () => cleanup(fs, directory) })
return Service.of({
transform: state.transform,
reload: state.reload,
limits: () => ({ ...state.get() }),
truncate,
cleanup: () => cleanup(fs, directory),
})
}),
)
@@ -137,5 +160,5 @@ const cleanupNode = makeGlobalNode({
export const node = makeLocationNode({
service: Service,
layer,
deps: [Config.node, FSUtil.node, Global.node, cleanupNode],
deps: [FSUtil.node, Global.node, cleanupNode],
})
+9 -12
View File
@@ -5,7 +5,6 @@ import { ToolFailure } from "@opencode-ai/ai"
import type { Content } from "@opencode-ai/schema/tool"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Deferred, Effect, Schema, Scope } from "effect"
import { Config } from "../../config.js"
import { Environment } from "../../environment/index.js"
import { LocationMutation } from "../../location-mutation.js"
import { Permission } from "../../permission.js"
@@ -14,6 +13,7 @@ import { NonNegativeInt } from "../../schema.js"
import { SessionSchema } from "../../session/schema.js"
import { Shell } from "../../shell.js"
import { ShellParse } from "../../shell/parse.js"
import { ShellPolicy } from "../../shell/policy.js"
import { ToolOutput } from "../../tool-output.js"
export const name = "shell"
@@ -86,8 +86,9 @@ export const Plugin = {
const environment = yield* Environment.Service
const mutation = yield* LocationMutation.Service
const shell = yield* Shell.Service
const shellPolicy = yield* ShellPolicy.Service
const permission = yield* Permission.Service
const config = yield* Config.Service
const toolOutput = yield* ToolOutput.Service
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* (
sessionID: SessionSchema.ID,
@@ -163,10 +164,8 @@ export const Plugin = {
invocation.cwd = target.absolute
finalTimeout = invocation.timeout
if (!unrestricted) {
const portable =
Config.latest(yield* config.entries(), "experimental")?.portable_shell_scanner === true
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute, {
portable,
portable: shellPolicy.portableScanner(),
})
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }),
@@ -209,18 +208,16 @@ export const Plugin = {
yield* context.progress({ shellID: info.id })
const captureShell = Effect.fnUntraced(function* () {
const configured = Config.latest(yield* config.entries(), "tool_output")
const maxLines = configured?.max_lines ?? ToolOutput.MAX_LINES
const maxBytes = configured?.max_bytes ?? ToolOutput.MAX_BYTES
const limits = toolOutput.limits()
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
const page = yield* shell.output(info.id, {
cursor: Math.max(0, latest.size - maxBytes),
limit: maxBytes,
cursor: Math.max(0, latest.size - limits.maxBytes),
limit: limits.maxBytes,
})
const lines = page.output.split("\n")
if (page.output.endsWith("\n")) lines.pop()
const truncated = latest.size > maxBytes || lines.length > maxLines
const output = lines.length > maxLines ? lines.slice(-maxLines).join("\n") : page.output
const truncated = latest.size > limits.maxBytes || lines.length > limits.maxLines
const output = lines.length > limits.maxLines ? lines.slice(-limits.maxLines).join("\n") : page.output
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
return {
output: `${output || "(no output)"}${notice}`,
+29
View File
@@ -273,6 +273,35 @@ describe("AISDKNative", () => {
})
})
test("maps Vertex Gemini settings to the native Gemini route", () => {
expect(
map("@ai-sdk/google-vertex", {
accessToken: "vertex-token",
baseURL: "https://vertex.example/v1",
headers: { "x-test": "value" },
labels: { component: "opencode", environment: "test" },
location: "eu",
project: "vertex-project",
thinkingConfig: { thinkingLevel: "high" },
}),
).toEqual({
package: "@opencode-ai/ai/providers/google-vertex",
settings: {
accessToken: "vertex-token",
baseURL: "https://vertex.example/v1",
location: "eu",
project: "vertex-project",
providerOptions: {
gemini: {
labels: { component: "opencode", environment: "test" },
thinkingConfig: { thinkingLevel: "high" },
},
},
},
headers: { "x-test": "value" },
})
})
test("maps Vertex Anthropic settings to native Messages", () => {
expect(
map("@ai-sdk/google-vertex/anthropic", {
+37
View File
@@ -104,6 +104,43 @@ it.effect("projects request settings, headers, and body overlays", () =>
}),
)
it.effect("lowers chronological system updates to wrapped user messages", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const resolved = yield* aisdk.model(model("opaque-provider"))
const prepared = yield* compileRequest(
LLM.request({
model: resolved,
system: "Initial instructions.",
messages: [
Message.user("Before."),
Message.system("Updated <rules> & constraints."),
Message.assistant("After."),
],
}),
)
expect(prepared.body.prompt).toEqual([
{ role: "system", content: "Initial instructions." },
{ role: "user", content: [{ type: "text", text: "Before." }] },
{
role: "user",
content: [
{
type: "text",
text: "<system-update>\nUpdated &lt;rules&gt; &amp; constraints.\n</system-update>",
},
],
},
{ role: "assistant", content: [{ type: "text", text: "After." }] },
])
}),
)
it.effect("leaves max output tokens unset when the request omits them", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
+1 -3
View File
@@ -1,19 +1,17 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Command } from "@opencode-ai/core/command"
import { Config } from "@opencode-ai/core/config"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "./fixture/mcp"
import { emptyMcpLayer, testLocationLayer } from "./fixture/mcp"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(Command.node, [
[MCP.node, emptyMcpLayer],
[Config.node, emptyConfigLayer],
[Location.node, testLocationLayer],
]),
)
@@ -0,0 +1,168 @@
import { describe, expect } from "bun:test"
import { LanguageModel, LLMClient, LLMEvent } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { Bus } from "@opencode-ai/core/bus"
import { Config } from "@opencode-ai/core/config"
import { ConfigCompactionPlugin } from "@opencode-ai/core/config/plugin/compaction"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { Session } from "@opencode-ai/core/session"
import { Agent } from "@opencode-ai/core/agent"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ConfigCompaction } from "@opencode-ai/schema/config/compaction"
import { Document, Event, Info } from "@opencode-ai/schema/config"
import { Money } from "@opencode-ai/schema/money"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { DateTime, Effect, Fiber, Layer, Option, Schema, Stream } from "effect"
import { testEffect } from "../lib/effect"
import { host } from "../plugin/host"
const model = LanguageModel.make({
id: "test-model",
provider: "test-provider",
route: OpenAIChat.route.with({ limits: { context: 100_000, output: 1_000 } }),
})
const config = Config.testLayer()
const it = testEffect(
Layer.merge(
config,
AppNodeBuilder.build(LayerNode.group([SessionCompaction.node, Config.node, Bus.node]), [
[
llmClient,
Layer.mock(LLMClient.Service)({
stream: () => Stream.make(LLMEvent.textDelta({ id: "summary", text: "summary" })),
}),
],
[
SessionRunnerModel.node,
Layer.mock(SessionRunnerModel.Service)({
resolve: () =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
}),
),
}),
],
[Config.node, config],
]),
),
)
describe("ConfigCompactionPlugin.Plugin", () => {
it.live("merges settings and reloads changed config", () =>
Effect.gen(function* () {
const compaction = yield* SessionCompaction.Service
const config = yield* Config.Test
const bus = yield* Bus.Service
yield* config.setEntries([
new Document({
type: "document",
info: new Info({ compaction: new ConfigCompaction.Info({ auto: false, buffer: 20_000 }) }),
}),
new Document({
type: "document",
info: new Info({
compaction: new ConfigCompaction.Info({
buffer: 10_000,
keep: new ConfigCompaction.Keep({ tokens: 0 }),
}),
}),
}),
])
yield* ConfigCompactionPlugin.Plugin.effect(host({ event: { subscribe: () => bus.subscribe(Event.Updated) } }))
expect(compaction.required(nearInput)).toBe(false)
const started = yield* bus
.subscribe(SessionEvent.Compaction.Started)
.pipe(Stream.runHead, Effect.forkScoped({ startImmediately: true }))
expect(
yield* compaction.compactManual({
session,
messages: [
{
id: SessionMessage.ID.create(),
type: "user",
text: "Older context",
time: { created: DateTime.makeUnsafe(0) },
},
{
id: SessionMessage.ID.create(),
type: "user",
text: "Recent context",
time: { created: DateTime.makeUnsafe(1) },
},
],
inputID: SessionMessage.ID.make("msg_compaction_manual"),
}),
).toEqual({ status: "completed" })
expect(Option.getOrThrow(yield* Fiber.join(started)).data.recent).toContain("Recent context")
yield* config.setEntries([
new Document({
type: "document",
info: new Info({ compaction: new ConfigCompaction.Info({ auto: true, buffer: 20_000 }) }),
}),
new Document({
type: "document",
info: new Info({ compaction: new ConfigCompaction.Info({ buffer: 10_000 }) }),
}),
])
yield* bus.publish(Event.Updated, {})
yield* Effect.gen(function* () {
for (let attempt = 0; attempt < 200; attempt++) {
if (compaction.required(nearInput)) return
yield* Effect.sleep("10 millis")
}
yield* Effect.die(new Error("Timed out waiting for compaction config reload"))
})
expect(compaction.required(bufferedInput)).toBe(false)
yield* config.setEntries([
new Document({
type: "document",
info: new Info({ compaction: new ConfigCompaction.Info({ auto: true, buffer: 20_000 }) }),
}),
])
yield* bus.publish(Event.Updated, {})
for (let attempt = 0; attempt < 200; attempt++) {
if (compaction.required(bufferedInput)) return
yield* Effect.sleep("10 millis")
}
yield* Effect.die(new Error("Timed out waiting for compaction config reload"))
}),
)
})
const session = Session.Info.make({
id: Session.ID.make("ses_compaction_config"),
projectID: Project.ID.global,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp") }),
})
const input = (tokens: number) => ({
session,
model,
cost: [],
messages: [
Schema.decodeUnknownSync(SessionMessage.Assistant)({
id: SessionMessage.ID.make("msg_compaction_config"),
type: "assistant",
agent: Agent.defaultID,
model: { id: "test-model", providerID: "test-provider" },
content: [],
tokens: { input: tokens, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, completed: 0 },
}),
],
})
const bufferedInput = input(85_000)
const nearInput = input(95_000)
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect } from "bun:test"
import { Bus } from "@opencode-ai/core/bus"
import { Config } from "@opencode-ai/core/config"
import { ConfigShellPlugin } from "@opencode-ai/core/config/plugin/shell"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ShellPolicy } from "@opencode-ai/core/shell/policy"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Document, Event, Info } from "@opencode-ai/schema/config"
import { ConfigExperimental } from "@opencode-ai/schema/config/experimental"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Effect, Layer } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
const it = testEffect(
Layer.merge(PluginTestLayer, AppNodeBuilder.build(LayerNode.group([ShellSelect.node, ShellPolicy.node]))),
)
describe("ConfigShellPlugin.Plugin", () => {
it.live("applies shell policy and reloads changed config", () =>
Effect.gen(function* () {
const shell = yield* ShellSelect.Service
const policy = yield* ShellPolicy.Service
const bus = yield* Bus.Service
const config = yield* Config.Test
const plugins = yield* Plugin.Service
yield* ConfigShellPlugin.Plugin.effect(yield* PluginHost.make(plugins))
const configured = process.platform === "win32" ? FSUtil.windowsPath(process.execPath) : process.execPath
expect(yield* shell.preferred()).toBe(configured)
expect(policy.portableScanner()).toBe(true)
yield* config.setEntries([])
yield* bus.publish(Event.Updated, {})
for (let attempt = 0; attempt < 200; attempt++) {
if ((yield* shell.preferred()) !== configured && !policy.portableScanner()) return
yield* Effect.sleep("10 millis")
}
yield* Effect.die(new Error("Timed out waiting for shell config reload"))
}).pipe(
Effect.provide(
Config.testLayer([
new Document({
type: "document",
info: new Info({
shell: process.execPath,
experimental: new ConfigExperimental.Info({ portable_shell_scanner: true }),
}),
}),
]),
),
),
)
})
@@ -0,0 +1,66 @@
import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Bus } from "@opencode-ai/core/bus"
import { Config } from "@opencode-ai/core/config"
import { ConfigSnapshotPlugin } from "@opencode-ai/core/config/plugin/snapshot"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { Document, Event, Info } from "@opencode-ai/schema/config"
import { Global } from "@opencode-ai/util/global"
import { Effect } from "effect"
import { tmpdir } from "../fixture/tmpdir"
import { it } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
describe("ConfigSnapshotPlugin.Plugin", () => {
it.live("applies availability and reloads changed config", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
yield* Effect.promise(async () => {
await fs.mkdir(project)
await fs.writeFile(path.join(project, "tracked.txt"), "one\n")
await $`git init`.cwd(project).quiet()
await $`git -c core.fsmonitor=false add .`.cwd(project).quiet()
})
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const bus = yield* Bus.Service
const config = yield* Config.Test
const plugins = yield* Plugin.Service
yield* ConfigSnapshotPlugin.Plugin.effect(yield* PluginHost.make(plugins))
expect(yield* snapshot.capture()).toBeUndefined()
yield* config.setEntries([new Document({ type: "document", info: new Info({ snapshots: true }) })])
yield* bus.publish(Event.Updated, {})
for (let attempt = 0; attempt < 200; attempt++) {
if ((yield* snapshot.capture()) !== undefined) return
yield* Effect.sleep("10 millis")
}
yield* Effect.die(new Error("Timed out waiting for snapshot config reload"))
}).pipe(
Effect.provide(
AppNodeBuilder.build(Snapshot.node, [
[Location.node, Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))],
[Global.node, Global.layerWith({ data: tmp.path, config: path.join(tmp.path, "config") })],
]),
),
)
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.provide(PluginTestLayer),
Effect.provide(Config.testLayer([new Document({ type: "document", info: new Info({ snapshots: false }) })])),
),
)
})
@@ -0,0 +1,66 @@
import { describe, expect } from "bun:test"
import { Bus } from "@opencode-ai/core/bus"
import { Config } from "@opencode-ai/core/config"
import { ConfigToolOutputPlugin } from "@opencode-ai/core/config/plugin/tool-output"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ToolOutput } from "@opencode-ai/core/tool-output"
import { Document, Event, Info } from "@opencode-ai/schema/config"
import { ConfigToolOutput } from "@opencode-ai/schema/config/tool-output"
import { Global } from "@opencode-ai/util/global"
import { Effect } from "effect"
import { tmpdir } from "../fixture/tmpdir"
import { it } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
describe("ConfigToolOutputPlugin.Plugin", () => {
it.live("applies limits and reloads changed config", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
const output = yield* ToolOutput.Service
const bus = yield* Bus.Service
const config = yield* Config.Test
const plugins = yield* Plugin.Service
yield* ConfigToolOutputPlugin.Plugin.effect(yield* PluginHost.make(plugins))
expect(output.limits()).toEqual({ maxLines: 1, maxBytes: ToolOutput.MAX_BYTES })
expect((yield* output.truncate({ content: "one\ntwo" })).metadata?.truncated).toBe(true)
yield* config.setEntries([
new Document({
type: "document",
info: new Info({
tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }),
}),
}),
])
yield* bus.publish(Event.Updated, {})
for (let attempt = 0; attempt < 200; attempt++) {
const result = yield* output.truncate({ content: "one\ntwo" })
if (result.metadata?.truncated === false) {
expect(output.limits()).toEqual({ maxLines: 2, maxBytes: 1_000 })
return
}
yield* Effect.sleep("10 millis")
}
yield* Effect.die(new Error("Timed out waiting for tool output config reload"))
}).pipe(
Effect.provide(AppNodeBuilder.build(ToolOutput.node, [[Global.node, Global.layerWith({ data: tmp.path })]])),
),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.provide(PluginTestLayer),
Effect.provide(
Config.testLayer([
new Document({
type: "document",
info: new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 1 }) }),
}),
]),
),
),
)
})
+135 -7
View File
@@ -4,18 +4,24 @@ import fs from "fs/promises"
import path from "path"
import { Deferred, Duration, Effect, Fiber, Layer, Option, Schedule, Stream } from "effect"
import { Config } from "@opencode-ai/core/config"
import { ConfigLocationWatcherPlugin } from "@opencode-ai/core/config/plugin/location-watcher"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeLocationNode, type LocationNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { LocationWatcher } from "@opencode-ai/core/filesystem/location-watcher"
import { LocationWatcherPolicy } from "@opencode-ai/core/filesystem/location-watcher-policy"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Document, Event, Info, type Entry } from "@opencode-ai/schema/config"
import { Location } from "@opencode-ai/core/location"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { host } from "../plugin/host"
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
const describeNative = process.env.CI ? describe.skip : describe
@@ -23,6 +29,11 @@ const describeNative = process.env.CI ? describe.skip : describe
const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))
const configLayer = Config.testLayer()
const pluginNode = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void })),
deps: [],
})
describe("Watcher.testLayer", () => {
it.effect("records subscriptions and broadcasts emitted updates through the service", () =>
@@ -135,16 +146,26 @@ describe("Watcher lifecycle", () => {
})
})
function provide(directory: string, vcs?: Location.Interface["vcs"], watcher?: Layer.Layer<Watcher.Service>) {
function provide(
directory: string,
vcs?: Location.Interface["vcs"],
watcher?: Layer.Layer<Watcher.Service>,
config: Layer.Layer<Config.Service> = configLayer,
plugins: LocationNode<PluginSupervisor.Service> = pluginNode,
) {
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
)
const built = AppNodeBuilder.build(LocationWatcher.node, [
[Config.node, configLayer],
[Location.node, locationLayer],
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
])
const built = AppNodeBuilder.build(
LayerNode.group([LocationWatcher.node, LocationWatcherPolicy.node, Bus.node, Config.node]),
[
[Config.node, config],
[Location.node, locationLayer],
[PluginSupervisor.node, plugins],
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
],
)
return Effect.provide(built)
}
@@ -154,6 +175,8 @@ function withTmp<A, E, R>(
vcs?: "git" | "hg"
init?: (directory: string) => Promise<void>
watcher?: Layer.Layer<Watcher.Service>
config?: Layer.Layer<Config.Service>
plugins?: LocationNode<PluginSupervisor.Service>
},
) {
return Effect.acquireRelease(
@@ -174,7 +197,11 @@ function withTmp<A, E, R>(
return { tmp, vcs: { type: "git" as const, store: AbsolutePath.make(path.join(tmp.path, ".git")) } }
}),
({ tmp }) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(provide(tmp.path, vcs, options?.watcher))))
).pipe(
Effect.flatMap(({ tmp, vcs }) =>
f(tmp.path, vcs).pipe(provide(tmp.path, vcs, options?.watcher, options?.config ?? configLayer, options?.plugins)),
),
)
}
describe("LocationWatcher subscriptions", () => {
@@ -223,6 +250,107 @@ describe("LocationWatcher subscriptions", () => {
{ vcs: "hg", watcher },
)
})
it.live("reconciles config without duplicate subscriptions", () => {
const entries = { current: [] as Entry[] }
const subscriptions: Watcher.WatchInput[] = []
const counts = { active: 0, released: 0 }
const watcher = Layer.succeed(
Watcher.Service,
Watcher.Service.of({
subscribe: (input) =>
Effect.sync(() => {
subscriptions.push(input)
counts.active++
return Stream.never.pipe(
Stream.ensuring(
Effect.sync(() => {
counts.active--
counts.released++
}),
),
)
}),
}),
)
const config = Layer.succeed(
Config.Service,
Config.Service.of({
entries: () => Effect.sync(() => entries.current),
update: () => Effect.die("unused config.update"),
changes: () => Stream.never,
}),
)
return Effect.gen(function* () {
yield* withTmp(
() =>
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
const bus = yield* Bus.Service
yield* Effect.sync(() => subscriptions.length).pipe(
Effect.filterOrFail((count) => count === 1),
Effect.retry(Schedule.spaced("10 millis")),
)
expect(counts.active).toBe(1)
entries.current = [new Document({ type: "document", info: new Info({ watcher: { ignore: [".git"] } }) })]
yield* ConfigLocationWatcherPlugin.Plugin.effect(
host({ event: { subscribe: () => bus.subscribe(Event.Updated) } }),
)
yield* Effect.sync(() => counts.active).pipe(
Effect.filterOrFail((count) => count === 0),
Effect.retry(Schedule.spaced("10 millis")),
)
expect(counts.released).toBe(1)
entries.current = []
yield* bus.publish(Event.Updated, {})
yield* Effect.sync(() => subscriptions.length).pipe(
Effect.filterOrFail((count) => count === 2),
Effect.retry(Schedule.spaced("10 millis")),
)
expect(counts.active).toBe(1)
yield* policy.reload()
expect(subscriptions).toHaveLength(2)
}),
{ vcs: "git", watcher, config },
)
expect(counts.active).toBe(0)
expect(counts.released).toBe(2)
})
})
it.live("does not start before configured policy is ready", () => {
const subscriptions: Watcher.WatchInput[] = []
const watcher = Layer.succeed(
Watcher.Service,
Watcher.Service.of({
subscribe: (input) => Effect.sync(() => subscriptions.push(input)).pipe(Effect.as(Stream.never)),
}),
)
const plugins = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.effect(
PluginSupervisor.Service,
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
yield* policy.transform((draft) => draft.add([".git"]))
return PluginSupervisor.Service.of({ flush: Effect.void })
}),
),
deps: [LocationWatcherPolicy.node],
})
return withTmp(
() =>
Effect.gen(function* () {
yield* LocationWatcher.Service
yield* Effect.sleep("50 millis")
expect(subscriptions).toEqual([])
}),
{ vcs: "git", watcher, plugins },
)
})
})
function wait(check: (event: WatcherEvent) => boolean) {
+6 -22
View File
@@ -1,7 +1,5 @@
import { describe, expect } from "bun:test"
import { Cause, Deferred, Effect, Exit, Layer, Queue } from "effect"
import { Config } from "@opencode-ai/core/config"
import { Document, Info } from "@opencode-ai/schema/config"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
@@ -9,6 +7,7 @@ import { Location } from "@opencode-ai/core/location"
import { Pty } from "@opencode-ai/core/pty"
import type { PtyID } from "@opencode-ai/core/pty/schema"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
@@ -18,13 +17,7 @@ const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })),
)
const configLayer = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node]), [
[Config.node, configLayer],
[Location.node, locationLayer],
]),
)
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node]), [[Location.node, locationLayer]]))
const ptyTest = process.platform === "win32" ? it.live.skip : it.live
const subscribePtyEvents = Effect.fn("PtySessionTest.subscribePtyEvents")(function* () {
@@ -207,26 +200,17 @@ describe("pty", () => {
const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash")
const configuredIt = testEffect(
AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node]), [
[
Config.node,
Layer.mock(Config.Service)({
entries: () =>
Effect.succeed(
configuredShell ? [new Document({ type: "document", info: new Info({ shell: configuredShell }) })] : [],
),
}),
],
[Location.node, locationLayer],
]),
AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node, ShellSelect.node]), [[Location.node, locationLayer]]),
)
const configuredTest = process.platform === "win32" ? configuredIt.live.skip : configuredIt.live
describe("pty create defaults", () => {
configuredTest("defaults command, login args, and cwd from config and location", () =>
configuredTest("defaults command, login args, and cwd from shell selection and location", () =>
Effect.gen(function* () {
if (!configuredShell) return
const pty = yield* Pty.Service
const shell = yield* ShellSelect.Service
yield* shell.transform((draft) => draft.configure(configuredShell))
const info = yield* Effect.acquireRelease(pty.create({ title: "configured" }), (created) =>
pty.remove(created.id).pipe(Effect.ignore),
)
@@ -1,7 +1,6 @@
import { expect, test } from "bun:test"
import { LLMClient, LLMEvent, LanguageModel, type LLMRequest } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { Config } from "@opencode-ai/core/config"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
@@ -67,7 +66,6 @@ const client = Layer.mock(LLMClient.Service)({
},
generate: () => Effect.die("unused"),
})
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () =>
Effect.succeed(
@@ -83,7 +81,6 @@ const it = testEffect(
[
[Bus.node, Bus.configured({ persist: true })],
[llmClient, client],
[Config.node, config],
[SessionRunnerModel.node, models],
],
),
+33 -1
View File
@@ -28,6 +28,7 @@ import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { Image } from "@opencode-ai/core/image"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { testEffect } from "./lib/effect"
const executionCalls: Session.ID[] = []
@@ -60,7 +61,7 @@ const locations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
() =>
// Attachment admission only needs image normalization and plugin readiness.
// These operations resolve Location services lazily and must wait for plugin-projected state.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
Layer.unwrap(
Effect.sync(() => {
@@ -72,6 +73,12 @@ const locations = Layer.effect(
? Effect.succeed(content.content.length > 5 * 1024 * 1024 ? { ...content, content: "AA==" } : content)
: Effect.die(new Error("Image service used before plugins were ready")),
}),
Layer.mock(Snapshot.Service, {
capture: () =>
ready ? Effect.succeed(undefined) : Effect.die(new Error("Snapshot used before plugins were ready")),
restore: () =>
ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready")),
}),
Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({ flush: Effect.sync(() => (ready = true)) }),
@@ -1051,6 +1058,31 @@ describe("Session.prompt", () => {
)
})
describe("Session.revert", () => {
it.effect("waits for location plugins before staging", () =>
Effect.gen(function* () {
yield* setup
const { db } = yield* Database.Service
const session = yield* Session.Service
yield* db.insert(SessionMessageTable).values(assistantRow(messageID, 0)).run().pipe(Effect.orDie)
yield* session.revert.stage({ sessionID, messageID })
}),
)
it.effect("waits for location plugins before clearing", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const bus = yield* Bus.Service
yield* bus.publish(SessionEvent.RevertEvent.Staged, {
sessionID,
revert: { messageID, snapshot: Snapshot.ID.make("tree"), files: [] },
})
yield* session.revert.clear(sessionID)
}),
)
})
describe("Session.inbox", () => {
it.effect("fails for an unknown session", () =>
Effect.gen(function* () {
+25
View File
@@ -127,6 +127,31 @@ describe("Snapshot", () => {
),
)
testEffect(Layer.empty).live("applies availability transforms", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
yield* Effect.promise(async () => {
await fs.mkdir(project)
await fs.writeFile(path.join(project, "tracked.txt"), "one\n")
await initGit(project)
})
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const registration = yield* snapshot.transform((draft) => draft.configure(false))
expect(yield* snapshot.capture()).toBeUndefined()
yield* registration.dispose
expect(yield* snapshot.capture()).toBeDefined()
}).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
testEffect(Layer.empty).live("treats capture outside Git as unavailable", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
+9 -12
View File
@@ -1,9 +1,6 @@
import { describe, expect } from "bun:test"
import path from "path"
import { Effect } from "effect"
import { Config } from "@opencode-ai/core/config"
import { Document, Info } from "@opencode-ai/schema/config"
import { ConfigToolOutput } from "@opencode-ai/schema/config/tool-output"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { ToolOutput } from "@opencode-ai/core/tool-output"
@@ -15,18 +12,18 @@ import { it } from "./lib/effect"
const withStore = <A, E, R>(
body: (output: ToolOutput.Interface, fs: FSUtil.Interface, root: string) => Effect.Effect<A, E, R>,
info = new Info(),
limits?: { maxLines?: number; maxBytes?: number },
) =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
const config = Config.testLayer([new Document({ type: "document", info })])
const layer = AppNodeBuilder.build(LayerNode.group([ToolOutput.node, FSUtil.node]), [
[Config.node, config],
[Global.node, Global.layerWith({ data: tmp.path })],
])
return Effect.gen(function* () {
return yield* body(yield* ToolOutput.Service, yield* FSUtil.Service, tmp.path)
const output = yield* ToolOutput.Service
if (limits) yield* output.transform((draft) => draft.configure(limits))
return yield* body(output, yield* FSUtil.Service, tmp.path)
}).pipe(Effect.provide(layer))
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
@@ -50,7 +47,7 @@ describe("ToolOutput", () => {
{ type: "text", text: `... 1 line truncated; full content saved to ${outputPath} ...` },
])
}),
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
{ maxLines: 2, maxBytes: 1_000 },
),
)
@@ -67,7 +64,7 @@ describe("ToolOutput", () => {
},
])
}),
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 100, max_bytes: 5 }) }),
{ maxLines: 100, maxBytes: 5 },
),
)
@@ -86,7 +83,7 @@ describe("ToolOutput", () => {
{ type: "text", text: expect.stringMatching(/^\.\.\. 1 line truncated; full content saved to /) },
])
}),
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
{ maxLines: 2, maxBytes: 1_000 },
),
)
@@ -119,7 +116,7 @@ describe("ToolOutput", () => {
metadata: { truncated: false },
})
}),
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
{ maxLines: 2, maxBytes: 1_000 },
),
)
@@ -133,7 +130,7 @@ describe("ToolOutput", () => {
{ type: "text", text: expect.stringMatching(/^\.\.\. 1 byte truncated; full content saved to /) },
])
}),
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 3 }) }),
{ maxLines: 2, maxBytes: 3 },
),
)
+5 -3
View File
@@ -32,6 +32,7 @@ import { Permission } from "@opencode-ai/core/permission"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Shell } from "@opencode-ai/core/shell"
import { ShellPolicy } from "@opencode-ai/core/shell/policy"
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
import { ShellTool } from "@opencode-ai/core/tool/plugin/shell"
import { ToolOutput } from "@opencode-ai/core/tool-output"
@@ -131,13 +132,14 @@ const shellPluginSupervisor = makeLocationNode({
registerToolPlugin(ShellTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
),
deps: [
Config.node,
Environment.node,
LocationMutation.node,
Permission.node,
PluginRuntime.node,
Shell.node,
ShellPolicy.node,
Tool.node,
ToolOutput.node,
],
})
@@ -543,7 +545,7 @@ describe("ShellTool", () => {
{ timeout: 15_000 },
)
it.live("does not add external-directory permission for an experimental portable heredoc", () =>
productionIt.live("does not add external-directory permission for an experimental portable heredoc", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
@@ -624,7 +626,7 @@ describe("ShellTool", () => {
{ timeout: 15_000 },
)
it.live("uses configured line limits", () =>
productionIt.live("uses configured line limits", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
+3
View File
@@ -1,6 +1,7 @@
import { Pty } from "@opencode-ai/core/pty"
import { PtyProtocol } from "@opencode-ai/core/pty/protocol"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { Location } from "@opencode-ai/core/location"
import { Effect, Queue } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
@@ -39,6 +40,8 @@ export const PtyHandler = HttpApiBuilder.group(Api, "server.pty", (handlers) =>
.handle(
"pty.create",
Effect.fn(function* (ctx) {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
const pty = yield* Pty.Service
const location = yield* Location.Service
const cwd = ctx.payload.cwd || location.directory
+3
View File
@@ -1,5 +1,6 @@
import { Shell } from "@opencode-ai/core/shell"
import { Location } from "@opencode-ai/core/location"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { ShellNotFoundError } from "@opencode-ai/protocol/errors"
@@ -19,6 +20,8 @@ export const ShellHandler = HttpApiBuilder.group(Api, "server.shell", (handlers)
.handle(
"shell.create",
Effect.fn(function* (ctx) {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
const shell = yield* Shell.Service
const location = yield* Location.Service
return yield* response(
+2 -6
View File
@@ -9,14 +9,12 @@ import { EventLogger } from "@opencode-ai/core/event-logger"
import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
import { Credential } from "@opencode-ai/core/credential"
import { Config } from "@opencode-ai/core/config"
import { Command } from "@opencode-ai/core/command"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Pty } from "@opencode-ai/core/pty"
import { Project } from "@opencode-ai/core/project"
import { Session } from "@opencode-ai/core/session"
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
import { Shell } from "@opencode-ai/core/shell"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Job } from "@opencode-ai/core/job"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Global } from "@opencode-ai/util/global"
@@ -115,9 +113,7 @@ function makeRoutes<AuthError, AuthServices>(
}),
],
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: options.config?.project })],
[Command.node, Command.configured({ gitbash: options.windows?.gitbash })],
[Pty.node, Pty.configured({ gitbash: options.windows?.gitbash })],
[Shell.node, Shell.configured({ gitbash: options.windows?.gitbash })],
[ShellSelect.node, ShellSelect.configured({ gitbash: options.windows?.gitbash })],
[
MCP.node,
MCP.configured({
+4
View File
@@ -111,6 +111,7 @@ export const DEFAULT_THEME = {
subdued: "$hue.neutral.600",
action: {
primary: { default: "$hue.neutral.200", $disabled: "$hue.neutral.500" },
secondary: { default: "$text.subdued", $hovered: "$text.default" },
destructive: { default: "$hue.red.200", $disabled: "$hue.neutral.500" },
},
formfield: {
@@ -142,6 +143,7 @@ export const DEFAULT_THEME = {
$selected: "$hue.interactive.700",
$disabled: "$hue.neutral.300",
},
secondary: { default: "transparent" },
destructive: {
default: "$hue.red.600",
$hovered: "$hue.red.700",
@@ -324,6 +326,7 @@ export const DEFAULT_THEME = {
subdued: "$hue.neutral.400",
action: {
primary: { default: "$hue.neutral.200", $disabled: "$hue.neutral.500" },
secondary: { default: "$text.subdued", $hovered: "$text.default" },
destructive: { default: "$hue.red.200", $disabled: "$hue.neutral.500" },
},
formfield: {
@@ -355,6 +358,7 @@ export const DEFAULT_THEME = {
$selected: "$hue.interactive.600",
$disabled: "$hue.neutral.800",
},
secondary: { default: "transparent" },
destructive: {
default: "$hue.red.600",
$hovered: "$hue.red.700",
+2 -1
View File
@@ -9,7 +9,7 @@ export type BaseHue = Schema.Schema.Type<typeof BaseHue>
export const HueAlias = Schema.Literals(["accent", "interactive", "neutral"])
export type HueAlias = Schema.Schema.Type<typeof HueAlias>
export const ActionVariant = Schema.Literals(["primary", "destructive"])
export const ActionVariant = Schema.Literals(["primary", "secondary", "destructive"])
export type ActionVariant = Schema.Schema.Type<typeof ActionVariant>
export const ActionState = Schema.Literals(["disabled", "pressed", "focused", "selected", "hovered"])
@@ -90,6 +90,7 @@ export type FormfieldColorDefinition = StatefulColorDefinition
const ActionColorDefinition = Schema.Struct({
primary: Schema.optional(StatefulColorDefinition),
secondary: Schema.optional(StatefulColorDefinition),
destructive: Schema.optional(StatefulColorDefinition),
})
+2
View File
@@ -82,6 +82,7 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
$focused: selected,
$selected: primary,
},
secondary: { default: "$text.subdued", $hovered: "$text.default" },
destructive: { default: destructive, $disabled: textMuted },
},
formfield: {
@@ -107,6 +108,7 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
},
action: {
primary: { default: "transparent", $hovered: backgroundPanel, $focused: primary, $selected: "transparent" },
secondary: { default: "transparent" },
destructive: { default: color("error") },
},
formfield: {
@@ -1,5 +1,5 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, Match, Show, Switch } from "solid-js"
import { createMemo, createSignal, Match, Show, Switch } from "solid-js"
import { contextUsage, formatContextUsage } from "../../util/session"
import { useTerminalDimensions } from "@opentui/solid"
@@ -10,6 +10,7 @@ const money = new Intl.NumberFormat("en-US", {
export function PromptFooter(props: { context: Plugin.Context; sessionID?: string; mode: "normal" | "shell" }) {
const dimensions = useTerminalDimensions()
const [liveHovered, setLiveHovered] = createSignal(false)
const subagents = createMemo(() => {
if (!props.sessionID) return 0
const count = props.context.data.session
@@ -47,16 +48,34 @@ export function PromptFooter(props: { context: Plugin.Context; sessionID?: strin
<Match when={props.mode === "normal"}>
<Switch>
<Match when={live() || status().length > 0}>
<text fg={props.context.theme.text.subdued} wrapMode="none" truncate flexShrink={1}>
<Show when={live() && shortcut("session.child.first")}>
{(value) => <span style={{ fg: props.context.theme.text.default }}>{value()} </span>}
<box flexDirection="row" flexShrink={1} minWidth={0}>
<Show when={live()}>
<box
flexShrink={0}
onMouseOver={() => setLiveHovered(true)}
onMouseOut={() => setLiveHovered(false)}
onMouseUp={() => props.context.keymap.dispatch("session.child.first")}
>
<text
fg={liveHovered() ? props.context.theme.text.default : props.context.theme.text.subdued}
wrapMode="none"
>
<Show when={shortcut("session.child.first")}>
{(value) => <span style={{ fg: props.context.theme.text.default }}>{value()} </span>}
</Show>
<Show when={subagents()}>{(value) => <>{value()}</>}</Show>
<Show when={subagents() && shells()}> · </Show>
<Show when={shells()}>{(value) => <>{value()}</>}</Show>
</text>
</box>
</Show>
<Show when={subagents()}>{(value) => <span>{value()}</span>}</Show>
<Show when={subagents() && shells()}> · </Show>
<Show when={shells()}>{(value) => <span>{value()}</span>}</Show>
<Show when={live() && status().length > 0}> · </Show>
<Show when={status().length > 0}>{status().join(" · ")}</Show>
</text>
<Show when={status().length > 0}>
<text fg={props.context.theme.text.subdued} wrapMode="none" truncate flexShrink={1}>
<Show when={live()}> · </Show>
{status().join(" · ")}
</text>
</Show>
</box>
</Match>
<Match when={dimensions().width >= 44}>
<text fg={props.context.theme.text.default} flexShrink={0}>
+79 -45
View File
@@ -14,9 +14,10 @@ import {
type ParentProps,
} from "solid-js"
import path from "path"
import { stat } from "fs/promises"
import { readFile, stat } from "fs/promises"
import { fileURLToPath, pathToFileURL } from "url"
import type { Page } from "@opencode-ai/plugin/tui/context"
import { Hash } from "@opencode-ai/util/hash"
import { resolveSlots, type Claim } from "./structure"
import { createStore, produce, reconcile as reconcileStore, unwrap } from "solid-js/store"
import { isDeepEqual } from "remeda"
@@ -78,6 +79,7 @@ type Registration = {
type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "options"> & { enabled: boolean }
const PluginContext = createContext<Value>()
let sourceVersion = Date.now()
export function combineMarkdownRenderers(
sources: ReadonlyArray<Readonly<Record<string, MarkdownCodeBlockRenderer>>>,
@@ -107,6 +109,18 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
states: [] as ReadonlyArray<State>,
registrations: {} as Record<string, Registration>,
})
// One save can emit several watch events. Remember setup failures so those
// events do not repeatedly tear down and restore the last good generation.
const setupFailures = new Map<string, { version: string; options: Registration["options"]; error: string }>()
const sourceVersions = new Map<string, { digest: string; generation: number }>()
const sourceGeneration = async (entrypoint: string) => {
const digest = Hash.sha256(await readFile(new URL(entrypoint)))
const previous = sourceVersions.get(entrypoint)
if (previous?.digest === digest) return previous.generation
const generation = ++sourceVersion
sourceVersions.set(entrypoint, { digest, generation })
return generation
}
const markdown = createMemo(() =>
combineMarkdownRenderers(
Object.values(store.registrations).flatMap((registration) =>
@@ -114,15 +128,18 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
),
),
)
const clearContributions = (id: string) => {
setStore("registrations", id, "routes", reconcileStore({}))
setStore("registrations", id, "slots", reconcileStore({}))
setStore("registrations", id, "markdown", reconcileStore({}))
}
const activate = async (id: string) => {
const item = store.registrations[id]
if (!item) return false
await deactivate(id)
batch(() => {
setStore("registrations", id, "routes", reconcileStore({}))
setStore("registrations", id, "slots", reconcileStore({}))
setStore("registrations", id, "markdown", reconcileStore({}))
clearContributions(id)
setStore("registrations", id, "cleanups", [])
})
const owned: Dispose[] = []
@@ -150,12 +167,17 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
},
})
const cleanup = await setup(item.plugin, context, owned).catch((error) => {
setStore("registrations", id, "routes", reconcileStore({}))
setStore("registrations", id, "slots", reconcileStore({}))
setStore("registrations", id, "markdown", reconcileStore({}))
clearContributions(id)
if (item.target)
setupFailures.set(item.target, {
version: item.version,
options: snapshotOptions(item.options),
error: errorMessage(error),
})
throw error
})
if (cleanup) owned.push(async () => cleanup())
if (item.target && sameGeneration(setupFailures.get(item.target), item)) setupFailures.delete(item.target)
batch(() => {
setStore("registrations", id, "cleanups", owned)
setStore("registrations", id, "active", true)
@@ -179,9 +201,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
await disposeAll(cleanups).finally(() =>
batch(() => {
if (store.registrations[id]) {
setStore("registrations", id, "routes", reconcileStore({}))
setStore("registrations", id, "slots", reconcileStore({}))
setStore("registrations", id, "markdown", reconcileStore({}))
clearContributions(id)
}
setStore("states", (items) =>
items.map((state) =>
@@ -275,10 +295,12 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
const memo = local ? undefined : npmFailures.get(target)
const resolved = memo
? { status: "failed" as const, error: memo }
: await resolvePlugin(target, local, options, previous, props.packages, source.install).catch((error) => ({
status: "failed" as const,
error: errorMessage(error),
}))
: await resolvePlugin(target, local, options, previous, props.packages, source.install, sourceGeneration).catch(
(error) => ({
status: "failed" as const,
error: errorMessage(error),
}),
)
if (resolved.status === "unsupported") {
if (source.server) continue
failures.push({ target, status: "unsupported" })
@@ -292,17 +314,21 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
status: "failed",
error: previous?.active ? `${resolved.error} (previous version still active)` : resolved.error,
})
if (previous)
desired.set(previous.plugin.id, {
plugin: previous.plugin,
source: previous.source,
target,
version: previous.version,
options: previous.options,
enabled: previous.active,
})
if (previous) desired.set(previous.plugin.id, toDesired(previous))
continue
}
const setupFailure = setupFailures.get(target)
if (setupFailure && sameGeneration(setupFailure, { version: resolved.version, options }) && previous) {
failures.push({
target,
id: previous.plugin.id,
status: "failed",
error: previous.active ? `${setupFailure.error} (previous version still active)` : setupFailure.error,
})
desired.set(previous.plugin.id, toDesired(previous))
continue
}
setupFailures.delete(target)
desired.set(resolved.plugin.id, {
plugin: resolved.plugin,
source: "external",
@@ -335,11 +361,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
// enabled derives from config directives alone, so config wins over
// manual dialog toggles on every reconcile — the same semantics
// config saves had before hot reload existed, just more frequent.
return (
registration.version !== item.version ||
!sameOptions(registration.options, item.options) ||
registration.active !== item.enabled
)
return !sameGeneration(registration, item) || registration.active !== item.enabled
})
// Swap: cleanup failures surface as a toast, never propagate, so one
@@ -348,22 +370,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
for (const id of changed) {
const item = desired.get(id)!
const registration = store.registrations[id]
const replaced =
!registration || registration.version !== item.version || !sameOptions(registration.options, item.options)
const replaced = !registration || !sameGeneration(registration, item)
// Snapshot the running version before it is overwritten: an import
// failure keeps last-good in the resolve phase, and a setup failure
// must not cost the previous version either.
const fallback: Desired | undefined =
replaced && registration
? {
plugin: registration.plugin,
source: registration.source,
target: registration.target,
version: registration.version,
options: registration.options,
enabled: registration.active,
}
: undefined
const fallback = replaced && registration ? toDesired(registration) : undefined
if (replaced) {
if (registration) await deactivateNoisily(id)
// In-place replacement keeps the registration's key position, which
@@ -564,6 +575,7 @@ async function resolvePlugin(
previous: Registration | undefined,
packages: PackageResolver,
install: boolean,
sourceGeneration: (entrypoint: string) => Promise<number>,
) {
// Package entrypoints never change within a session, so a loaded previous
// version needs no re-resolution (which could otherwise hit npm).
@@ -571,9 +583,9 @@ async function resolvePlugin(
return { status: "unchanged" as const, plugin: previous.plugin, version: previous.version }
const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec, install)
if (!entrypoint) return { status: "unsupported" as const }
// The cache-busted specifier doubles as the version: unique per entrypoint
// and mtime, so equal versions mean an identical module.
const version = local ? freshSpecifier(entrypoint, (await stat(new URL(entrypoint))).mtimeMs) : entrypoint
// Content remains stable across the several mtimes one save may expose to
// filesystem watchers, while the generation keeps reverted modules fresh.
const version = local ? freshSpecifier(entrypoint, await sourceGeneration(entrypoint)) : entrypoint
if (previous && previous.version === version && sameOptions(previous.options, options))
return { status: "unchanged" as const, plugin: previous.plugin, version }
const mod: { readonly default?: unknown } = await import(version)
@@ -587,7 +599,7 @@ function toRegistration(item: Desired): Registration {
source: item.source,
target: item.target,
version: item.version,
options: item.options,
options: snapshotOptions(item.options),
active: false,
routes: {},
slots: {},
@@ -596,10 +608,32 @@ function toRegistration(item: Desired): Registration {
}
}
function toDesired(item: Registration): Desired {
return {
plugin: item.plugin,
source: item.source,
target: item.target,
version: item.version,
options: item.options,
enabled: item.active,
}
}
function sameOptions(a: Registration["options"], b: Registration["options"]) {
return isDeepEqual(a ?? null, b ?? null)
}
function sameGeneration(
a: Pick<Registration, "version" | "options"> | undefined,
b: Pick<Registration, "version" | "options">,
) {
return a?.version === b.version && sameOptions(a.options, b.options)
}
function snapshotOptions(options: Registration["options"]) {
return options ? structuredClone(unwrap(options)) : undefined
}
async function resolveLocal(url: URL) {
const info = await stat(url)
if (info.isFile()) return url.href
+7 -9
View File
@@ -45,15 +45,13 @@ export function localSource(spec: string, directory: string) {
return undefined
}
// Key local plugin imports by mtime so edited sources re-import fresh instead
// of hitting the ESM cache. Bun ignores query params when caching file:// URL
// imports, so bust with a plain path there; Node keys its cache on the full
// URL. Mirrors the core plugin supervisor's loader.
// The mtime is truncated to whole milliseconds: a fractional mtimeMs puts a
// dot in the query, and Bun's compiled binaries then skip runtime plugin
// hooks for the import, breaking JSX/solid rewriting for external plugins.
export function freshSpecifier(entrypoint: string, mtime: number) {
const version = Math.trunc(mtime)
// Key local plugin imports by a numeric source version so edited sources
// re-import fresh instead of hitting the ESM cache. Bun ignores query params
// when caching file:// URL imports, so bust with a plain path there; Node keys
// its cache on the full URL. Fractional versions break Bun's runtime JSX/solid
// plugin hooks, so always truncate them.
export function freshSpecifier(entrypoint: string, sourceVersion: number) {
const version = Math.trunc(sourceVersion)
if (typeof Bun !== "undefined") return `${fileURLToPath(entrypoint).replaceAll("\\", "/")}?mtime=${version}`
return `${entrypoint}?mtime=${version}`
}
+7 -5
View File
@@ -275,6 +275,9 @@ export function Session(props: { verticalTabsWidth: number }) {
const sessionTabs = useSessionTabs()
const [awayFromBottom, setAwayFromBottom] = createSignal(false)
const [latestHovered, setLatestHovered] = createSignal(false)
createEffect(() => {
if (!awayFromBottom()) setLatestHovered(false)
})
const clearMessageNavigation = () => {
setNavigationSlack(0)
@@ -1196,16 +1199,15 @@ export function Session(props: { verticalTabsWidth: number }) {
<box height={1} flexShrink={0} flexDirection="row" justifyContent="flex-end">
<Show when={awayFromBottom()}>
<box
id="session-jump-to-latest"
paddingLeft={1}
paddingRight={1}
backgroundColor={
latestHovered() ? theme.background.action.primary.focused : theme.background.action.primary.default
}
onMouseOver={() => setLatestHovered(true)}
onMouseOut={() => setLatestHovered(false)}
onMouseUp={toBottom}
>
<text fg={latestHovered() ? theme.text.action.primary.focused : theme.text.action.primary.default}>
<text
fg={latestHovered() ? theme.text.action.secondary.hovered : theme.text.action.secondary.default}
>
Jump to latest
</text>
</box>
@@ -1,18 +1,26 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { RGBA, TextRenderable } from "@opentui/core"
import { testRender } from "@opentui/solid"
import type { Context } from "@opencode-ai/plugin/tui/context"
import { PromptFooter } from "../../src/feature-plugins/prompt/footer"
test("prompt footer separates simultaneous subagent, shell, and usage status", async () => {
const color = RGBA.fromInts(200, 200, 200)
const subdued = RGBA.fromInts(100, 100, 100)
const dispatched: string[] = []
const context = {
location: { directory: "/workspace" },
theme: { text: { default: color, subdued: color } },
theme: {
text: {
default: color,
subdued,
},
},
keymap: {
shortcuts: (id: string) =>
id === "session.child.first" ? ["ctrl+j"] : id === "command.palette.show" ? ["ctrl+p"] : [],
dispatch: (id: string) => dispatched.push(id),
},
data: {
session: {
@@ -39,6 +47,14 @@ test("prompt footer separates simultaneous subagent, shell, and usage status", a
await app.renderOnce()
expect(app.captureCharFrame()).toContain("ctrl+j 1 subagent · 1 shell · $1.00")
expect(app.captureCharFrame()).toContain("ctrl+p commands")
await app.mockMouse.moveTo(2, 0)
const live = app.renderer.root.getChildren()[0]?.getChildren()[0]?.getChildren()[0]
expect(live).toBeInstanceOf(TextRenderable)
expect((live as TextRenderable).fg.toInts()).toEqual(color.toInts())
await app.mockMouse.click(2, 0)
expect(dispatched).toEqual(["session.child.first"])
} finally {
app.renderer.destroy()
}
+15 -5
View File
@@ -271,30 +271,40 @@ test("a save whose setup throws restores the previous version", async () => {
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
await mkdir(directory, { recursive: true })
const marker = path.join(tmp.path, "a.txt")
const markerB = path.join(tmp.path, "b.txt")
const source = path.join(directory, "a.ts")
const sourceB = path.join(directory, "b.ts")
await writeFile(source, lifecycleSource(marker, "test.a", "a1"))
await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b1"))
await using app = await bootApp(tmp.path)
const read = () => readFile(marker, "utf8")
const readB = () => readFile(markerB, "utf8")
expect(await until(read, (value) => value === "a1:setup\n")).toBe("a1:setup\n")
expect(await until(readB, (value) => value === "b1:setup\n")).toBe("b1:setup\n")
// The module imports fine but its setup throws — unlike an import failure,
// the swap has already torn down a1, so keep-last-good means restoring it.
await writeFile(
source,
`
const broken = `
export default {
id: "test.a",
setup: async () => {
throw new Error("setup boom")
},
}
`,
)
`
await writeFile(source, broken)
expect(await until(read, (value) => value === "a1:setup\na1:cleanup\na1:setup\n")).toBe(
"a1:setup\na1:cleanup\na1:setup\n",
)
// Duplicate notifications for unchanged contents must not retry the broken
// generation and cycle the restored plugin again.
await writeFile(source, broken)
await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b2"))
expect(await until(readB, (value) => value?.includes("b2:setup") ?? false)).toBe("b1:setup\nb1:cleanup\nb2:setup\n")
expect(await read()).toBe("a1:setup\na1:cleanup\na1:setup\n")
// Fixing the file swaps out the restored version normally.
await writeFile(source, lifecycleSource(marker, "test.a", "a2"))
expect(await until(read, (value) => value?.includes("a2:setup") ?? false)).toBe(
@@ -154,6 +154,23 @@ test("merges partial documents with the selected OpenCode defaults", () => {
expect(theme.background.action.destructive.pressed).toBeInstanceOf(RGBA)
})
test("resolves custom secondary actions and falls back per mode", () => {
const document = {
version: 2,
light: {
text: { action: { secondary: { default: "#123456", $hovered: "#234567" } } },
},
dark: {},
} as const
const lightTheme = resolveSource(document, "light")
const darkTheme = resolveSource(document, "dark")
expect(lightTheme.text.action.secondary.default.toInts()).toEqual([18, 52, 86, 255])
expect(lightTheme.text.action.secondary.hovered.toInts()).toEqual([35, 69, 103, 255])
expect(darkTheme.text.action.secondary.default).toBe(darkTheme.text.subdued)
expect(darkTheme.text.action.secondary.hovered).toBe(darkTheme.text.default)
})
test("expands user structural fallbacks before merging defaults", () => {
const expanded = resolveSource(
{
@@ -214,6 +231,8 @@ test("resolves matched action variants and states", () => {
expect(theme.text.action.primary.pressed).toBeInstanceOf(RGBA)
expect(theme.text.action.primary.hovered).toBeInstanceOf(RGBA)
expect(theme.text.action.primary.selected).toBeInstanceOf(RGBA)
expect(theme.text.action.secondary.default).toBe(theme.text.subdued)
expect(theme.text.action.secondary.hovered).toBe(theme.text.default)
expect(theme.background.action.primary.pressed).toBeInstanceOf(RGBA)
expect(theme.background.action.primary.hovered).toBeInstanceOf(RGBA)
expect(theme.background.action.primary.selected).toBeInstanceOf(RGBA)
@@ -30,6 +30,8 @@ test("migrates resolved V1 modes into V2 tokens", () => {
expect(migrated.dark.background?.surface?.offset).toBe("$hue.neutral.700")
expect(migrated.dark.background?.surface?.overlay).toBe("$hue.neutral.600")
expect(migrated.light.text?.action?.primary?.default).toBe("$text.default")
expect(migrated.light.text?.action?.secondary?.default).toBe("$text.subdued")
expect(migrated.light.text?.action?.secondary?.$hovered).toBe("$text.default")
expect(migrated.light.background?.action?.primary?.$selected).toBe("transparent")
expect(resolved.background.surface.offset.toInts()).toEqual(legacy.backgroundPanel.toInts())
expect(resolved.background.surface.overlay.toInts()).toEqual(legacy.backgroundElement.toInts())
@@ -42,6 +44,8 @@ test("migrates resolved V1 modes into V2 tokens", () => {
expect(resolved.hue.interactive[800].toInts()).toEqual(legacy.primary.toInts())
expect(resolved.background.action.primary.selected.toInts()).toEqual([0, 0, 0, 0])
expect(resolved.text.action.primary.selected.toInts()).toEqual(legacy.primary.toInts())
expect(resolved.text.action.secondary.default.toInts()).toEqual(legacy.textMuted.toInts())
expect(resolved.text.action.secondary.hovered.toInts()).toEqual(legacy.text.toInts())
expect(resolved.background.feedback.error.default.toInts()).toEqual(legacy.background.toInts())
expect(resolved.contextual.elevated.background.default.toInts()).toEqual(legacy.backgroundPanel.toInts())
expect(resolved.contextual.elevated.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
@@ -52,25 +52,25 @@ Semantic values can reference another token by prefixing its path with `$`,
for example `$text.default`. Stateful tokens inherit their `default`
value when a state is omitted.
| Group | Tokens |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `text` | `text.default`<br />`text.subdued` |
| `text.action` | `text.action.primary.default`<br />`text.action.primary.$hovered`<br />`text.action.primary.$focused`<br />`text.action.primary.$pressed`<br />`text.action.primary.$selected`<br />`text.action.primary.$disabled`<br />`text.action.destructive.default`<br />`text.action.destructive.$hovered`<br />`text.action.destructive.$focused`<br />`text.action.destructive.$pressed`<br />`text.action.destructive.$selected`<br />`text.action.destructive.$disabled` |
| `text.formfield` | `text.formfield.default`<br />`text.formfield.$hovered`<br />`text.formfield.$focused`<br />`text.formfield.$pressed`<br />`text.formfield.$selected`<br />`text.formfield.$disabled` |
| `text.feedback` | `text.feedback.error.default`<br />`text.feedback.error.subdued`<br />`text.feedback.warning.default`<br />`text.feedback.warning.subdued`<br />`text.feedback.success.default`<br />`text.feedback.success.subdued`<br />`text.feedback.info.default`<br />`text.feedback.info.subdued` |
| `background` | `background.default` |
| `background.surface` | `background.surface.offset`<br />`background.surface.overlay` |
| `background.action` | `background.action.primary.default`<br />`background.action.primary.$hovered`<br />`background.action.primary.$focused`<br />`background.action.primary.$pressed`<br />`background.action.primary.$selected`<br />`background.action.primary.$disabled`<br />`background.action.destructive.default`<br />`background.action.destructive.$hovered`<br />`background.action.destructive.$focused`<br />`background.action.destructive.$pressed`<br />`background.action.destructive.$selected`<br />`background.action.destructive.$disabled` |
| `background.formfield` | `background.formfield.default`<br />`background.formfield.$hovered`<br />`background.formfield.$focused`<br />`background.formfield.$pressed`<br />`background.formfield.$selected`<br />`background.formfield.$disabled` |
| `background.feedback` | `background.feedback.error.default`<br />`background.feedback.warning.default`<br />`background.feedback.success.default`<br />`background.feedback.info.default` |
| `border` | `border.default` |
| `scrollbar` | `scrollbar.default` |
| `diff.text` | `diff.text.added`<br />`diff.text.removed`<br />`diff.text.context`<br />`diff.text.hunkHeader` |
| `diff.background` | `diff.background.added`<br />`diff.background.removed`<br />`diff.background.context` |
| `diff.highlight` | `diff.highlight.added`<br />`diff.highlight.removed` |
| `diff.lineNumber` | `diff.lineNumber.text`<br />`diff.lineNumber.background.added`<br />`diff.lineNumber.background.removed` |
| `syntax` | `syntax.comment`<br />`syntax.keyword`<br />`syntax.function`<br />`syntax.variable`<br />`syntax.string`<br />`syntax.number`<br />`syntax.type`<br />`syntax.operator`<br />`syntax.punctuation` |
| `markdown` | `markdown.text`<br />`markdown.heading`<br />`markdown.link`<br />`markdown.linkText`<br />`markdown.code`<br />`markdown.blockQuote`<br />`markdown.emphasis`<br />`markdown.strong`<br />`markdown.horizontalRule`<br />`markdown.listItem`<br />`markdown.listEnumeration`<br />`markdown.image`<br />`markdown.imageText`<br />`markdown.codeBlock` |
| Group | Tokens |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `text` | `text.default`<br />`text.subdued` |
| `text.action` | `text.action.primary.default`<br />`text.action.primary.$hovered`<br />`text.action.primary.$focused`<br />`text.action.primary.$pressed`<br />`text.action.primary.$selected`<br />`text.action.primary.$disabled`<br />`text.action.secondary.default`<br />`text.action.secondary.$hovered`<br />`text.action.secondary.$focused`<br />`text.action.secondary.$pressed`<br />`text.action.secondary.$selected`<br />`text.action.secondary.$disabled`<br />`text.action.destructive.default`<br />`text.action.destructive.$hovered`<br />`text.action.destructive.$focused`<br />`text.action.destructive.$pressed`<br />`text.action.destructive.$selected`<br />`text.action.destructive.$disabled` |
| `text.formfield` | `text.formfield.default`<br />`text.formfield.$hovered`<br />`text.formfield.$focused`<br />`text.formfield.$pressed`<br />`text.formfield.$selected`<br />`text.formfield.$disabled` |
| `text.feedback` | `text.feedback.error.default`<br />`text.feedback.error.subdued`<br />`text.feedback.warning.default`<br />`text.feedback.warning.subdued`<br />`text.feedback.success.default`<br />`text.feedback.success.subdued`<br />`text.feedback.info.default`<br />`text.feedback.info.subdued` |
| `background` | `background.default` |
| `background.surface` | `background.surface.offset`<br />`background.surface.overlay` |
| `background.action` | `background.action.primary.default`<br />`background.action.primary.$hovered`<br />`background.action.primary.$focused`<br />`background.action.primary.$pressed`<br />`background.action.primary.$selected`<br />`background.action.primary.$disabled`<br />`background.action.secondary.default`<br />`background.action.secondary.$hovered`<br />`background.action.secondary.$focused`<br />`background.action.secondary.$pressed`<br />`background.action.secondary.$selected`<br />`background.action.secondary.$disabled`<br />`background.action.destructive.default`<br />`background.action.destructive.$hovered`<br />`background.action.destructive.$focused`<br />`background.action.destructive.$pressed`<br />`background.action.destructive.$selected`<br />`background.action.destructive.$disabled` |
| `background.formfield` | `background.formfield.default`<br />`background.formfield.$hovered`<br />`background.formfield.$focused`<br />`background.formfield.$pressed`<br />`background.formfield.$selected`<br />`background.formfield.$disabled` |
| `background.feedback` | `background.feedback.error.default`<br />`background.feedback.warning.default`<br />`background.feedback.success.default`<br />`background.feedback.info.default` |
| `border` | `border.default` |
| `scrollbar` | `scrollbar.default` |
| `diff.text` | `diff.text.added`<br />`diff.text.removed`<br />`diff.text.context`<br />`diff.text.hunkHeader` |
| `diff.background` | `diff.background.added`<br />`diff.background.removed`<br />`diff.background.context` |
| `diff.highlight` | `diff.highlight.added`<br />`diff.highlight.removed` |
| `diff.lineNumber` | `diff.lineNumber.text`<br />`diff.lineNumber.background.added`<br />`diff.lineNumber.background.removed` |
| `syntax` | `syntax.comment`<br />`syntax.keyword`<br />`syntax.function`<br />`syntax.variable`<br />`syntax.string`<br />`syntax.number`<br />`syntax.type`<br />`syntax.operator`<br />`syntax.punctuation` |
| `markdown` | `markdown.text`<br />`markdown.heading`<br />`markdown.link`<br />`markdown.linkText`<br />`markdown.code`<br />`markdown.blockQuote`<br />`markdown.emphasis`<br />`markdown.strong`<br />`markdown.horizontalRule`<br />`markdown.listItem`<br />`markdown.listEnumeration`<br />`markdown.image`<br />`markdown.imageText`<br />`markdown.codeBlock` |
### Contexts