Compare commits

...
Author SHA1 Message Date
James Long 919ffe549a refactor(client): simplify service replacement 2026-09-01 17:25:44 +00:00
James Long d7e642b80d refactor(client): reuse mismatch reason for replacement 2026-09-01 17:24:49 +00:00
James Long 7cee402232 feat(client): add service replacement 2026-09-01 17:13:54 +00:00
Shoubhit Dash 9f01e2b548 fix(plugin): make load failures easier to diagnose (#46594) 2026-09-01 22:29:35 +05:30
Shoubhit Dash 7856515140 feat(tui): add shareable stats poster (#46563) 2026-09-01 22:21:52 +05:30
Dax Raad 818804e181 chore: refresh bun lockfile 2026-09-01 12:19:40 -04:00
Aiden Cline ce6247bd2f fix(core): estimate context growth before compaction (#46543) 2026-09-01 10:48:34 -05:00
opencode-agent[bot]andrekram1-node 0465328297 test(tui): use real session id in home fixture (#46618)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-01 10:39:04 -05:00
Dax Raad 43d09b9d75 fix(server): await plugin activation when checking updates 2026-09-01 11:16:02 -04:00
Dax Raad 7730cec123 fix(tui): limit cached non-tab session families 2026-09-01 11:14:01 -04:00
opencode-agent[bot]andJames Long 367ee47d7e fix(tui): tone down patch failure details (#46470)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-09-01 11:07:03 -04:00
David Hillandjlongster c596a8f11d fix(tui): simplify narrow interrupt footer (#46533)
Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
2026-09-01 11:06:25 -04:00
Kit Langton 30f998c5b9 feat(tui): make session preview tabs the default (#46497) 2026-09-01 11:02:11 -04:00
Kit Langton d4b4dd17cc refactor(core): rename plugin flush to awaitActivation 2026-09-01 10:48:08 -04:00
Dax Raad f330f3e02b feat(cli): check and update plugins with flat inventory 2026-09-01 10:46:47 -04:00
opencode-agent[bot]andnexxeln a9c27209bb fix(tui): open rename dialog from tab menu (#46603)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-01 19:58:36 +05:30
98 changed files with 2736 additions and 481 deletions
+1 -13
View File
@@ -3213,8 +3213,6 @@
"@upstash/redis": ["@upstash/redis@1.38.0", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg=="],
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
@@ -5453,8 +5451,6 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"sury": ["sury@11.0.0-alpha.4", "", { "peerDependencies": { "rescript": "12.x" }, "optionalPeers": ["rescript"] }, "sha512-oeG/GJWZvQCKtGPpLbu0yCZudfr5LxycDo5kh7SJmKHDPCsEPJssIZL2Eb4Tl7g9aPEvIDuRrkS+L0pybsMEMA=="],
"svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
@@ -5669,8 +5665,6 @@
"uuid": ["uuid@14.0.2", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ=="],
"valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="],
"validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
@@ -5819,9 +5813,7 @@
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
@@ -5853,8 +5845,6 @@
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
"zod-openapi": ["zod-openapi@5.4.6", "", { "peerDependencies": { "zod": "^3.25.74 || ^4.0.0" } }, "sha512-P2jsOOBAq/6hCwUsMCjUATZ8szkMsV5VAwZENfyxp2Hc/XPJQpVwAgevWZc65xZauCwWB9LAn7zYeiCJFAEL+A=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="],
@@ -6609,8 +6599,6 @@
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
"postcss-css-variables/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
+18
View File
@@ -199,6 +199,24 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
package: Argument.string("package").pipe(Argument.withDescription("npm registry or Git package specifier")),
},
}),
Spec.make("check", {
description: "Check package plugins for updates",
params: {
target: Argument.string("target").pipe(
Argument.withDescription("Configured package target"),
Argument.optional,
),
},
}),
Spec.make("update", {
description: "Update package plugins",
params: {
target: Argument.string("target").pipe(
Argument.withDescription("Configured package target; omit to update all outdated plugins"),
Argument.optional,
),
},
}),
Spec.make("remove", {
description: "Remove a plugin from global configuration",
params: {
@@ -0,0 +1,14 @@
import { EOL } from "node:os"
import { Effect, Option } from "effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { format, inspect } from "./inventory"
export default Runtime.handler(
Commands.commands.plugin.commands.check,
Effect.fn("cli.plugin.check")(function* (input) {
const result = yield* inspect(Option.getOrUndefined(input.target))
process.stdout.write((format(result.items) || "No package plugins found") + EOL)
if (result.items.some((item) => item.error)) process.exitCode = 1
}),
)
@@ -0,0 +1,101 @@
import { EOL } from "node:os"
import { Cause, Effect, Exit } from "effect"
import { OpenCode, type PluginInfo } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Npm } from "@opencode-ai/util/npm"
import { Config } from "../../../config"
import { ServiceConfig } from "../../../services/service-config"
export interface Item {
readonly runtime: "Server" | "TUI"
readonly target: string
readonly name: string
readonly version?: string
readonly outdated: boolean
readonly error?: string
}
export const inspect = Effect.fn("cli.plugin.inspect")(function* (selected?: string) {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const location = { directory: process.cwd() }
const listed = yield* Effect.promise(() => client.plugin.list({ location }))
const serverTargets = new Set(
listed.data.flatMap((plugin) => (plugin.source.type === "package" ? [plugin.source.target] : [])),
)
const server =
selected === undefined || serverTargets.has(selected)
? yield* Effect.promise(() => client.plugin.check({ location, ...(selected ? { target: selected } : {}) }))
: listed
const serverItems = server.data.flatMap((plugin): Item[] => {
if (plugin.source.type !== "package") return []
if (selected !== undefined && plugin.source.target !== selected) return []
return [
{
runtime: "Server",
target: plugin.source.target,
name: plugin.id ?? plugin.source.target,
...(plugin.source.version ? { version: displayVersion(plugin.source.version) } : {}),
outdated: plugin.source.outdated === true,
...(plugin.state.status === "failed" ? { error: plugin.state.error } : {}),
},
]
})
const config = yield* Config.Service
const info = yield* config.get()
const configured = [
...new Set(
(info.plugins ?? []).flatMap((entry) => {
const target = typeof entry === "string" ? entry : entry.package
if (target.startsWith("-") || target === "*" || target.endsWith(".*") || target.startsWith("opencode.")) return []
return [target]
}),
),
]
const tuiTargets = yield* Effect.promise(async () => {
const installable = await Promise.all(configured.map((target) => Npm.isInstallablePackage(target)))
return configured.filter((target, index) => installable[index] && (selected === undefined || target === selected))
})
const npm = yield* Npm.Service
const tuiItems = yield* Effect.forEach(
tuiTargets,
(target) =>
Effect.gen(function* () {
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
const outdated = yield* npm.check(target).pipe(Effect.exit)
return {
runtime: "TUI" as const,
target,
name: target,
...(installed.version ? { version: displayVersion(installed.version) } : {}),
outdated: Exit.isSuccess(outdated) && outdated.value,
...(Exit.isFailure(outdated) ? { error: Cause.pretty(outdated.cause) } : {}),
}
}),
{ concurrency: "unbounded" },
)
const items = [...serverItems, ...tuiItems]
if (selected !== undefined && !items.length) return yield* Effect.fail(new Error(`Plugin is not configured: ${selected}`))
return { client, location, items }
})
export function displayVersion(version: string) {
return /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(version) ? version.slice(0, 7) : version
}
export function format(items: readonly Item[]) {
return ["Server", "TUI"]
.flatMap((runtime) => {
const rows = items.filter((item) => item.runtime === runtime)
if (!rows.length) return []
return [
runtime,
...rows.map(
(item) =>
` ${item.name}${item.version ? ` ${item.version}` : ""} (${item.error ? "check failed" : item.outdated ? "update available" : "current"})`,
),
]
})
.join(EOL)
}
@@ -8,7 +8,9 @@ import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { Config } from "../../../config"
import { Global } from "@opencode-ai/util/global"
import { discoverTuiPlugins, localPluginDirectories } from "@opencode-ai/tui/plugin/discovery"
import { Npm } from "@opencode-ai/util/npm"
import { fileURLToPath } from "node:url"
import { discoverTuiPlugins, localPluginDirectories, localSource } from "@opencode-ai/tui/plugin/discovery"
export default Runtime.handler(
Commands.commands.plugin.commands.list,
@@ -22,15 +24,23 @@ export default Runtime.handler(
const discovered = yield* Effect.promise(() =>
localPluginDirectories(process.cwd(), global.config).then(discoverTuiPlugins),
)
const npm = yield* Npm.Service
const configured = yield* Effect.forEach(info.plugins ?? [], (entry) =>
Effect.gen(function* () {
const target = typeof entry === "string" ? entry : entry.package
if (target.startsWith("-") || target === "*" || target.endsWith(".*") || target.startsWith("opencode."))
return []
const local = localSource(target, path.dirname(config.path))
if (local) return [{ target: fileURLToPath(local), version: "local" }]
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(target)))) return []
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
if (!installed.entrypoint) return []
return [{ target, version: installed.version }]
}),
)
const output = format(
response.data,
[
...(info.plugins ?? []).flatMap((entry) => {
const target = typeof entry === "string" ? entry : entry.package
return target.startsWith("-") ? [] : [{ target, source: "configured" as const }]
}),
...discovered.map((target) => ({ target, source: "discovered" as const })),
],
[...configured.flat(), ...discovered.map((target) => ({ target, version: "local" }))],
input.builtin,
)
if (!output) {
@@ -43,37 +53,50 @@ export default Runtime.handler(
export function format(
plugins: readonly PluginInfo[],
tui: ReadonlyArray<{ readonly target: string; readonly source: "configured" | "discovered" }>,
tui: ReadonlyArray<{ readonly target: string; readonly version?: string }>,
builtin = false,
) {
const server = plugins
.filter((plugin) => builtin || plugin.source.type !== "builtin")
.toSorted((a, b) => name(a).localeCompare(name(b)))
.map((plugin) => `${name(plugin)} (${plugin.state.status})`)
const advertised = plugins.flatMap((plugin) =>
plugin.state.status !== "active" || !plugin.features.tui
? []
: plugin.source.type === "package"
? [{ target: plugin.source.target, source: "advertised" as const }]
: plugin.source.type === "local"
? [{ target: path.dirname(plugin.source.path), source: "advertised" as const }]
: [],
)
const targets = [...tui, ...advertised]
.map((plugin) => ({
id: plugin.id ?? "-",
version:
plugin.source.type === "package"
? (plugin.source.version ?? "-")
: plugin.source.type === "local"
? "local"
: "-",
target:
plugin.source.type === "package"
? plugin.source.target
: plugin.source.type === "local"
? plugin.source.path
: plugin.source.type,
}))
const targets = tui
.filter(
(item) =>
!plugins.some((plugin) =>
plugin.source.type === "package"
? plugin.source.target === item.target
: plugin.source.type === "local" &&
(plugin.source.path === item.target ||
(plugin.features.tui &&
path.dirname(plugin.source.path) ===
(item.version === "local" && path.extname(item.target) ? path.dirname(item.target) : item.target))),
),
)
.filter((plugin, index, all) => all.findIndex((candidate) => candidate.target === plugin.target) === index)
.toSorted((a, b) => a.target.localeCompare(b.target))
.map((plugin) => `${plugin.target} (${plugin.source})`)
return [
targets.length ? ["TUI", ...targets].join(EOL) : undefined,
server.length ? ["Server", ...server].join(EOL) : undefined,
]
.filter((section) => section !== undefined)
.join(EOL + EOL)
}
function name(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.target
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
.map((plugin) => ({ id: "-", version: plugin.version ?? "-", target: plugin.target }))
const rows = [...server, ...targets]
.toSorted((a, b) => a.id.localeCompare(b.id) || a.target.localeCompare(b.target))
.map((item) => [
item.id,
/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(item.version) ? item.version.slice(0, 7) : item.version,
item.target,
])
if (!rows.length) return ""
const table = [["ID", "VERSION", "SOURCE"], ...rows]
const widths = [0, 1].map((index) => Math.max(...table.map((row) => row[index].length)))
return table.map((row) => `${row[0].padEnd(widths[0])} ${row[1].padEnd(widths[1])} ${row[2]}`).join(EOL)
}
@@ -0,0 +1,44 @@
import { EOL } from "node:os"
import { Cause, Effect, Exit, Option } from "effect"
import { Npm } from "@opencode-ai/util/npm"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { inspect } from "./inventory"
export default Runtime.handler(
Commands.commands.plugin.commands.update,
Effect.fn("cli.plugin.update")(function* (input) {
const result = yield* inspect(Option.getOrUndefined(input.target))
for (const item of result.items) {
if (!item.error) continue
process.stderr.write(`Failed to check ${item.runtime} plugin "${item.name}": ${item.error}${EOL}`)
}
const selected = result.items.filter((item) => item.outdated)
if (!selected.length) {
process.stdout.write("No plugin updates available" + EOL)
if (result.items.some((item) => item.error)) process.exitCode = 1
return
}
const npm = yield* Npm.Service
const updated = yield* Effect.forEach(
selected,
(item) =>
(item.runtime === "Server"
? Effect.promise(() => result.client.plugin.update({ location: result.location, target: item.target }))
: npm.update(item.target, { subpaths: ["tui"] }).pipe(Effect.asVoid)
).pipe(Effect.exit, Effect.map((result) => ({ item, result }))),
{ concurrency: "unbounded" },
)
for (const item of updated) {
if (Exit.isSuccess(item.result)) {
process.stdout.write(`Updated ${item.item.runtime} plugin "${item.item.name}"${EOL}`)
continue
}
process.stderr.write(
`Failed to update ${item.item.runtime} plugin "${item.item.name}": ${Cause.pretty(item.result.cause)}${EOL}`,
)
process.exitCode = 1
}
}),
)
+19 -84
View File
@@ -1,5 +1,7 @@
import { ClientError, OpenCode, type SessionStatsInfo } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { TokenUsage } from "@opencode-ai/schema/token-usage"
import { activityCalendar } from "@opencode-ai/util/activity-calendar"
import { Effect, Option } from "effect"
import { EOL } from "node:os"
import { Commands } from "../commands"
@@ -91,7 +93,7 @@ type RenderOptions = {
const colors = terminalPalette()
export function renderStats(stats: SessionStatsInfo, options: RenderOptions) {
const totalTokens = tokenTotal(stats.tokens)
const totalTokens = TokenUsage.total(stats.tokens)
const toolTotals = stats.tools.mode === "none" ? undefined : stats.tools.totals
const terminalTools = toolTotals ? toolTotals.succeeded + toolTotals.failed : 0
const toolRate = !toolTotals || terminalTools === 0 ? undefined : (toolTotals.succeeded / terminalTools) * 100
@@ -167,43 +169,23 @@ function renderActivity(
color: boolean,
width: number,
) {
const values = new Map(activity.map((day) => [day.date, day.steps]))
const rangeStart = dateOrdinal(new Date(from))
const rangeEnd = dateOrdinal(new Date(to - 1))
const end = new Date(to - 1)
end.setHours(12, 0, 0, 0)
end.setDate(end.getDate() + (7 - mondayIndex(end) - 1))
const start = new Date(from)
start.setHours(12, 0, 0, 0)
start.setDate(start.getDate() - mondayIndex(start))
const maxWeeks = Math.max(1, Math.min(53, width - 4))
const totalWeeks = Math.floor((dateOrdinal(end) - dateOrdinal(start)) / 7) + 1
const latest = new Date(end)
latest.setDate(latest.getDate() - (maxWeeks - 1) * 7)
if (start < latest) start.setTime(latest.getTime())
const active = [...values.values()].filter((value) => value > 0)
const levels = [...new Set(active)].sort((a, b) => a - b)
const weekStarts = Array.from({ length: Math.floor((dateOrdinal(end) - dateOrdinal(start)) / 7) + 1 }, (_, week) => {
const date = new Date(start)
date.setDate(date.getDate() + week * 7)
return date
})
const weeks = weekStarts.map((week) =>
Array.from({ length: 7 }, (_, day) => {
const date = new Date(week)
date.setDate(date.getDate() + day)
const ordinal = dateOrdinal(date)
if (ordinal < rangeStart || ordinal > rangeEnd) return " "
return activityGlyph(values.get(dateKey(date)) ?? 0, levels, color)
}),
)
const calendar = activityCalendar({ activity, from, to, maxWeeks: width - 4 })
const months = calendar.months
.map((month, index) =>
(index === calendar.months.length - 1 || month.label.length <= month.span ? month.label : "").padEnd(month.span),
)
.join("")
.trimEnd()
const weekdays = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
return [
style(totalWeeks > maxWeeks ? `activity · last ${maxWeeks} weeks` : "activity", `1;${colors.primary}`, color),
` ${style(monthLabels(weekStarts), "2", color)}`,
style(
calendar.clipped ? `activity · last ${calendar.weeks.length} weeks` : "activity",
`1;${colors.primary}`,
color,
),
` ${style(months, "2", color)}`,
...weekdays.flatMap((label, day) => [
`${style(label, "2", color)} ${weeks.map((week) => week[day]).join("")}`,
`${style(label, "2", color)} ${calendar.weeks.map((week) => (week[day].level < 0 ? " " : paintActivity(week[day].level, color))).join("")}`,
...(day === weekdays.length - 1 ? [] : [""]),
]),
"",
@@ -238,7 +220,7 @@ function renderModels(stats: SessionStatsInfo, limit: number, width: number) {
`${item.model.providerID}/${item.model.id}${item.model.variant ? `#${item.model.variant}` : ""}`,
width,
),
` ${formatNumber(tokenTotal(item.tokens))} tokens · ${formatNumber(item.steps)} steps · $${item.cost.toFixed(2)}`,
` ${formatNumber(TokenUsage.total(item.tokens))} tokens · ${formatNumber(item.steps)} steps · $${item.cost.toFixed(2)}`,
]),
...(more > 0 ? ["", `+${more.toLocaleString("en-US")} more model${more === 1 ? "" : "s"}`] : []),
]
@@ -248,7 +230,7 @@ function renderModels(stats: SessionStatsInfo, limit: number, width: number) {
...models.map((item) =>
tableRow(
`${item.model.providerID}/${item.model.id}${item.model.variant ? `#${item.model.variant}` : ""}`,
formatNumber(tokenTotal(item.tokens)),
formatNumber(TokenUsage.total(item.tokens)),
formatNumber(item.steps),
`$${item.cost.toFixed(2)}`,
),
@@ -310,10 +292,6 @@ function truncate(value: string, width: number) {
return value.length <= width ? value : value.slice(0, width - 1) + "…"
}
function tokenTotal(tokens: SessionStatsInfo["tokens"]) {
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
}
function formatNumber(value: number) {
if (value >= 1_000_000_000) return `${trimDecimal(value / 1_000_000_000)}b`
if (value >= 1_000_000) return `${trimDecimal(value / 1_000_000)}m`
@@ -342,13 +320,6 @@ function style(value: string, code: string, color: boolean) {
return color ? `\x1b[${code}m${value}\x1b[0m` : value
}
function activityGlyph(value: number, levels: number[], color: boolean) {
if (value === 0) return paintActivity(0, color)
const index = levels.indexOf(value)
const level = Math.max(1, Math.ceil(((index + 1) / levels.length) * 4))
return paintActivity(level, color)
}
function paintActivity(level: number, color: boolean) {
const glyph = ["·", "░", "▒", "▓", "█"][level]
if (!color) return glyph
@@ -370,39 +341,3 @@ function terminalPalette() {
}
return { primary: "36", activity: ["2;36", "36", "1;36", "1;96"] }
}
function monthLabels(weeks: Date[]) {
const line: string[] = []
weeks.reduce((previous, week, index) => {
const middle = new Date(week)
middle.setDate(middle.getDate() + 3)
const month = middle.getMonth()
if (month === previous) return previous
Intl.DateTimeFormat("en-US", { month: "short" })
.format(middle)
.split("")
.forEach((character, offset) => {
line[index + offset] = character
})
return month
}, -1)
return Array.from({ length: Math.max(weeks.length, line.length) }, (_, index) => line[index] ?? " ")
.join("")
.trimEnd()
}
function mondayIndex(date: Date) {
return (date.getDay() + 6) % 7
}
function dateKey(date: Date) {
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, "0"),
String(date.getDate()).padStart(2, "0"),
].join("-")
}
function dateOrdinal(date: Date) {
return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000)
}
+2
View File
@@ -42,6 +42,8 @@ const Handlers = Runtime.handlers(Commands, {
plugin: {
list: () => import("./commands/handlers/plugin/list"),
add: () => import("./commands/handlers/plugin/add"),
check: () => import("./commands/handlers/plugin/check"),
update: () => import("./commands/handlers/plugin/update"),
remove: () => import("./commands/handlers/plugin/remove"),
},
models: () => import("./commands/handlers/models"),
@@ -54,11 +54,7 @@ function managedService(options: EnsureOptions) {
const reconnectOptions = { ...options, version: undefined }
return {
reconnect: () => Service.ensure(reconnectOptions),
restart: () =>
Effect.gen(function* () {
yield* Service.stop(options)
yield* Service.ensure(reconnectOptions)
}),
restart: () => Service.replace(reconnectOptions).pipe(Effect.asVoid),
}
}
+18
View File
@@ -0,0 +1,18 @@
import { EOL } from "node:os"
import { expect, test } from "bun:test"
import { displayVersion, format, type Item } from "../src/commands/handlers/plugin/inventory"
test("formats server and TUI package update status", () => {
const items: Item[] = [
{ runtime: "Server", target: "server", name: "server.plugin", version: "1.2.3", outdated: true },
{ runtime: "TUI", target: "tui", name: "tui", version: "2.0.0", outdated: false },
]
expect(format(items)).toBe(
["Server", " server.plugin 1.2.3 (update available)", "TUI", " tui 2.0.0 (current)"].join(EOL),
)
})
test("shortens Git revisions", () => {
expect(displayVersion("dadba138b7088d61f937869bbc1ef34b1f91188d")).toBe("dadba13")
})
+42 -21
View File
@@ -2,14 +2,14 @@ import { expect, test } from "bun:test"
import { EOL } from "node:os"
import { format } from "../src/commands/handlers/plugin/list"
test("formats server and TUI plugins in sections without builtins", () => {
test("lists plugin IDs, installed versions, and sources without runtime sections", () => {
expect(
format(
[
{ id: "opencode.agent", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
{
id: "acme.dual",
source: { type: "package", target: "acme-plugin@1.0.0" },
source: { type: "package", target: "acme-plugin@1.0.0", version: "1.0.0" },
state: { status: "active" },
features: { server: true, tui: true },
},
@@ -26,24 +26,22 @@ test("formats server and TUI plugins in sections without builtins", () => {
},
],
[
{ target: "tui-only", source: "configured" },
{ target: "/tmp/local.ts", source: "discovered" },
{ target: "tui-only", version: "2.0.0" },
{ target: "/tmp/local.ts", version: "local" },
{ target: "acme-plugin@1.0.0", version: "1.0.0" },
{ target: "/tmp/local/tui.ts", version: "local" },
],
),
).toBe(
[
"TUI",
"/tmp/local (advertised)",
"/tmp/local.ts (discovered)",
"acme-plugin@1.0.0 (advertised)",
"tui-only (configured)",
"",
"Server",
"acme.dual (active)",
"broken-plugin (failed)",
"local.dual (active)",
].join(EOL),
)
)
.split(EOL)
.map((line) => line.split(/\s{2,}/)),
).toEqual([
["ID", "VERSION", "SOURCE"],
["-", "local", "/tmp/local.ts"],
["-", "-", "broken-plugin"],
["-", "2.0.0", "tui-only"],
["acme.dual", "1.0.0", "acme-plugin@1.0.0"],
["local.dual", "local", "/tmp/local/index.ts"],
])
})
test("includes builtins when requested", () => {
@@ -59,6 +57,29 @@ test("includes builtins when requested", () => {
],
[],
true,
),
).toBe(["Server", "opencode.agent (active)"].join(EOL))
)
.split(EOL)
.map((line) => line.split(/\s{2,}/)),
).toEqual([
["ID", "VERSION", "SOURCE"],
["opencode.agent", "-", "builtin"],
])
})
test("shortens Git commits and leaves absent IDs unknown", () => {
expect(
format(
[
{
source: { type: "package", target: "github:acme/plugin", version: "a".repeat(40) },
state: { status: "active" },
features: { server: true },
},
],
[],
)
.split(EOL)[1]
.split(/\s{2,}/),
).toEqual(["-", "aaaaaaa", "github:acme/plugin"])
expect(format([], [])).toBe("")
})
+14
View File
@@ -48,6 +48,20 @@ describe("stats rendering", () => {
expect(output).not.toContain("$12.34")
})
test("renders shared intensity levels and partial month labels", () => {
const output = renderStats(
{
...stats,
range: { from: new Date(2026, 3, 29).getTime(), to: new Date(2026, 4, 20).getTime() },
activity: [{ date: "2026-04-29", steps: 1 }],
},
options(),
)
expect(output).toContain(" May")
expect(output).not.toContain("Apr")
expect(output.split(/\r?\n/).find((line) => line.startsWith("We "))).toContain("\u2588")
})
test("renders only requested detail tables", () => {
const output = renderStats(stats, options({ tools: true, cost: true }))
expect(output).toContain("COST & TOKENS")
+8
View File
@@ -88,6 +88,13 @@ export type PluginListInput = {
export type PluginListOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
export type PluginListOperation<E = never> = (input?: PluginListInput) => Effect.Effect<PluginListOutput, E>
export type PluginCheckInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly target?: string | undefined
}
export type PluginCheckOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
export type PluginCheckOperation<E = never> = (input?: PluginCheckInput) => Effect.Effect<PluginCheckOutput, E>
export type PluginUpdateInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly target: string
@@ -97,6 +104,7 @@ export type PluginUpdateOperation<E = never> = (input: PluginUpdateInput) => Eff
export interface PluginApi<E = never> {
readonly list: PluginListOperation<E>
readonly check: PluginCheckOperation<E>
readonly update: PluginUpdateOperation<E>
}
@@ -15,6 +15,8 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginCheckInput,
PluginCheckOutput,
PluginUpdateInput,
PluginUpdateOutput,
SessionListInput,
@@ -324,6 +326,13 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
raw["plugin.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointPluginCheck = (raw: RawClient["server.plugin"]) => (input?: PluginCheckInput) =>
preserveEffect<PluginCheckOutput>()(
raw["plugin.check"]({ query: { location: input?.["location"] }, payload: { target: input?.["target"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointPluginUpdate = (raw: RawClient["server.plugin"]) => (input: PluginUpdateInput) =>
preserveEffect<PluginUpdateOutput>()(
raw["plugin.update"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
@@ -333,6 +342,7 @@ const EndpointPluginUpdate = (raw: RawClient["server.plugin"]) => (input: Plugin
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({
list: EndpointPluginList(raw),
check: EndpointPluginCheck(raw),
update: EndpointPluginUpdate(raw),
})
+17 -4
View File
@@ -51,10 +51,20 @@ export const incumbent = Effect.fn("service.incumbent")(function* (
// becomes discoverable. A contender is never killed merely for slow startup.
/** Ensure a healthy, compatible local service is running. */
export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOptions = {}) {
return yield* ensureService(options, false)
})
/** Replace the registered local service while preserving persistent terminals. */
export const replace = Effect.fn("service.replace")(function* (options: EnsureOptions = {}) {
return yield* ensureService(options, true)
})
const ensureService = Effect.fnUntraced(function* (options: EnsureOptions, forceReplacement: boolean) {
const timing = ensureTiming(options)
const contenders = new Set<ServiceContender>()
let timeouts: { readonly info: Info; readonly count: number } | undefined
let announced = false
let replaceCurrent = forceReplacement
let lastSpawn = 0
let spawnDelay = timing.spawnDelay
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) =>
@@ -88,6 +98,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
yield* Effect.logWarning("Background service is unresponsive; recovery cannot preserve persistent terminals")
yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
yield* terminate(info, options, timing)
replaceCurrent = false
timeouts = undefined
lastSpawn = Date.now() - spawnDelay
}
@@ -95,14 +106,15 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
if (service !== undefined) {
spawnDelay = timing.spawnDelay
const compatible = !service.legacy && matchesVersion(service.version, options)
if (compatible && service.state === "ready") {
if (!replaceCurrent && compatible && service.state === "ready") {
yield* Effect.tryPromise(() => PtyHandoff.complete(options.file ?? fallback(), service.info))
return Option.some(service)
}
if (compatible && service.state === "failed")
if (!replaceCurrent && compatible && service.state === "failed")
return yield* Effect.fail(new Error("Background service failed to start"))
if (compatible) return Option.none<LocalService>()
if (!replaceCurrent && compatible) return Option.none<LocalService>()
yield* announce("version-mismatch", service.version)
replaceCurrent = false
if (!service.legacy && service.state === "ready")
yield* Effect.tryPromise(() =>
PtyHandoff.prepare(options.file ?? fallback(), service.info, timing.requestTimeout),
@@ -116,6 +128,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
lastSpawn = 0
return Option.none<LocalService>()
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
else if (info === undefined) replaceCurrent = false
const finished = [...contenders].filter(contenderFinished)
const failure = finished.map(contenderFailure).find((error): error is Error => error !== undefined)
@@ -294,4 +307,4 @@ const terminate = Effect.fnUntraced(function* (info: Info, options: { readonly f
})
/** Effect-based local service lifecycle operations. */
export const Service = { discover, incumbent, ensure, stop, headers, Info }
export const Service = { discover, incumbent, ensure, replace, stop, headers, Info }
@@ -9,6 +9,8 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginCheckInput,
PluginCheckOutput,
PluginUpdateInput,
PluginUpdateOutput,
SessionListInput,
@@ -468,6 +470,19 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
check: (input?: PluginCheckInput, requestOptions?: RequestOptions) =>
request<PluginCheckOutput>(
{
method: "POST",
path: `/api/plugin/check`,
query: { location: input?.["location"] },
body: { target: input?.["target"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
update: (input: PluginUpdateInput, requestOptions?: RequestOptions) =>
request<PluginUpdateOutput>(
{
+13 -1
View File
@@ -18,7 +18,7 @@ export type PluginSource =
export type PluginFeatures = { server?: true; tui?: true; rpc?: true }
export type PluginState = { status: "active" } | { status: "failed"; error: string }
export type PluginState = { status: "active" } | { status: "failed"; error: string; ref?: string }
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
@@ -2585,6 +2585,18 @@ export type PluginListOutput = {
data: Array<PluginInfo>
}
export type PluginCheckInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly target?: { readonly target?: string | undefined }["target"]
}
export type PluginCheckOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<PluginInfo>
}
export type PluginUpdateInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+18 -4
View File
@@ -31,11 +31,21 @@ export async function discover(options: DiscoverOptions = {}) {
/** Ensure a healthy, compatible local service is running. */
export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
return ensureService(options, false)
}
/** Replace the registered local service while preserving persistent terminals. */
export async function replace(options: EnsureOptions = {}): Promise<Endpoint> {
return ensureService(options, true)
}
async function ensureService(options: EnsureOptions, forceReplacement: boolean): Promise<Endpoint> {
const timing = ensureTiming(options)
const deadline = Date.now() + timing.promiseTimeout
const contenders = new Set<ServiceContender>()
let timeouts: { readonly info: Info; readonly count: number } | undefined
let announced = false
let replaceCurrent = forceReplacement
let lastSpawn = 0
let spawnDelay = timing.spawnDelay
@@ -68,6 +78,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
console.warn("Background service is unresponsive; recovery cannot preserve persistent terminals")
await PtyHandoff.clear(options.file ?? fallback())
await terminate(registration.info, options, timing)
replaceCurrent = false
timeouts = undefined
lastSpawn = Date.now() - spawnDelay
}
@@ -77,13 +88,15 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
spawnDelay = timing.spawnDelay
const service = registration.service
const compatible = !service.legacy && matchesVersion(service.version, options)
if (compatible && service.state === "ready") {
if (!replaceCurrent && compatible && service.state === "ready") {
await PtyHandoff.complete(options.file ?? fallback(), service.info)
return service.endpoint
}
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
if (!compatible) {
if (!replaceCurrent && compatible && service.state === "failed")
throw new Error("Background service failed to start")
if (replaceCurrent || !compatible) {
announce("version-mismatch", service.version)
replaceCurrent = false
if (!service.legacy && service.state === "ready")
await PtyHandoff.prepare(options.file ?? fallback(), service.info, timing.requestTimeout)
else {
@@ -95,6 +108,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
lastSpawn = 0
}
} else {
if (registration.info === undefined) replaceCurrent = false
if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now()
const finished = [...contenders].filter(contenderFinished)
const failure = finished.map(contenderFailure).find((error) => error !== undefined)
@@ -252,4 +266,4 @@ function delay(milliseconds: number) {
}
/** Promise-based local service lifecycle operations. */
export const Service = { discover, ensure, stop, headers }
export const Service = { discover, ensure, replace, stop, headers }
+53 -15
View File
@@ -147,7 +147,7 @@ function createSync() {
state.set(key, entry)
entry.promise = (wait ? wait.catch(() => undefined).then(load) : load())
.then(() => {
if (state.get(key) === entry) state.set(key, true)
if (state.get(key) === entry && !entry.invalidated) state.set(key, true)
})
.finally(() => {
if (state.get(key) === entry) state.delete(key)
@@ -493,6 +493,33 @@ export function createData(config: CreateDataInput) {
)
}
function evictSession(sessionID: string) {
if (sessionOutbox.has(sessionID)) return
sync.invalidate(`session.pending:${sessionID}`)
sync.invalidate(`session.message:${sessionID}`)
messageLoads.delete(sessionID)
// Keep unacknowledged submissions until their echo or rollback settles them.
const pending = store.session.pending[sessionID]?.filter((item) => outbox.has(item.id)) ?? []
const messages = store.session.message[sessionID]?.filter((item) => outbox.has(item.id)) ?? []
messageIndex.delete(sessionID)
if (messages.length) messageIndex.set(sessionID, new Map(messages.map((item, index) => [item.id, index])))
setStore(
"session",
produce((draft) => {
delete draft.message[sessionID]
delete draft.messageCursor[sessionID]
delete draft.messageLoading[sessionID]
delete draft.pending[sessionID]
delete draft.input[sessionID]
if (messages.length) draft.message[sessionID] = messages
if (pending.length) {
draft.pending[sessionID] = pending
draft.input[sessionID] = pending.filter((item) => item.type !== "compaction").map((item) => item.id)
}
}),
)
}
function removeSession(sessionID: string) {
activeUpdates?.set(sessionID, undefined)
store.session.pending[sessionID]?.forEach((item) => outbox.delete(item.id))
@@ -1260,9 +1287,11 @@ export function createData(config: CreateDataInput) {
return creating.has(sessionID)
},
remember(info: SessionInfo) {
setStore("session", "info", info.id, reconcile(info))
sync.complete(`session:${info.id}`)
registerSession(info.id)
batch(() => {
setStore("session", "info", info.id, reconcile(info))
sync.complete(`session:${info.id}`)
registerSession(info.id)
})
},
setStatus(sessionID: string, status: DataSessionStatus) {
setSessionActive(sessionID, status)
@@ -1273,6 +1302,13 @@ export function createData(config: CreateDataInput) {
family(sessionID: string) {
return store.session.family[resolveRoot(sessionID)] ?? []
},
/** Clear heavy cached data for the root and all known descendants. */
evict(sessionID: string) {
const root = resolveRoot(sessionID)
batch(() => {
for (const id of new Set([root, sessionID, ...(store.session.family[root] ?? [])])) evictSession(id)
})
},
cost(sessionID: string) {
const session = store.session.info[sessionID]
if (!session) return 0
@@ -1484,17 +1520,19 @@ export function createData(config: CreateDataInput) {
: [],
])
const sessions = [info, ...children]
setStore(
"session",
"info",
produce((draft) => {
for (const session of sessions) draft[session.id] = session
}),
)
for (const session of sessions) {
sync.complete(`session:${session.id}`)
registerSession(session.id)
}
batch(() => {
setStore(
"session",
"info",
produce((draft) => {
for (const session of sessions) draft[session.id] = session
}),
)
for (const session of sessions) {
sync.complete(`session:${session.id}`)
registerSession(session.id)
}
})
})
},
invalidate(sessionID: string) {
+177
View File
@@ -0,0 +1,177 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createData, type CreateDataInput } from "../src/solid"
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
test("evicts a whole family while preserving metadata and attention", async () => {
const setup = fixture()
try {
for (const id of ["ses_parent", "ses_child", "ses_grandchild", "ses_other"]) {
await setup.data.session.message.sync(id)
await setup.data.session.pending.sync(id)
}
setup.data.session.setStatus("ses_child", "running")
setup.emit({
id: "evt_permission",
created: 1,
type: "permission.asked",
data: { id: "per_test", sessionID: "ses_child", action: "bash", resources: ["bun test"] },
})
setup.data.session.evict("ses_parent")
for (const id of ["ses_parent", "ses_child", "ses_grandchild"]) {
expect(setup.data.session.message.list(id)).toEqual([])
expect(setup.data.session.message.get(id, "msg_page")).toBeUndefined()
expect(setup.data.session.message.more(id)).toBe(false)
expect(setup.data.session.message.loading(id)).toBe(false)
expect(setup.data.session.pending.list(id)).toEqual([])
expect(setup.data.session.input.list(id)).toEqual([])
expect(setup.data.session.get(id)?.id).toBe(id)
}
expect(setup.data.session.family("ses_parent")).toEqual(["ses_parent", "ses_child", "ses_grandchild"])
expect(setup.data.session.status("ses_child")).toBe("running")
expect(setup.data.session.permission.list("ses_child")?.[0]?.id).toBe("per_test")
expect(setup.data.session.message.list("ses_other")).toHaveLength(2)
setup.emit({
id: "evt_agent",
created: 1,
type: "session.agent.selected",
durable: { aggregateID: "ses_parent", seq: 1, version: 1 },
data: { sessionID: "ses_parent", agent: "build" },
})
expect(setup.data.session.message.list("ses_parent")).toMatchObject([{ type: "agent-switched" }])
await setup.data.session.message.sync("ses_parent")
expect(setup.data.session.message.list("ses_parent").map((item) => item.id)).toEqual(["msg_page"])
await setup.data.session.message.sync("ses_grandchild")
await setup.data.session.pending.sync("ses_grandchild")
expect(setup.data.session.message.list("ses_grandchild")).toHaveLength(2)
expect(setup.data.session.message.list("ses_child")).toEqual([])
} finally {
setup.dispose()
}
})
test("evicting an in-flight read leaves the next sync invalidated", async () => {
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
let requests = 0
const setup = fixture(async () => {
if (++requests !== 1) return
requested.resolve()
await release.promise
})
try {
const initial = setup.data.session.message.sync("ses_child")
await requested.promise
setup.data.session.evict("ses_parent")
release.resolve()
await initial
await setup.data.session.message.sync("ses_child")
expect(requests).toBe(2)
expect(setup.data.session.message.list("ses_child")).toHaveLength(1)
} finally {
release.resolve()
setup.dispose()
}
})
test.each(["echo", "reject"] as const)("eviction preserves optimistic submissions until %s", async (mode) => {
const release = Promise.withResolvers<void>()
const setup = fixture(async (url) => {
if (!url.pathname.endsWith("/prompt")) return undefined
await release.promise
return mode === "reject"
? Response.json({ message: "rejected" }, { status: 400 })
: Response.json({ id: "msg_local" })
})
try {
const request = setup.data.session.prompt({ sessionID: "ses_child", id: "msg_local", text: "local" })
const settled = Promise.allSettled([request])
setup.data.session.evict("ses_parent")
expect(setup.data.session.message.list("ses_child")).toMatchObject([{ id: "msg_local", text: "local" }])
if (mode === "echo") setup.emit(enqueued("ses_child", "msg_local"))
release.resolve()
await settled
expect(setup.data.session.message.list("ses_child")).toHaveLength(mode === "echo" ? 1 : 0)
expect(setup.data.session.pending.list("ses_child")).toHaveLength(mode === "echo" ? 1 : 0)
} finally {
release.resolve()
setup.dispose()
}
})
function info(id: string, parentID?: string): SessionInfo {
return {
id,
parentID,
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
location: { directory: "/project" },
}
}
function enqueued(sessionID: string, inboxID = "msg_pending"): OpenCodeEvent {
return {
id: `evt_${inboxID}`,
created: 1,
type: "session.inbox.enqueued",
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: { sessionID, inboxID, item: { type: "user", delivery: "steer", payload: { text: "pending" } } },
}
}
function fixture(read?: (url: URL) => Promise<Response | void>) {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input) => {
const url = new URL(input instanceof Request ? input.url : String(input))
const response = await read?.(url)
if (response) return response
const sessionID = url.pathname.split("/")[3]
if (url.pathname.endsWith("/inbox"))
return Response.json({
data: [
{
id: "msg_pending",
sessionID,
type: "user",
delivery: "steer",
payload: { text: "pending" },
timeCreated: 1,
},
],
})
return Response.json({
data: [{ id: "msg_page", type: "user", text: "page", time: { created: 0 } }],
cursor: url.searchParams.has("cursor") ? {} : { next: "older" },
})
},
})
return createRoot((dispose) => {
const data = createData({
api: () => api,
directory: "/project",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
})
data.session.remember(info("ses_parent"))
data.session.remember(info("ses_child", "ses_parent"))
data.session.remember(info("ses_grandchild", "ses_child"))
data.session.remember(info("ses_other"))
return {
data,
dispose,
emit: (details: OpenCodeEvent) => listeners.forEach((listener) => listener({ name: details.type, details })),
}
})
}
@@ -93,7 +93,7 @@ const layer = Layer.effect(
)
yield* policy.observe(reconcile)
yield* Effect.gen(function* () {
yield* plugins.flush
yield* plugins.awaitActivation
yield* reconcile(policy.current())
}).pipe(
Effect.catchCauseIf(
+19 -6
View File
@@ -26,6 +26,11 @@ const Definition = Schema.Struct({
]),
})
export class LoadError extends Schema.TaggedError<LoadError>()("PluginModule.LoadError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {}
export const load = Effect.fn("PluginModule.load")(function* (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
options?: { readonly install?: boolean },
@@ -39,13 +44,21 @@ export const load = Effect.fn("PluginModule.load")(function* (
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
const entrypoint = installed.entrypoint
if (!local && options?.install === false && !entrypoint) return { pending: true as const }
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
if (!entrypoint) return yield* new LoadError({ message: `Plugin entrypoint not found: ${operation.target}` })
// Bun currently ignores query parameters when caching file:// imports.
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
const source = operation.mtime === undefined ? entrypoint : `${target}?mtime=${operation.mtime}`
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
const mod = yield* Effect.promise(() => importModule(source))
const value = (yield* Schema.decodeUnknownEffect(Definition)(mod)).default
const value = (yield* Schema.decodeUnknownEffect(Definition)(mod).pipe(
Effect.mapError(
(cause) =>
new LoadError({
message: "Plugin must export a default definition with an id and an effect or setup function.",
cause,
}),
),
)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
const features = local
? yield* localFeatures(operation.target)
@@ -77,11 +90,11 @@ function localFeatures(entrypoint: string) {
if (!path.basename(entrypoint).startsWith("index.")) return Effect.succeed({})
return Effect.promise(() => readdir(path.dirname(entrypoint), { withFileTypes: true })).pipe(
Effect.map((entries) => {
const names = new Set(entries.filter((entry) => entry.isFile() || entry.isSymbolicLink()).map((entry) => entry.name))
const names = new Set(
entries.filter((entry) => entry.isFile() || entry.isSymbolicLink()).map((entry) => entry.name),
)
const has = (name: string) =>
["ts", "tsx", "js", "jsx", "mts", "mjs", "cts", "cjs"].some((extension) =>
names.has(`${name}.${extension}`),
)
["ts", "tsx", "js", "jsx", "mts", "mjs", "cts", "cjs"].some((extension) => names.has(`${name}.${extension}`))
return {
...(has("tui") ? { tui: true as const } : {}),
...(has("rpc") ? { rpc: true as const } : {}),
@@ -8,10 +8,12 @@ import { Context, Effect } from "effect"
*/
export interface Interface {
/**
* Wait for the plugin generation to settle. Use this rarely: blocking reads,
* UI startup, or other unrelated work on plugin boot should be avoided.
* Wait for configured plugin activation to settle, including missing-package installs.
* Completion does not imply every plugin succeeded.
* Interrupting this wait does not cancel activation. Use rarely: avoid blocking reads,
* UI startup, or unrelated work on plugin boot.
*/
readonly flush: Effect.Effect<void>
readonly awaitActivation: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
+15 -13
View File
@@ -55,11 +55,13 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
}
const plugin = yield* PluginModule.load(operation, { install }).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(
Effect.as({ error: Cause.pretty(cause) }),
),
),
Effect.catchCause((cause) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
const error = Cause.squash(cause)
return Effect.logWarning("failed to load plugin", { target: operation.target, ref, cause }).pipe(
Effect.as({ error: error instanceof PluginModule.LoadError ? error.message : "Plugin failed to load", ref }),
)
}),
)
if ("pending" in plugin) {
pending.add(operation.target)
@@ -68,7 +70,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
if ("error" in plugin) {
failures.set(operation.target, {
source: pluginSource(operation.target),
state: { status: "failed", error: plugin.error },
state: { status: "failed", error: plugin.error, ref: plugin.ref },
features: { server: true },
})
continue
@@ -127,9 +129,7 @@ export const layer = Layer.effect(
// Activate everything available locally before waiting on missing package installs.
const immediate = yield* resolve(pre, post, operations, false)
const source = (source: Plugin.Source) =>
source.type === "package" && outdated.has(source.target)
? { ...source, outdated: true as const }
: source
source.type === "package" && outdated.has(source.target) ? { ...source, outdated: true as const } : source
const apply = (resolved: typeof immediate) =>
registry.activate(
resolved.plugins.map((plugin) => (plugin.source ? { ...plugin, source: source(plugin.source) } : plugin)),
@@ -162,12 +162,14 @@ export const layer = Layer.effect(
const reloads = Stream.merge(
Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])),
updates.changes().pipe(
Stream.filter((target) => packages.has(target)),
Stream.tap((target) => Effect.sync(() => outdated.delete(target))),
Stream.filter((update) => packages.has(update.target)),
Stream.tap((update) =>
Effect.sync(() => (update.outdated ? outdated.add(update.target) : outdated.delete(update.target))),
),
Stream.map(() => undefined),
),
).pipe(
// Make accepted work visible to flush before coalescing the burst.
// Make accepted work visible to awaitActivation before coalescing the burst.
Stream.mapEffect(() =>
Effect.gen(function* () {
observed++
@@ -189,7 +191,7 @@ export const layer = Layer.effect(
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.sleep("24 hours").pipe(Effect.andThen(activate()), Effect.forever, Effect.forkScoped)
return Service.of({ flush: ready.await })
return Service.of({ awaitActivation: ready.await })
}),
)
+7 -6
View File
@@ -9,9 +9,9 @@ import { KeyedMutex } from "../effect/keyed-mutex.js"
const interval = 24 * 60 * 60 * 1_000
export interface Interface {
readonly check: (target: string) => Effect.Effect<boolean>
readonly check: (target: string, options?: { readonly refresh?: boolean }) => Effect.Effect<boolean>
readonly update: (target: string) => Effect.Effect<void, Npm.InstallFailedError | EffectFlock.LockError>
readonly changes: () => Stream.Stream<string>
readonly changes: () => Stream.Stream<{ readonly target: string; readonly outdated: boolean }>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginUpdate") {}
@@ -22,28 +22,29 @@ const layer = Layer.effect(
const npm = yield* Npm.Service
const locks = KeyedMutex.makeUnsafe<string>()
const status = new Map<string, { readonly outdated: boolean; readonly checkedAt: number }>()
const changes = yield* PubSub.unbounded<string>()
const changes = yield* PubSub.unbounded<{ readonly target: string; readonly outdated: boolean }>()
return Service.of({
check: (target) =>
check: (target, options) =>
locks.withLock(target)(
Effect.gen(function* () {
const checkedAt = yield* Clock.currentTimeMillis
const current = status.get(target)
if (current && checkedAt - current.checkedAt < interval) return current.outdated
if (!options?.refresh && current && checkedAt - current.checkedAt < interval) return current.outdated
const outdated = yield* npm.check(target).pipe(
Effect.tapCause((cause) => Effect.logWarning("failed to check plugin update", { target, cause })),
Effect.option,
)
const value = Option.getOrElse(outdated, () => current?.outdated ?? false)
status.set(target, { outdated: value, checkedAt })
if ((current?.outdated ?? false) !== value) yield* PubSub.publish(changes, { target, outdated: value })
return value
}),
),
update: (target) =>
npm.update(target).pipe(
Effect.tap(() => Effect.sync(() => status.delete(target))),
Effect.tap(() => PubSub.publish(changes, target)),
Effect.tap(() => PubSub.publish(changes, { target, outdated: false })),
Effect.asVoid,
),
changes: () => Stream.fromPubSub(changes),
+1 -1
View File
@@ -407,7 +407,7 @@ const layer = Layer.effect(
const session = yield* result.get(input.sessionID)
const commands = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
yield* plugins.awaitActivation
return yield* Command.Service
}).pipe(instances.provide(session))
const delivery = input.delivery ?? "steer"
+73 -16
View File
@@ -1,6 +1,6 @@
export * as SessionCompaction from "./compaction.js"
import { LLMClient, LLMEvent, Message } from "@opencode-ai/ai"
import { LLMClient, LLMEvent, Message, type ContentPart } from "@opencode-ai/ai"
import { Agent } from "@opencode-ai/schema/agent"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
@@ -10,18 +10,21 @@ import { llmClient } from "../effect/app-node-platform.js"
import { SessionEvent } from "./event.js"
import type { SessionContext } from "./context.js"
import type { SessionMessage } from "./message.js"
import type { SessionModelRequest } from "./model-request.js"
import { SessionModelRequest } from "./model-request.js"
import type { SessionRunnerModel } from "./runner/model.js"
import { SessionSchema } from "./schema.js"
import { toSessionError } from "./to-session-error.js"
import { Token } from "../util/token.js"
import { SessionUsage } from "./usage.js"
import { State } from "../state.js"
import { toLLMMessages } from "./runner/to-llm-message.js"
const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 15_000
const OUTPUT_TOKEN_MAX = 32_000
const TOOL_OUTPUT_MAX_CHARS = 2_000
const IMAGE_TOKEN_ESTIMATE = 1_500
const PDF_TOKEN_ESTIMATE = 2_000
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
<template>
## Objective
@@ -71,7 +74,9 @@ export type AutoInput = {
readonly prepare: SessionModelRequest.Interface["prepare"]
}
type RequiredInput = Pick<AutoInput, "messages" | "resolved">
type RequiredInput = Pick<AutoInput, "messages" | "resolved"> & {
readonly context: SessionContext.Loaded
}
export type ManualInput = {
readonly session: SessionSchema.Info
@@ -107,6 +112,67 @@ export interface Interface extends State.Transformable<Draft> {
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionCompaction") {}
export const estimateTokens = (input: RequiredInput) => {
const index = input.messages.findLastIndex(
(message) =>
message.type === "assistant" &&
!message.error &&
message.tokens !== undefined &&
message.tokens.input + message.tokens.cache.read + message.tokens.cache.write > 0,
)
const last = input.messages[index]
// Keep the anchor's local tool results: they are not covered by its provider usage.
const added = SessionModelRequest.unsupportedParts(
toLLMMessages(input.messages.slice(Math.max(0, index)), input.resolved.ref),
input.resolved.capabilities,
)
.filter((message) => message.role !== "assistant" || message.id !== last?.id)
.reduce((sum, message) => sum + message.content.reduce((sum, part) => sum + estimatePart(part), 0), 0)
if (last?.type === "assistant" && last.tokens)
return (
added +
last.tokens.input +
last.tokens.cache.read +
last.tokens.cache.write +
last.tokens.output +
last.tokens.reasoning
)
const transcript = SessionModelRequest.baseTranscript({
agent: input.context.agent.info,
model: input.resolved,
tools: input.context.tools,
initial: input.context.initial,
messages: [],
})
return (
added +
transcript.system.reduce((sum, part) => sum + Token.estimate(part.text), 0) +
input.context.tools.definitions.reduce(
(sum, tool) => sum + Token.estimate(tool.name + tool.description + JSON.stringify(tool.inputSchema)),
0,
)
)
}
const estimateMedia = (mime: string) => {
const type = mime.toLowerCase()
return type.startsWith("image/") ? IMAGE_TOKEN_ESTIMATE : type === "application/pdf" ? PDF_TOKEN_ESTIMATE : 0
}
const estimatePart = (part: ContentPart): number => {
if (part.type === "text" || part.type === "reasoning") return Token.estimate(part.text)
if (part.type === "media") return estimateMedia(part.mediaType)
if (part.type === "tool-call") return Token.estimate(part.name + (JSON.stringify(part.input) ?? ""))
if (part.result.type === "content")
return part.result.value.reduce(
(sum, content) => sum + (content.type === "text" ? Token.estimate(content.text) : estimateMedia(content.mime)),
0,
)
return Token.estimate(
typeof part.result.value === "string" ? part.result.value : (JSON.stringify(part.result.value) ?? ""),
)
}
export const truncateToolOutput = (value: string) => {
if (value.length <= TOOL_OUTPUT_MAX_CHARS) return value
let end = 0
@@ -364,27 +430,18 @@ export const layer = Layer.effect(
const required = (input: RequiredInput) => {
const config = state.get()
if (!config.auto) return false
// Run the completed checkpoint before considering another automatic compaction.
const last = input.messages.at(-1)
if (last?.type === "compaction" && last.status === "completed") return false
const limit = input.resolved.limit
const context = limit.context
if (context <= 0) return false
const last = input.messages.findLast(
(message): message is SessionMessage.Assistant & { tokens: NonNullable<SessionMessage.Assistant["tokens"]> } =>
message.type === "assistant" && message.tokens !== undefined,
)
if (!last) return false
const output = Math.min(limit.output, OUTPUT_TOKEN_MAX)
const promptCeiling = Math.min(
limit.input === undefined ? Number.POSITIVE_INFINITY : limit.input - config.buffer,
context - Math.max(output, config.buffer),
)
const used =
last.tokens.input +
last.tokens.output +
last.tokens.reasoning +
last.tokens.cache.read +
last.tokens.cache.write
if (used <= 0) return false
return used >= promptCeiling
return estimateTokens(input) >= promptCeiling
}
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
const content = planContent(input.messages, state.get().tokens)
+1 -1
View File
@@ -125,7 +125,7 @@ const layer = Layer.effect(
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
return yield* Effect.interrupt
yield* plugins.flush
yield* plugins.awaitActivation
yield* mcpTools.flush
const agent = yield* agents.select(session.agent)
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
+1 -1
View File
@@ -37,7 +37,7 @@ export const make = Effect.fn("SessionPrompt.make")(function* () {
messageID: SessionMessage.ID
input: Input
}) {
yield* plugins.flush
yield* plugins.awaitActivation
const event = yield* hooks.trigger("session", "prompt", {
sessionID: request.sessionID,
messageID: request.messageID,
+2 -2
View File
@@ -38,7 +38,7 @@ export const make = Effect.fn("SessionRevert.make")(function* () {
const snapshot = yield* Snapshot.Service
const stage: Interface["stage"] = Effect.fn("SessionRevert.stage")(function* (input) {
yield* plugins.flush
yield* plugins.awaitActivation
const original = input.session.revert?.snapshot
? Snapshot.ID.make(input.session.revert.snapshot)
: yield* snapshot.capture()
@@ -66,7 +66,7 @@ export const make = Effect.fn("SessionRevert.make")(function* () {
})
const clear: Interface["clear"] = Effect.fn("SessionRevert.clear")(function* (session) {
yield* plugins.flush
yield* plugins.awaitActivation
if (!session.revert) return
const original = session.revert.snapshot ? Snapshot.ID.make(session.revert.snapshot) : undefined
if (original)
+2 -2
View File
@@ -57,7 +57,7 @@ const layer = Layer.effect(
const control = pending.type === "compaction" || pending.type === "move"
if (promotable === "steer" && pending.delivery === "queue" && !control) return DrainResult.Complete()
}
yield* plugins.flush
yield* plugins.awaitActivation
yield* settleStaleToolCalls(sessionID)
const advanceToStep = Effect.fn("SessionRunner.advanceToStep")(() =>
@@ -185,7 +185,7 @@ const layer = Layer.effect(
resolved: loaded.model,
prepare: context.prepare,
}
if (compaction.required(compactionInput)) {
if (compaction.required({ ...compactionInput, context: loaded })) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status !== "completed") return yield* new StepFailedError({ error: compacted.error })
assistantMessageID = SessionMessage.ID.create()
+1 -1
View File
@@ -196,7 +196,7 @@ export const make = Effect.fn("Session.make")(function* () {
// Resolve shell services here without pinning Session events to this Location after a move.
const shell = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
yield* plugins.awaitActivation
return yield* Shell.Service
}).pipe(instances.provide(session))
const started = yield* shell
+2 -5
View File
@@ -8,6 +8,7 @@ import { Project } from "@opencode-ai/schema/project"
import { Provider } from "@opencode-ai/schema/provider"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import { ToolMode } from "@opencode-ai/schema/session-stats"
import { TokenUsage } from "@opencode-ai/schema/token-usage"
import { Database } from "../database/database.js"
import { EventTable } from "../event/sql.js"
import { SessionMessageTable, SessionTable } from "./sql.js"
@@ -341,7 +342,7 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) {
streak: longestStreak(days.map(([date]) => date)),
activity: days.map(([date, steps]) => ({ date, steps })),
models: [...models.values()]
.sort((a, b) => tokenTotal(b.tokens) - tokenTotal(a.tokens))
.sort((a, b) => TokenUsage.total(b.tokens) - TokenUsage.total(a.tokens))
.map((model) => ({ ...model, cost: Money.USD.make(model.cost) })),
}
})
@@ -378,10 +379,6 @@ function addTokens(target: Tokens, source: Tokens) {
target.cache.write += source.cache.write
}
function tokenTotal(tokens: Tokens) {
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
}
function addToolStatus(
target: { calls: number; succeeded: number; failed: number; unfinished: number },
status: string | null,
+17 -6
View File
@@ -145,10 +145,8 @@ const session = Session.Info.make({
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp") }),
})
const input = (tokens: number) => ({
session,
resolved,
messages: [
const input = (tokens: number) => {
const messages = [
Schema.decodeUnknownSync(SessionMessage.Assistant)({
id: SessionMessage.ID.make("msg_compaction_config"),
type: "assistant",
@@ -158,7 +156,20 @@ const input = (tokens: number) => ({
tokens: { input: tokens, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, completed: 0 },
}),
],
})
]
return {
session,
resolved,
messages,
context: {
session,
model: resolved,
messages,
agent: { id: Agent.defaultID, info: Agent.Info.default(Agent.defaultID) },
initial: "",
tools: { definitions: [], execute: () => Effect.die("unused") },
},
}
}
const bufferedInput = input(85_000)
const nearInput = input(95_000)
+34 -19
View File
@@ -20,7 +20,7 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect, Fiber, Layer, Logger, Option, Schedule, Stream } from "effect"
import { Cause, Effect, Fiber, Layer, Logger, Option, Schedule, Stream } from "effect"
import { Database } from "../../src/database/database"
import { tmpdir } from "../fixture/tmpdir"
import { tempGlobalLayer } from "../fixture/global"
@@ -217,12 +217,15 @@ describe("PluginSupervisor config", () => {
)
it.live("logs invalid packages and continues loading", () => {
const output: string[] = []
const output: Array<{ target: string; ref: string; diagnostic: string }> = []
const logger = Logger.map(Logger.formatStructured, (entry) => {
if (!Array.isArray(entry.message) || entry.message[0] !== "failed to load plugin") return
const details = entry.message[1]
if (typeof details !== "object" || details === null || !("target" in details)) return
if (typeof details.target === "string") output.push(details.target)
if (typeof details !== "object" || details === null) return
if (!("target" in details) || typeof details.target !== "string") return
if (!("ref" in details) || typeof details.ref !== "string") return
if (!("cause" in details) || !Cause.isCause(details.cause)) return
output.push({ target: details.target, ref: details.ref, diagnostic: Cause.pretty(details.cause) })
})
return withLocation(
{
@@ -230,6 +233,7 @@ describe("PluginSupervisor config", () => {
"-*",
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/invalid"),
path.join(import.meta.dir, "../plugin/fixtures/load-failure"),
{
package: path.join(import.meta.dir, "../plugin/fixtures/config-promise"),
options: { description: "Loaded after invalid plugins" },
@@ -243,16 +247,30 @@ describe("PluginSupervisor config", () => {
expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
description: "Loaded after invalid plugins",
})
expect(output).toEqual([
expect(output.map((entry) => entry.target)).toEqual([
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/invalid/index.ts"),
path.join(import.meta.dir, "../plugin/fixtures/load-failure/index.ts"),
])
expect(
(yield* plugins.list()).filter((plugin) => plugin.state.status === "failed").map((plugin) => plugin.source),
).toEqual([
const failed = (yield* plugins.list()).filter((plugin) => plugin.state.status === "failed")
expect(failed.map((plugin) => plugin.source)).toEqual([
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts") },
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/invalid/index.ts") },
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/load-failure/index.ts") },
])
expect(failed.map((plugin) => plugin.state)).toEqual([
{ status: "failed", error: "Plugin failed to load", ref: output[0]?.ref },
{
status: "failed",
error: "Plugin must export a default definition with an id and an effect or setup function.",
ref: output[1]?.ref,
},
{ status: "failed", error: "Plugin failed to load", ref: output[2]?.ref },
])
output.forEach((entry) => expect(entry.ref).toMatch(/^err_[0-9a-f]{8}$/))
expect(new Set(output.map((entry) => entry.ref)).size).toBe(3)
expect(output[2]?.diagnostic).toContain("private plugin loader details")
expect(JSON.stringify(failed)).not.toContain("private plugin loader details")
}),
).pipe(Effect.provide(Logger.layer([logger])))
})
@@ -490,7 +508,7 @@ describe("PluginSupervisor config", () => {
),
)
it.live("unblocks flush when plugin activation fails", () =>
it.live("unblocks awaitActivation when plugin activation fails", () =>
Effect.gen(function* () {
const sdk = yield* SdkPlugins.Service
yield* sdk.register(define({ id: "duplicate-id", effect: () => Effect.void }))
@@ -530,19 +548,18 @@ describe("PluginSupervisor config", () => {
const plugins = yield* Plugin.Service
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("opencode.provider.openai")
const supervisor = yield* PluginSupervisor.Service
expect(Option.isNone(yield* supervisor.flush.pipe(Effect.timeoutOption("20 millis")))).toBeTrue()
expect(Option.isNone(yield* supervisor.awaitActivation.pipe(Effect.timeoutOption("20 millis")))).toBeTrue()
yield* Effect.promise(() => Bun.write(path.join(global.tmp, "cold-plugin", "release"), ""))
yield* supervisor.flush.pipe(Effect.timeout("2 seconds"))
yield* supervisor.awaitActivation.pipe(Effect.timeout("2 seconds"))
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("cold-plugin")
}),
),
)
})
const ready = Effect.fnUntraced(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
yield* supervisor.awaitActivation
})
const waitForFile = (file: string) =>
@@ -610,12 +627,10 @@ function discoveredPlugin(id: string) {
return `export default { id: ${JSON.stringify(id)}, setup() {} }`
}
async function writeDiscoveredPackage(
directory: string,
name: string,
files: Record<string, string>,
) {
async function writeDiscoveredPackage(directory: string, name: string, files: Record<string, string>) {
const plugin = path.join(directory, ".opencode", "plugins", name)
await fs.mkdir(plugin, { recursive: true })
await Promise.all(Object.entries(files).map(([file, id]) => fs.writeFile(path.join(plugin, file), discoveredPlugin(id))))
await Promise.all(
Object.entries(files).map(([file, id]) => fs.writeFile(path.join(plugin, file), discoveredPlugin(id))),
)
}
@@ -31,7 +31,7 @@ const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.nod
const configLayer = Config.testLayer()
const pluginNode = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void })),
layer: Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ awaitActivation: Effect.void })),
deps: [],
})
@@ -313,7 +313,7 @@ describe("LocationWatcher subscriptions", () => {
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
yield* policy.transform((draft) => draft.add([".git"]))
return PluginSupervisor.Service.of({ flush: Effect.void })
return PluginSupervisor.Service.of({ awaitActivation: Effect.void })
}),
),
deps: [LocationWatcherPolicy.node],
@@ -31,7 +31,7 @@ export const promptLocationNode = makeGlobalNode({
}),
Layer.succeed(FSUtil.Service, fs),
Layer.succeed(PluginSupervisor.Service, {
flush: Effect.void,
awaitActivation: Effect.void,
}),
Layer.mock(Reference.Service, { refresh: () => Effect.void }),
),
+1 -1
View File
@@ -44,7 +44,7 @@ function withFormatter<A, E, R>(
Effect.andThen(
Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
yield* plugins.awaitActivation
return yield* body(yield* Formatter.Service, directory)
}).pipe(
Effect.scoped,
+1 -1
View File
@@ -69,7 +69,7 @@ describe("InstancePlugins", () => {
const agents = (ref: Location.Ref) =>
Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
yield* supervisor.awaitActivation
const service = yield* Agent.Service
return {
bound: yield* service.get(Agent.ID.make("thread-a-agent")),
+2 -2
View File
@@ -77,7 +77,7 @@ describe("Instance vanilla", () => {
const read = (ref: Location.Ref) =>
Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
yield* supervisor.awaitActivation
const config = yield* Config.Service
const discovery = yield* InstructionDiscovery.Service
const tools = yield* Tool.Service
@@ -145,7 +145,7 @@ describe("Instance vanilla", () => {
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
yield* Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
yield* supervisor.awaitActivation
const config = yield* Config.Service
// Only the pathless host-injected document; nothing file-backed.
const entries = yield* config.entries()
+29 -29
View File
@@ -199,7 +199,7 @@ describe("LocationServiceMap", () => {
const ref = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
const read = Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
yield* supervisor.awaitActivation
const agents = yield* Agent.Service
return yield* agents.get(id)
})
@@ -233,14 +233,14 @@ describe("LocationServiceMap", () => {
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
yield* Deferred.await(started)
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
Effect.provide(context),
Effect.forkChild,
)
expect(flushFiber.pollUnsafe()).toBeUndefined()
expect(activationFiber.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(flushFiber)
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
yield* Fiber.join(activationFiber)
yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
Effect.provide(context),
Effect.timeout("1 second"),
)
@@ -281,7 +281,7 @@ describe("LocationServiceMap", () => {
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
yield* Deferred.await(firstStarted)
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
Effect.provide(context),
Effect.forkChild({ startImmediately: true }),
)
@@ -296,10 +296,10 @@ describe("LocationServiceMap", () => {
yield* Deferred.succeed(releaseFirst, undefined)
yield* Deferred.await(secondStarted)
expect(flushFiber.pollUnsafe()).toBeUndefined()
expect(activationFiber.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(releaseSecond, undefined)
yield* Fiber.join(flushFiber)
yield* Fiber.join(activationFiber)
}),
),
),
@@ -352,22 +352,22 @@ describe("LocationServiceMap", () => {
)
yield* Fiber.join(updated)
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
Effect.provide(context),
Effect.forkChild,
)
yield* Deferred.succeed(releaseFirst, undefined)
yield* Deferred.await(secondStarted)
expect(flushFiber.pollUnsafe()).toBeUndefined()
expect(activationFiber.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(releaseSecond, undefined)
yield* Fiber.join(flushFiber)
yield* Fiber.join(activationFiber)
expect(activations.count).toBe(2)
}),
),
),
)
itWithSdk.live("keeps flush pending while startup updates continue", () =>
itWithSdk.live("keeps awaitActivation pending while startup updates continue", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
@@ -376,7 +376,7 @@ describe("LocationServiceMap", () => {
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
Effect.provide(context),
Effect.forkChild({ startImmediately: true }),
)
@@ -387,8 +387,8 @@ describe("LocationServiceMap", () => {
() => bus.publish(SdkPlugins.Updated, {}).pipe(Effect.andThen(Effect.sleep("50 millis"))),
{ discard: true },
)
expect(flushFiber.pollUnsafe()).toBeUndefined()
yield* Fiber.join(flushFiber)
expect(activationFiber.pollUnsafe()).toBeUndefined()
yield* Fiber.join(activationFiber)
}),
),
),
@@ -412,7 +412,7 @@ describe("LocationServiceMap", () => {
const locations = yield* LocationServiceMap.Service
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(context))
yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(Effect.provide(context))
expect(activations.count).toBe(1)
yield* Bus.Service.use((bus) => bus.publish(Config.Event.Updated, {})).pipe(Effect.provide(context))
@@ -424,7 +424,7 @@ describe("LocationServiceMap", () => {
),
)
itWithSdk.live("keeps flush open while later hot reload runs", () =>
itWithSdk.live("keeps awaitActivation pending while later hot reload runs", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
@@ -433,7 +433,7 @@ describe("LocationServiceMap", () => {
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(context))
yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(Effect.provide(context))
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
@@ -451,20 +451,20 @@ describe("LocationServiceMap", () => {
)
yield* Deferred.await(started)
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
Effect.provide(context),
Effect.forkChild({ startImmediately: true }),
)
expect(flushFiber.pollUnsafe()).toBeUndefined()
expect(activationFiber.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(flushFiber)
yield* Fiber.join(activationFiber)
yield* Deferred.await(completed)
}),
),
),
)
itWithSdk.live("does not cancel activation when a flush waiter is interrupted", () =>
itWithSdk.live("does not cancel activation when an awaitActivation waiter is interrupted", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
@@ -489,15 +489,15 @@ describe("LocationServiceMap", () => {
const locations = yield* LocationServiceMap.Service
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
yield* Deferred.await(started)
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
Effect.provide(context),
Effect.forkChild({ startImmediately: true }),
)
yield* Fiber.interrupt(flushFiber)
yield* Fiber.interrupt(activationFiber)
yield* Deferred.succeed(release, undefined)
yield* Deferred.await(completed)
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
Effect.provide(context),
Effect.timeout("500 millis"),
)
@@ -519,7 +519,7 @@ describe("LocationServiceMap", () => {
const plugins = yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
yield* supervisor.awaitActivation
return yield* plugins.list()
}).pipe(
Effect.scoped,
@@ -546,7 +546,7 @@ describe("LocationServiceMap", () => {
yield* Effect.gen(function* () {
const registry = yield* Plugin.Service
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
yield* supervisor.awaitActivation
expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"])
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.command"] })))
@@ -710,7 +710,7 @@ describe("LocationServiceMap", () => {
const catalog = yield* Catalog.Service
yield* catalog.transform((editor) => editor.provider.update(providerID, () => {}))
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
yield* supervisor.awaitActivation
const registry = yield* Tool.Service
return {
providers: yield* catalog.provider.all(),
@@ -992,7 +992,7 @@ describe("LocationServiceMap", () => {
yield* Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
const mcp = yield* Mcp.Service
yield* supervisor.flush
yield* supervisor.awaitActivation
expect(observed.example).toBe(false)
yield* mcp.add("dynamic", {
type: "remote",
@@ -0,0 +1 @@
throw new Error("private plugin loader details")
+1 -1
View File
@@ -46,6 +46,6 @@ it.effect("publishes successful package updates", () =>
yield* updates.update("fixture")
expect(Option.getOrUndefined(yield* Fiber.join(changed))).toBe("fixture")
expect(Option.getOrUndefined(yield* Fiber.join(changed))).toEqual({ target: "fixture", outdated: false })
}),
)
+94 -11
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { LLMClient, LLMEvent, LanguageModel, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import { LLMClient, LLMEvent, LanguageModel, SystemPart, ToolDefinition, type LLMRequest } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -150,7 +150,7 @@ test("compaction points an existing summary to the following history", () => {
expect(prompt).not.toContain("conversation history above")
})
it.effect("auto compaction reserves a buffer below the prompt ceiling", () =>
it.effect("auto compaction estimates current content against the buffered prompt ceiling", () =>
Effect.gen(function* () {
const compaction = yield* SessionCompaction.Service
const session = Session.Info.make({
@@ -161,25 +161,45 @@ it.effect("auto compaction reserves a buffer below the prompt ceiling", () =>
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp") }),
})
const input = (tokens: number, limit: { context: number; input?: number; output: number }) => ({
session,
resolved: SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
const input = (tokens: number, limit: { context: number; input?: number; output: number }) => {
const resolved = SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image", "pdf"], output: ["text"] },
cost: [],
limit,
}),
messages: [
})
const messages = [
Schema.decodeUnknownSync(SessionMessage.Assistant)({
id: SessionMessage.ID.make("msg_assistant"),
type: "assistant",
agent: Agent.defaultID,
model: { id: "test-model", providerID: "test-provider" },
content: [],
content: [{ type: "text", text: "Done" }],
tokens: { input: tokens, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, completed: 0 },
}),
],
})
]
return {
session,
resolved,
messages,
context: {
session,
model: resolved,
messages,
agent: {
id: Agent.defaultID,
info: { ...Agent.Info.default(Agent.defaultID), system: "You are a helpful assistant." },
},
initial: "Project instructions.",
tools: {
definitions: [
ToolDefinition.make({ name: "read", description: "Read files", inputSchema: { type: "object" } }),
],
execute: () => Effect.die("unused"),
},
},
}
}
const inputLimited = { context: 400_000, input: 272_000, output: 128_000 }
expect(compaction.required(input(251_999, inputLimited))).toBe(false)
@@ -192,6 +212,69 @@ it.effect("auto compaction reserves a buffer below the prompt ceiling", () =>
const outputLimited = { context: 100_000, output: 30_000 }
expect(compaction.required(input(69_999, outputLimited))).toBe(false)
expect(compaction.required(input(70_000, outputLimited))).toBe(true)
const assistant = input(79_000, contextLimited).messages[0]
const tool = SessionMessage.AssistantTool.make({
type: "tool",
id: "call_read",
name: "read",
state: { status: "completed", input: {}, content: [{ type: "text", text: "x".repeat(4_000) }] },
time: { created: DateTime.makeUnsafe(0) },
})
const grown = { ...input(79_000, contextLimited), messages: [{ ...assistant, content: [tool] }] }
expect(SessionCompaction.estimateTokens(grown)).toBe(80_000)
expect(compaction.required(grown)).toBe(true)
const interrupted = { ...assistant, id: SessionMessage.ID.create(), tokens: undefined }
expect(SessionCompaction.estimateTokens({ ...grown, messages: [...grown.messages, interrupted] })).toBe(80_001)
// Without provider usage, include 20 tokens for the system prompt, instructions, and tool definition.
expect(SessionCompaction.estimateTokens({ ...grown, messages: [interrupted] })).toBe(21)
expect(
SessionCompaction.estimateTokens({
...grown,
messages: [{ ...interrupted, tokens: input(0, contextLimited).messages[0].tokens }],
}),
).toBe(21)
const media = [
{ type: "file", mime: "image/png", uri: `data:image/png;base64,${"a".repeat(100_000)}` },
{ type: "file", mime: "application/pdf", uri: `data:application/pdf;base64,${"a".repeat(100_000)}` },
] as const
const messages = [
{ ...assistant, content: [{ ...tool, state: { status: "completed" as const, input: {}, content: media } }] },
]
expect(SessionCompaction.estimateTokens({ ...grown, messages })).toBe(82_500)
const user = Schema.decodeUnknownSync(SessionMessage.User)({
id: SessionMessage.ID.create(),
type: "user",
text: "",
files: media.map((file) => ({ mime: file.mime, data: "a".repeat(100_000), source: { type: "inline" } })),
time: { created: 0 },
})
expect(SessionCompaction.estimateTokens({ ...grown, messages: [...messages, user] })).toBe(86_000)
for (const [modalities, tokens, fallback] of [
[["text", "image"], 82_040, 1_520],
[["text", "pdf"], 83_042, 2_021],
[["text"], 79_082, 41],
] as const) {
const selected = {
...grown,
resolved: { ...grown.resolved, capabilities: { ...grown.resolved.capabilities, input: modalities } },
}
expect(SessionCompaction.estimateTokens({ ...selected, messages: [...messages, user] })).toBe(tokens)
expect(SessionCompaction.estimateTokens({ ...selected, messages: [user] })).toBe(fallback + 20)
}
const checkpoint = Schema.decodeUnknownSync(SessionMessage.CompactionCompleted)({
id: SessionMessage.ID.create(),
type: "compaction",
status: "completed",
reason: "auto",
summary: "x".repeat(400_000),
recent: "",
time: { created: 0, completed: 0 },
})
expect(compaction.required({ ...grown, messages: [checkpoint] })).toBe(false)
}),
)
+1 -1
View File
@@ -111,7 +111,7 @@ const discovery = Layer.mock(InstructionDiscovery.Service, {
const skills = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void })
const plugins = Layer.mock(PluginSupervisor.Service, { awaitActivation: Effect.void })
const tools = Layer.mock(Tool.Service, {
snapshot: () =>
Effect.succeed({
+23 -15
View File
@@ -95,7 +95,7 @@ const setup = Effect.fnUntraced(function* (options?: {
)
const hooks = yield* PluginHooks.Service.pipe(Effect.provide(LayerNode.compile(PluginHooks.node)))
const locations: Location.Ref[] = []
const flushes: Location.Ref[] = []
const activationWaits: Location.Ref[] = []
const resumes: SessionSchema.ID[] = []
const wakes: Array<{ sessionID: SessionSchema.ID; pending: SessionMessage.ID[]; enqueued: number }> = []
const execution = SessionExecution.Service.of({
@@ -143,8 +143,8 @@ const setup = Effect.fnUntraced(function* (options?: {
}),
options?.snapshot?.(ref) ?? Layer.mock(Snapshot.Service, {}),
Layer.succeed(PluginSupervisor.Service, {
flush: Effect.sync(() => {
flushes.push(ref)
awaitActivation: Effect.sync(() => {
activationWaits.push(ref)
}),
}),
),
@@ -175,7 +175,7 @@ const setup = Effect.fnUntraced(function* (options?: {
}),
Effect.provideService(SessionExecution.Service, options?.execution ?? execution),
)
return { sessions, hooks, locations, flushes, resumes, wakes, db: database.db, bus, store }
return { sessions, hooks, locations, activationWaits, resumes, wakes, db: database.db, bus, store }
})
describe("Session-owned handles", () => {
@@ -252,7 +252,7 @@ describe("Session-owned handles", () => {
const calls: string[] = []
yield* fixture.hooks.register("session", "prompt", (event) =>
Effect.sync(() => {
expect(fixture.flushes).toEqual([source])
expect(fixture.activationWaits).toEqual([source])
calls.push(event.prompt.text)
event.prompt.text += " prepared"
}),
@@ -277,7 +277,7 @@ describe("Session-owned handles", () => {
)
expect(calls).toEqual(["Original"])
expect(fixture.locations).toEqual([source])
expect(fixture.flushes).toEqual([source])
expect(fixture.activationWaits).toEqual([source])
expect(fixture.wakes).toEqual([
{ sessionID, pending: [synthetic.id, first.id], enqueued: 2 },
{ sessionID, pending: [synthetic.id, first.id], enqueued: 2 },
@@ -363,7 +363,7 @@ describe("Session-owned handles", () => {
expect(fixture.locations).toEqual([source])
yield* prompt
expect(fixture.locations).toEqual([source, destination])
expect(fixture.flushes).toEqual([source, destination])
expect(fixture.activationWaits).toEqual([source, destination])
expect((yield* fixture.sessions.forSession(otherID).get()).location).toEqual(source)
}),
)
@@ -408,7 +408,7 @@ describe("Session-owned handles", () => {
expect(
events.filter((event) => event.type === SessionEvent.Skill.Activated.type).map((event) => event.location),
).toEqual([undefined, undefined, source])
expect(fixture.flushes).toEqual([])
expect(fixture.activationWaits).toEqual([])
expect(fixture.resumes).toEqual([])
expect(fixture.wakes).toEqual([])
}),
@@ -442,7 +442,7 @@ describe("Session-owned handles", () => {
expect(yield* handle.get()).toEqual(before)
expect(yield* handle.inbox()).toEqual([])
expect(yield* fixture.store.context(sessionID)).toEqual([])
expect(fixture.flushes).toEqual([])
expect(fixture.activationWaits).toEqual([])
expect(fixture.resumes).toEqual([])
expect(fixture.wakes).toEqual([])
}),
@@ -776,7 +776,7 @@ describe("Session-owned handles", () => {
expect(captures).toEqual([source, destination])
expect(fixture.locations).toEqual([source, destination, destination])
expect(fixture.flushes).toEqual([source, destination, destination])
expect(fixture.activationWaits).toEqual([source, destination, destination])
expect((yield* handle.get()).revert).toBeUndefined()
}),
)
@@ -800,7 +800,7 @@ describe("SessionPrompt construction", () => {
Layer.mergeAll(
Layer.succeed(PluginHooks.Service, fixture.hooks),
Layer.succeed(PluginSupervisor.Service, {
flush: Effect.sync(() => {
awaitActivation: Effect.sync(() => {
calls.push("ready")
}),
}),
@@ -842,8 +842,8 @@ describe("SessionRevert construction", () => {
Effect.provide(
Layer.merge(
Layer.succeed(PluginSupervisor.Service, {
flush: Effect.sync(() => {
calls.push("flush")
awaitActivation: Effect.sync(() => {
calls.push("awaitActivation")
}),
}),
Layer.mock(Snapshot.Service, {
@@ -871,7 +871,7 @@ describe("SessionRevert construction", () => {
yield* revert
.stage({ session, messageID: boundary.id, files: false })
.pipe(Effect.satisfiesServicesType<never>(), Effect.provide(unrelated))
expect(calls).toEqual(["flush", "capture", "capture", "diff"])
expect(calls).toEqual(["awaitActivation", "capture", "capture", "diff"])
const staged = yield* handle.get()
expect(staged.revert?.snapshot).toBe(Snapshot.ID.make("captured-tree"))
@@ -879,7 +879,15 @@ describe("SessionRevert construction", () => {
const cleared = yield* handle.get()
expect(cleared.revert).toBeUndefined()
yield* revert.clear(cleared).pipe(Effect.satisfiesServicesType<never>(), Effect.provide(unrelated))
expect(calls).toEqual(["flush", "capture", "capture", "diff", "flush", "restore", "flush"])
expect(calls).toEqual([
"awaitActivation",
"capture",
"capture",
"diff",
"awaitActivation",
"restore",
"awaitActivation",
])
}),
)
})
@@ -60,7 +60,7 @@ const setup = Effect.gen(function* () {
const services = locations.get(session.location)
const hooks = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
yield* plugins.awaitActivation
return yield* PluginHooks.Service
}).pipe(Effect.provide(services))
return { sessions, session, hooks, services }
+1 -1
View File
@@ -115,7 +115,7 @@ const locations = (references: Layer.Layer<Reference.Service>) =>
Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({
flush: Effect.sync(() => (ready = true)),
awaitActivation: Effect.sync(() => (ready = true)),
}),
),
),
+1 -1
View File
@@ -67,7 +67,7 @@ describe("Session.revert files", () => {
yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
yield* plugins.awaitActivation
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.capture()
if (!before) throw new Error("Initial snapshot missing")
@@ -85,7 +85,10 @@ const referenceInstructions = Layer.mock(ReferenceInstructions.Service, {
})
const mcpInstructions = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const config = Config.testLayer()
const pluginSupervisor = Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void }))
const pluginSupervisor = Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({ awaitActivation: Effect.void }),
)
const promptCatalog = Layer.mock(Catalog.Service, {
provider: {
get: () => Effect.undefined,
+6 -7
View File
@@ -121,7 +121,7 @@ const compactModel = testModel("compact", { context: 4_000, output: 50 })
const fullOutputModel = testModel("full-output", { context: 262_144, output: 262_144 })
const unknownContextModel = testModel("unknown-context", { context: 0, output: 32_000 })
const undersizedContextModel = testModel("undersized-context", { context: 1, output: 1_000 })
const recoveryModel = testModel("recovery", { context: 20_000, output: 1_000 })
const recoveryModel = testModel("recovery", { context: 200_000, output: 1_000 })
test("calculates step cost using the matching context tier", () => {
expect(
@@ -206,7 +206,7 @@ const makeRunnerState = () => {
systemUnavailable: false,
systemLoadHook: Effect.void,
skillBaselines: new Map<Agent.ID, string>(),
pluginFlushHook: Effect.void,
pluginActivationHook: Effect.void,
authorizations: new Array<Tool.Context>(),
executions: new Array<string>(),
closedTransports: new Array<Session.ID>(),
@@ -390,7 +390,7 @@ const layer = Layer.unwrap(
const pluginSupervisor = Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({
flush: Effect.suspend(() => state.pluginFlushHook),
awaitActivation: Effect.suspend(() => state.pluginActivationHook),
}),
)
const promptCatalog = Layer.mock(Catalog.Service, {
@@ -1840,7 +1840,7 @@ describe("SessionRunnerLLM", () => {
scenario("waits for initial plugin readiness before constructing the model request", function* (s) {
const release = yield* Deferred.make<void>()
s.pluginFlushHook = Deferred.await(release)
s.pluginActivationHook = Deferred.await(release)
yield* s.session.prompt({ sessionID, text: "Wait for plugins", resume: false })
s.requests.length = 0
@@ -2540,17 +2540,16 @@ describe("SessionRunnerLLM", () => {
])
})
scenario("recovers from provider context overflow despite an undersized configured context limit", function* (s) {
scenario("compacts before requesting an undersized configured context limit", function* (s) {
yield* setupOverflowRecovery(s)
s.currentModel = undersizedContextModel
yield* s.llm.push(
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
TestLLM.text("## Objective\n- Recover undersized limit", "text-summary-undersized-limit"),
TestLLM.text("Recovered", "text-final-undersized-limit"),
)
yield* s.runPrompt("Continue")
expect(s.requests).toHaveLength(3)
expect(s.requests).toHaveLength(2)
expect(yield* s.context).toMatchObject([
{ type: "compaction", summary: "## Objective\n- Recover undersized limit" },
{ type: "assistant", finish: "stop" },
+1 -1
View File
@@ -53,7 +53,7 @@ const locations = makeGlobalNode({
list: () => Effect.succeed([info]),
}),
Layer.succeed(PluginSupervisor.Service, {
flush: Effect.void,
awaitActivation: Effect.void,
}),
Layer.mock(Reference.Service, { refresh: () => Effect.void }),
),
+1 -1
View File
@@ -130,7 +130,7 @@ const it = testEffect(
Catalog.node.replace(catalog),
SessionRunnerModel.node.replace(models),
Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })),
PluginSupervisor.node.replace(Layer.mock(PluginSupervisor.Service, { flush: Effect.void })),
PluginSupervisor.node.replace(Layer.mock(PluginSupervisor.Service, { awaitActivation: Effect.void })),
],
),
)
+2 -2
View File
@@ -129,7 +129,7 @@ const shellPluginSupervisor = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.effect(
PluginSupervisor.Service,
registerToolPlugin(ShellTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
registerToolPlugin(ShellTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ awaitActivation: Effect.void }))),
),
deps: [
Config.node,
@@ -220,7 +220,7 @@ const withSession = <A, E, R>(directory: string, body: (registry: Tool.Interface
const locationLayer = locations.get(location)
return yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
yield* plugins.awaitActivation
const registry = yield* Tool.Service
return yield* body(registry)
}).pipe(Effect.provide(locationLayer), Effect.ensuring(locations.invalidate(location)))
+6 -2
View File
@@ -107,7 +107,9 @@ const subagentPluginSupervisor = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.effect(
PluginSupervisor.Service,
registerToolPlugin(SubagentTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
registerToolPlugin(SubagentTool.Plugin).pipe(
Effect.as(PluginSupervisor.Service.of({ awaitActivation: Effect.void })),
),
),
deps: [Agent.node, Config.node, Permission.node, PluginRuntime.node, Tool.node],
})
@@ -155,7 +157,9 @@ const completionIt = testEffect(
const withSubagent = (location: Location.Ref) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(locations.get(location)))
yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
Effect.provide(locations.get(location)),
)
yield* Agent.Service.use((agents) =>
agents.transform((draft) => {
// The caller identity used by executeTool; subagent permission asserts against it.
+5 -1
View File
@@ -156,7 +156,11 @@ export interface Page {
readonly render: (input: { readonly data?: Record<string, any> }) => JSX.Element
}
type PromptFooterInput = { readonly sessionID?: string; readonly mode: "normal" | "shell" }
type PromptFooterInput = {
readonly sessionID?: string
readonly mode: "normal" | "shell"
readonly showDetails: boolean
}
/**
* The host UI's slot tree. Every path is one slot: a named boundary a plugin
+129
View File
@@ -486,6 +486,132 @@
"summary": "List plugins"
}
},
"/api/plugin/check": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.check",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.InfoEncoded"
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Plugin.Info"
}
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Check one or all package plugins for available updates.",
"summary": "Check plugin updates",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/plugin/update": {
"post": {
"tags": ["plugin"],
@@ -16772,6 +16898,9 @@
},
"error": {
"type": "string"
},
"ref": {
"type": "string"
}
},
"required": ["status", "error"],
+16
View File
@@ -20,6 +20,22 @@ export const PluginGroup = HttpApiGroup.make("server.plugin")
}),
),
)
.add(
HttpApiEndpoint.post("plugin.check", "/api/plugin/check", {
query: LocationQuery,
payload: Schema.Struct({ target: Schema.String.pipe(Schema.optional) }),
success: Location.response(Schema.Array(Plugin.Info)),
error: InvalidRequestError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.plugin.check",
summary: "Check plugin updates",
description: "Check one or all package plugins for available updates.",
}),
),
)
.add(
HttpApiEndpoint.post("plugin.update", "/api/plugin/update", {
query: LocationQuery,
+1 -1
View File
@@ -29,7 +29,7 @@ export type Features = typeof Features.Type
export const State = Schema.Union([
Schema.Struct({ status: Schema.Literal("active") }),
Schema.Struct({ status: Schema.Literal("failed"), error: Schema.String }),
Schema.Struct({ status: Schema.Literal("failed"), error: Schema.String, ref: Schema.String.pipe(optional) }),
]).annotate({ identifier: "Plugin.State" })
export type State = typeof State.Type
+4
View File
@@ -12,3 +12,7 @@ export const Info = Schema.Struct({
write: Schema.Finite,
}),
}).annotate({ identifier: "TokenUsage.Info" })
export function total(tokens: Info) {
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
}
+14
View File
@@ -23,3 +23,17 @@ test("embeds plugin state with a status discriminator", () => {
outdated: true,
})
})
test("plugin failure references round trip and absent references stay omitted", () => {
const codec = Schema.fromJsonString(Plugin.State)
const failed = { status: "failed", error: "Plugin failed to load", ref: "err_a1b2c3d4" } as const
expect(Schema.decodeUnknownSync(codec)(Schema.encodeSync(codec)(failed))).toEqual(failed)
expect(Schema.encodeSync(Plugin.State)({ ...failed, ref: undefined })).toEqual({
status: "failed",
error: failed.error,
})
expect(Schema.decodeUnknownSync(Plugin.State)({ status: "failed", error: failed.error })).toMatchObject({
status: "failed",
error: failed.error,
})
})
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "bun:test"
import { TokenUsage } from "../src/token-usage.js"
test("totals every token category", () => {
expect(TokenUsage.total({ input: 1, output: 2, reasoning: 4, cache: { read: 8, write: 16 } })).toBe(31)
})
test("supports zero and large usage totals without rounding", () => {
expect(TokenUsage.total({ input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } })).toBe(0)
expect(
TokenUsage.total({
input: 1_000_000_000,
output: 2_000_000_000,
reasoning: 3,
cache: { read: 4_000_000_000, write: 5 },
}),
).toBe(7_000_000_008)
})
+1 -1
View File
@@ -48,7 +48,7 @@ for (const selection of ["explicit", "default"] as const) {
PluginSupervisor.Service,
Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
return { flush: release.open.pipe(Effect.andThen(plugins.flush)) }
return { awaitActivation: release.open.pipe(Effect.andThen(plugins.awaitActivation)) }
}),
).pipe(Layer.provide(layer)),
)
+43 -1
View File
@@ -14,10 +14,52 @@ export const PluginHandler = HttpApiBuilder.group(Api, "server.plugin", (handler
return yield* response(Plugin.Service.use((plugin) => plugin.list()))
}),
)
.handle("plugin.check", (ctx) =>
Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.awaitActivation
const plugins = yield* Plugin.Service
const inventory = yield* plugins.list()
const targets = [
...new Set(
inventory.flatMap((plugin) => (plugin.source.type === "package" ? [plugin.source.target] : [])),
),
].filter((target) => ctx.payload.target === undefined || target === ctx.payload.target)
if (ctx.payload.target !== undefined && !targets.length)
return yield* new InvalidRequestError({
message: `Plugin package is not in the current server inventory: ${ctx.payload.target}`,
field: "target",
})
const updates = yield* PluginUpdate.Service
const outdated = new Map(
yield* Effect.forEach(
targets,
(target) => updates.check(target, { refresh: true }).pipe(Effect.map((value) => [target, value] as const)),
{ concurrency: "unbounded" },
),
)
return yield* response(
Effect.succeed(
inventory.map((plugin) => {
if (plugin.source.type !== "package" || !outdated.has(plugin.source.target)) return plugin
return {
...plugin,
source: {
type: "package" as const,
target: plugin.source.target,
...(plugin.source.version ? { version: plugin.source.version } : {}),
...(outdated.get(plugin.source.target) ? { outdated: true as const } : {}),
},
}
}),
),
)
}),
)
.handle("plugin.update", (ctx) =>
Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
yield* supervisor.awaitActivation
const plugins = yield* Plugin.Service
if (
!(yield* plugins.list()).some(
+1 -1
View File
@@ -9,7 +9,7 @@ export const RpcHandler = HttpApiBuilder.group(Api, "server.rpc", (handlers) =>
handlers.handle("rpc.call", ({ params, payload }) =>
Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
yield* supervisor.awaitActivation
const rpc = yield* Rpc.Service
const output = yield* rpc.call(params.rpcID, params.method, payload.input)
return output === undefined ? {} : { output }
+18 -1
View File
@@ -37,7 +37,7 @@ it.live("updates package plugins in the requested Location", () =>
Npm.Service.of({
add: () => Effect.sync(entry),
resolve: () => Effect.sync(entry),
check: () => Effect.succeed(false),
check: () => Effect.sync(() => version === "1.0.0"),
update: () => {
if (fail) return Effect.fail(new Npm.InstallFailedError({ dir: plugin }))
return Effect.sync(() => (version = "2.0.0")).pipe(Effect.map(entry))
@@ -60,6 +60,23 @@ it.live("updates package plugins in the requested Location", () =>
),
)
const checked = yield* Effect.promise(async () => {
const response = await handler(
new Request(`http://opencode.local/api/plugin/check?location[directory]=${encodeURIComponent(project)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
}),
)
return response.json() as Promise<{
data: Array<{ id?: string; source: { target?: string; outdated?: true } }>
}>
})
expect(checked.data.find((item) => item.id === "fixture.plugin")?.source).toMatchObject({
target: "fixture-plugin",
outdated: true,
})
expect((yield* update("fixture-plugin")).status).toBe(204)
const listedVersion = yield* Effect.promise(async () => {
const response = await handler(
+1 -1
View File
@@ -48,7 +48,7 @@ const fixture = Effect.fn(function* (plugins: readonly Plugin.Plugin[]) {
boot: (directory: string) =>
Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
yield* supervisor.awaitActivation
}).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory) })))),
call: (
route: string,
@@ -250,7 +250,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
Layer.build(locations.get(Location.Ref.make({ directory: AbsolutePath.make(secondDirectory) }))),
])
yield* Effect.forEach([primary, secondary], (context) =>
PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(context)),
PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(Effect.provide(context)),
)
expect(activations).toBe(2)
+6
View File
@@ -0,0 +1,6 @@
# TUI Experiments
- Temporary, opt-in UI experiments belong in the `experiments` registry in `src/component/dialog-experiments.tsx`, with behavior gated by the matching `config.experimental` key.
- The Experiments framework is permanent infrastructure. Keep the registry, dialog, settings persistence, toggle handling, and UI entry point even when there are no active experiments.
- Graduating an experiment means removing only its registry entry and feature-specific gate, making that feature's behavior unconditional. Abandoning an experiment means removing its entry, gate, and experimental behavior.
- Do not remove or simplify away the reusable Experiments infrastructure because the registry is empty. An empty registry is intentional and ready for future experiments, not dead code.
+1 -1
View File
@@ -1321,7 +1321,7 @@ function App(props: { pair?: DialogPairCredentials }) {
<PaneResizeHandle resize={tabsResize} left={tabsResize.size() - 1} />
</Show>
</box>
<Show when={devtools()}>
<Show when={devtools() && !(route.data.type === "plugin" && route.data.id === "opencode.stats")}>
<DevToolsBar />
</Show>
<Show when={!startup.skipInitialLoading}>
@@ -1,6 +1,6 @@
import { CliRenderEvents, TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
import { useConfig } from "../config"
import { useClipboard } from "../context/clipboard"
import { Keymap } from "../context/keymap"
@@ -9,17 +9,25 @@ import { useRoute } from "../context/route"
import { getScrollAcceleration } from "../util/scroll"
import { useTheme } from "../context/theme"
import { emptyPrompt } from "../prompt/history"
import { useDialog } from "../ui/dialog"
import { dialogWidth, useDialog } from "../ui/dialog"
import { FilePath } from "../ui/file-path"
import { useToast } from "../ui/toast"
import { errorDetails } from "../util/error-details"
export function DialogErrorDetails(props: { title: string; error: string; context?: string; onBack: () => void }) {
export function DialogErrorDetails(props: {
title: string
source?: string
error: string
context?: string
diagnosticRef?: string
onBack: () => void
}) {
const clipboard = useClipboard()
const dialog = useDialog()
const location = useLocation()
const route = useRoute()
const toast = useToast()
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const config = useConfig().data
@@ -51,7 +59,7 @@ export function DialogErrorDetails(props: { title: string; error: string; contex
const copy = () => {
void clipboard
.write(props.error)
.write(errorDetails(props).text)
.then(() => setCopied(true))
.catch(toast.error)
}
@@ -62,7 +70,7 @@ export function DialogErrorDetails(props: { title: string; error: string; contex
location: location.ref,
prompt: {
...emptyPrompt(),
text: `Investigate why this OpenCode component failed in the current project.\n\n${props.title}${props.context ? `\n${props.context}` : ""}\nError: ${props.error}\n\nInspect the relevant project and global OpenCode configuration, startup or loading behavior, required environment variables or credentials, dependencies, and logs. Identify the root cause and recommend a fix.`,
text: errorDetails(props).prompt,
},
})
dialog.clear()
@@ -88,40 +96,49 @@ export function DialogErrorDetails(props: { title: string; error: string; contex
})
return (
<box paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
{props.title}
</text>
<text fg={theme.text.subdued} onMouseUp={props.onBack}>
esc
</text>
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<box>
<box flexDirection="row" gap={2}>
<text
attributes={TextAttributes.BOLD}
fg={theme.text.default}
flexGrow={1}
minWidth={0}
wrapMode="none"
truncate
>
{props.title}
</text>
<text fg={theme.text.subdued} flexShrink={0} onMouseUp={props.onBack}>
esc
</text>
</box>
<Show when={props.source}>
{(source) => (
<FilePath
value={source()}
maxWidth={Math.min(dialogWidth(dialog.size), dimensions().width - 2) - 4}
fg={theme.text.subdued}
/>
)}
</Show>
</box>
<box
backgroundColor={overlayTheme.background.default}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
>
<box>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
height={height()}
scrollbarOptions={{ visible: false }}
scrollAcceleration={getScrollAcceleration(config)}
>
<text fg={overlayTheme.text.default} wrapMode="word">
<text fg={theme.text.default} wrapMode="word">
{props.error}
</text>
</scrollbox>
<Show when={props.diagnosticRef}>
<text fg={theme.text.subdued}>Reference: {props.diagnosticRef}</text>
</Show>
</box>
<box flexDirection="row" gap={3} paddingLeft={2} paddingRight={2}>
<text flexGrow={1}>
<span style={{ fg: theme.text.default }}>
<b>{scrollable() ? "↑/↓" : ""}</b>
</span>
<span style={{ fg: theme.text.subdued }}>{scrollable() ? " scroll" : ""}</span>
</text>
<box flexDirection="row" gap={3} flexWrap="wrap">
<text onMouseUp={investigate}>
<span style={{ fg: theme.text.default }}>
<b>i</b>
@@ -134,6 +151,9 @@ export function DialogErrorDetails(props: { title: string; error: string; contex
</span>
<span style={{ fg: theme.text.subdued }}>{copied() ? "" : " copy details"}</span>
</text>
<Show when={scrollable()}>
<text fg={theme.text.subdued}>/ scroll</text>
</Show>
</box>
</box>
)
@@ -13,13 +13,7 @@ type Experiment = {
// In-flight features anyone can opt into. Each entry is temporary: an
// experiment either graduates (delete the entry, make the behavior
// unconditional) or dies (delete the entry and the branch it gated).
export const experiments: Experiment[] = [
{
id: "session-preview-tabs",
title: "Session preview tabs",
description: "Preview sessions in temporary tabs before keeping them open",
},
]
export const experiments: Experiment[] = []
export function DialogExperiments() {
const config = useConfig()
+5 -1
View File
@@ -1595,7 +1595,11 @@ export function Prompt(props: PromptProps) {
if (agentLabel()) revealedPromptMetadata.add(local)
})
const borderHighlight = createMemo(() => tint(theme.border.default, highlight(), agentMetaAlpha()))
const footerInput = () => ({ sessionID: props.sessionID, mode: store.mode })
const footerInput = () => ({
sessionID: props.sessionID,
mode: store.mode,
showDetails: store.interrupt === 0 || dimensions().width >= 80,
})
const placeholderText = createMemo(() => {
if (props.showPlaceholder === false) return undefined
+2 -1
View File
@@ -374,6 +374,7 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
}))
const actions = createMemo(() => {
const sessionID = props.state.sessionID
const title = props.state.title
return [
...(props.tabs.add ? [{ title: "New tab", run: () => props.tabs.add?.() }] : []),
...(sessionID
@@ -383,7 +384,7 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
: []),
{
title: "Rename",
run: () => DialogSessionRename.show(dialog, sessionID, props.state.title),
run: () => DialogSessionRename.show(dialog, sessionID, title),
},
{ title: "Close", run: () => props.tabs.close(sessionID) },
]
@@ -0,0 +1,41 @@
import { createEffect, untrack, type Accessor } from "solid-js"
import type { Data } from "@opencode-ai/client/solid"
export function createSessionRetention(input: {
session: Pick<Data["session"], "list" | "root" | "evict">
current: Accessor<string | undefined>
keep: Accessor<readonly string[]>
limit: number
}) {
let previous: string | undefined
let recent: string[] = []
let evicted = new Map<string, string>()
createEffect(() => {
const viewed = input.current()
const current = viewed === undefined ? undefined : input.session.root(viewed)
const keep = new Set(input.keep().map((id) => input.session.root(id)))
// Resolve again when metadata arrives, but only navigation advances recency.
recent = [
...new Set([
...(viewed !== previous && current !== undefined ? [current] : []),
...recent.map((id) => input.session.root(id)),
]),
]
previous = viewed
const retained = new Set([...keep, ...recent.filter((id) => !keep.has(id)).slice(0, input.limit)])
if (current !== undefined) retained.add(current)
recent = recent.filter((id) => retained.has(id))
const excluded = new Map(
input.session.list().flatMap((session) => {
const root = input.session.root(session.id)
return retained.has(root) ? [] : [[session.id, root] as const]
}),
)
// New descendants need eviction even when their root was already evicted.
const roots = new Set([...excluded].filter(([id, root]) => evicted.get(id) !== root).map(([, root]) => root))
evicted = excluded
untrack(() => roots.forEach((root) => input.session.evict(root)))
})
}
+18 -26
View File
@@ -1,4 +1,5 @@
import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useKeyboard, useRenderer } from "@opentui/solid"
import { isDeepEqual } from "remeda"
import { createSimpleContext } from "./helper"
@@ -12,6 +13,7 @@ import { useLocation } from "./location"
import { useStorage } from "./storage"
import { useTuiPaths } from "./runtime"
import { newSessionLocation } from "../config/new-session-location"
import { createSessionRetention } from "./session-retention"
import {
closeSessionTab,
cycleSessionTab,
@@ -63,7 +65,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const renderer = useRenderer()
const storage = useStorage()
const enabled = () => config.tabs.enabled
const previews = () => config.experimental?.["session-preview-tabs"] === true
const [focused, setFocused] = createSignal<boolean>()
// Keyed reconcile keeps tab object identity across reorders, so strip rows move instead of
// mutating in place, which per-row animations and drag state depend on.
@@ -74,9 +75,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
},
key: "sessionID",
})
const [preview, updatePreview] = storage.memory<{ global?: string; cwd?: string }>("session-tab-preview", {
initial: {},
})
const [preview, updatePreview] = createStore<{ global?: string; cwd?: string }>({})
const fallback = empty()
const [promptPulses, setPromptPulses] = createSignal<Record<string, number>>({})
let history: SessionTabHistory = { entries: [], index: -1 }
@@ -108,16 +107,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
}
const previewID = () => preview[config.tabs.scope]
const setPreview = (sessionID: string | undefined) => {
const scope = config.tabs.scope
updatePreview((draft) => {
if (sessionID === undefined) {
delete draft[scope]
return
}
draft[scope] = sessionID
})
}
const setPreview = (sessionID: string | undefined) => updatePreview(config.tabs.scope, sessionID)
function update(mutation: (draft: TabsState) => void) {
const scope = config.tabs.scope
@@ -149,6 +139,13 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
unread: {},
})
const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
createSessionRetention({
session: data.session,
current: () =>
route.data.type === "session" && route.data.sessionID !== "dummy" ? route.data.sessionID : undefined,
keep: () => (enabled() ? state().tabs.map((tab) => tab.sessionID) : []),
limit: 3,
})
const newTab = createMemo((open = false) => {
if (route.data.type === "home") return true
if (!open) return false
@@ -178,13 +175,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
}
createEffect(() => {
if (enabled() && previews()) return
if (enabled()) return
promotedSession = undefined
if (!preview.global && !preview.cwd) return
updatePreview((draft) => {
delete draft.global
delete draft.cwd
})
updatePreview({ global: undefined, cwd: undefined })
})
// Shared storage updates must not re-admit a tab unless this client changes route or scope.
@@ -203,10 +196,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
history = recordSessionTabHistory(history, sessionID)
if (state().tabs.some((tab) => tab.sessionID === sessionID)) return
const fallback = newTab() ? NEW_SESSION_TAB_TITLE : undefined
const temporary = previews() && !permanent
const replaced = temporary ? previewID() : undefined
const replaced = permanent ? undefined : previewID()
if (replaced) scrollAnchors.delete(replaced)
if (temporary) setPreview(sessionID)
if (!permanent) setPreview(sessionID)
update((draft) => {
if (cancelledTabs.has(sessionID)) return
const tab = {
@@ -377,7 +369,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
return state().tabs
},
isPreview(sessionID: string) {
return enabled() && previews() && previewID() === root(sessionID)
return enabled() && previewID() === root(sessionID)
},
newTab() {
return newTab()
@@ -404,7 +396,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
route.navigate({ type: "session", sessionID: root(sessionID) })
},
promote(sessionID: string) {
if (!enabled() || !previews()) return
if (!enabled()) return
const session = root(sessionID)
if (previewID() === session) {
setPreview(undefined)
@@ -448,7 +440,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const tabs = result.tabs
if (!tabs || !result.sessionID) return
cancelledTabs.delete(result.sessionID)
if (previews()) promotedSession = result.sessionID
promotedSession = result.sessionID
update((draft) => {
draft.tabs = tabs
})
@@ -8,7 +8,12 @@ const money = new Intl.NumberFormat("en-US", {
currency: "USD",
})
export function PromptFooter(props: { context: Plugin.Context; sessionID?: string; mode: "normal" | "shell" }) {
export function PromptFooter(props: {
context: Plugin.Context
sessionID?: string
mode: "normal" | "shell"
showDetails: boolean
}) {
const dimensions = useTerminalDimensions()
const [liveHovered, setLiveHovered] = createSignal(false)
const subagents = createMemo(() => {
@@ -69,7 +74,7 @@ export function PromptFooter(props: { context: Plugin.Context; sessionID?: strin
</text>
</box>
</Show>
<Show when={status().length > 0}>
<Show when={props.showDetails && status().length > 0}>
<text fg={props.context.theme.text.subdued} wrapMode="none" truncate flexShrink={1}>
<Show when={live()}> · </Show>
{status().join(" · ")}
@@ -77,14 +82,14 @@ export function PromptFooter(props: { context: Plugin.Context; sessionID?: strin
</Show>
</box>
</Match>
<Match when={dimensions().width >= 44}>
<Match when={props.showDetails && dimensions().width >= 44}>
<text fg={props.context.theme.text.default} flexShrink={0}>
{shortcut("agent.cycle")} <span style={{ fg: props.context.theme.text.subdued }}>agents</span>
</text>
</Match>
</Switch>
<Show when={dimensions().width >= 44}>
<text fg={props.context.theme.text.default} flexShrink={0}>
<Show when={props.showDetails}>
<text fg={props.context.theme.text.default} wrapMode="none" flexShrink={0}>
{shortcut("command.palette.show")} <span style={{ fg: props.context.theme.text.subdued }}>commands</span>
</text>
</Show>
@@ -106,7 +111,14 @@ export default Plugin.define({
setup(context) {
context.ui.slot({
append: "prompt.footer",
render: (props) => <PromptFooter context={context} sessionID={props.sessionID} mode={props.mode} />,
render: (props) => (
<PromptFooter
context={context}
sessionID={props.sessionID}
mode={props.mode}
showDetails={props.showDetails}
/>
),
})
},
})
@@ -186,9 +186,11 @@ export function PluginsDialog(props: {
>
{(entry) => (
<DialogErrorDetails
title={`${entry().runtime === "tui" ? "TUI" : "Server"} plugin: ${label(entry(), props.context)}`}
title={`${entry().runtime === "tui" ? "TUI" : "Server"} plugin error`}
source={pluginSource(entry(), props.context)}
error={pluginError(entry()) ?? "Unknown plugin error"}
context={`Status: failed\nRuntime: ${entry().runtime}\nSource: ${pluginSource(entry(), props.context)}`}
diagnosticRef={pluginErrorRef(entry())}
context={`Plugin: ${label(entry(), props.context)}\nStatus: failed\nRuntime: ${entry().runtime}\nSource: ${pluginSource(entry(), props.context)}`}
onBack={() => {
setDetail()
dialog.setSize("medium")
@@ -241,11 +243,14 @@ function displayVersion(version: string) {
}
function pluginError(entry: Entry | undefined) {
if (entry?.runtime === "server")
return entry.plugin.state.status === "failed" ? entry.plugin.state.error : undefined
if (entry?.runtime === "server") return entry.plugin.state.status === "failed" ? entry.plugin.state.error : undefined
return entry?.error
}
function pluginErrorRef(entry: Entry) {
if (entry.runtime === "server" && entry.plugin.state.status === "failed") return entry.plugin.state.ref
}
function Commands(props: { context: Plugin.Context }) {
const plugins = usePlugin()
props.context.keymap.layer(() => ({
@@ -0,0 +1,18 @@
import type { SessionStatsInfo } from "@opencode-ai/client"
import { TokenUsage } from "@opencode-ai/schema/token-usage"
export function statsMetrics(stats: SessionStatsInfo) {
return [
{
label: "tokens",
value: TokenUsage.total(stats.tokens),
},
{ label: "best streak", value: stats.streak },
{ label: "active days", value: stats.activeDays },
{ label: "sessions", value: stats.sessions },
]
}
export function statsNumber(value: number) {
return new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(value)
}
@@ -0,0 +1,218 @@
import type { SessionStatsInfo } from "@opencode-ai/client"
import { Plugin } from "@opencode-ai/plugin/tui"
import { activityCalendar } from "@opencode-ai/util/activity-calendar"
import { TextAttributes } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import { createMemo, createResource, createSignal, For, Show } from "solid-js"
import { Logo } from "../../component/logo"
import { useTheme, useThemes } from "../../context/theme"
import { tint } from "../../theme/color"
import { statsMetrics, statsNumber } from "./stats-data"
const digits: Record<string, string[]> = {
"0": ["111", "101", "101", "101", "111"],
"1": ["010", "110", "010", "010", "111"],
"2": ["111", "001", "111", "100", "111"],
"3": ["111", "001", "111", "001", "111"],
"4": ["101", "101", "111", "001", "001"],
"5": ["111", "100", "111", "001", "111"],
"6": ["111", "100", "111", "101", "111"],
"7": ["111", "001", "010", "010", "010"],
"8": ["111", "101", "111", "101", "111"],
"9": ["111", "101", "111", "001", "111"],
".": ["0", "0", "0", "0", "1"],
K: ["101", "110", "100", "110", "101"],
M: ["10001", "11011", "10101", "10001", "10001"],
B: ["110", "101", "110", "101", "110"],
T: ["111", "010", "010", "010", "010"],
}
export function StatsPoster(props: { stats: SessionStatsInfo }) {
const dimensions = useTerminalDimensions()
const theme = useTheme()
const themes = useThemes()
const width = () => Math.max(12, Math.min(110, dimensions().width - 8))
const compact = () => dimensions().height < 38
const metrics = createMemo(() => statsMetrics(props.stats))
const number = () => statsNumber(metrics()[0].value)
const dates = createMemo(() =>
new Intl.DateTimeFormat("en-US", { month: "short", year: "numeric" }).formatRange(
new Date(props.stats.range.from),
new Date(Math.max(props.stats.range.from, props.stats.range.to - 1)),
),
)
const letters = createMemo(() => Array.from(number()).map((char) => digits[char]))
const large = () =>
letters().every(Boolean) && letters().reduce((sum, char) => sum + char[0].length * 2 + 2, -2) <= width()
const calendar = createMemo(() =>
activityCalendar({
activity: props.stats.activity,
from: props.stats.range.from,
to: props.stats.range.to,
maxWeeks: Math.floor((width() - 4) / 2),
}),
)
const shades = createMemo(() => [
theme.text.subdued,
...[0.3, 0.5, 0.75, 1].map((alpha) =>
tint(theme.background.default, theme.categorical[0][themes.mode() === "light" ? 800 : 200], alpha),
),
])
return (
<box width={width()} flexDirection="column" alignItems="center" flexShrink={0} gap={compact() ? 1 : 2}>
<box width="100%" flexDirection={width() < 44 ? "column" : "row"} justifyContent="space-between">
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
opencode / stats
</text>
<text fg={theme.text.subdued}>{dates()}</text>
</box>
<Show when={!compact()}>
<Logo />
</Show>
<box alignItems="center" gap={1}>
<Show
when={large()}
fallback={
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
{number()}
</text>
}
>
<box>
<For each={[0, 1, 2, 3, 4]}>
{(row) => (
<text fg={theme.text.default} selectable={false}>
{letters()
.map((char) => char[row].replaceAll("1", "\u2588\u2588").replaceAll("0", " "))
.join(" ")}
</text>
)}
</For>
</box>
</Show>
<text fg={theme.text.subdued}>TOKENS</text>
</box>
<box alignItems="center">
<text fg={theme.text.subdued}>
{" " +
calendar()
.months.map((month) => (month.label.length <= month.span * 2 ? month.label : "").padEnd(month.span * 2))
.join("")}
</text>
<For each={["M", "T", "W", "T", "F", "S", "S"]}>
{(day, index) => (
<box flexDirection="row" height={1}>
<text fg={theme.text.subdued}>{day + " "}</text>
<For each={calendar().weeks}>
{(week) => (
<text fg={shades()[Math.max(0, week[index()].level)]} selectable={false}>
{week[index()].level < 0 ? " " : week[index()].level === 0 ? "\u00b7 " : "\u25a0 "}
</text>
)}
</For>
</box>
)}
</For>
<Show when={calendar().clipped}>
<text fg={theme.text.subdued}>Your last {calendar().weeks.length} weeks</text>
</Show>
</box>
<box width="100%" flexDirection="row" justifyContent="space-around">
<For each={metrics().slice(1)}>
{(metric) => (
<box alignItems="center">
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
{statsNumber(metric.value)}
{metric.label === "best streak" ? " days" : ""}
</text>
<text fg={theme.text.subdued}>{metric.label}</text>
</box>
)}
</For>
</box>
<box width="100%" flexDirection="row" justifyContent="flex-end">
<text fg={theme.text.default}>opencode.ai</text>
</box>
</box>
)
}
function StatsPage(props: { context: Plugin.Context; onClose: () => void }) {
const [result] = createResource(() => {
const now = new Date()
return props.context.client.session.stats({
from: new Date(now.getFullYear(), 0, 1).getTime(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
tools: "none",
})
})
const theme = useTheme()
props.context.keymap.layer(() => ({
commands: [{ bind: "escape", title: "back", run: props.onClose }],
}))
return (
<box width="100%" height="100%" backgroundColor={theme.background.default}>
<scrollbox
flexGrow={1}
contentOptions={{
alignItems: "center",
justifyContent: "center",
minHeight: "100%",
paddingTop: 1,
paddingBottom: 1,
}}
>
<Show
when={!result.error}
fallback={
<text fg={theme.text.feedback.error.default}>Could not load stats. Reopen /stats to try again.</text>
}
>
<Show when={result()} fallback={<text fg={theme.text.subdued}>Gathering your stats...</text>}>
{(value) => <StatsPoster stats={value()} />}
</Show>
</Show>
</scrollbox>
</box>
)
}
export default Plugin.define({
id: "opencode.stats",
setup(context) {
const [previous, setPrevious] = createSignal({ ...context.ui.router.current() })
context.ui.router.register({
name: "stats",
render: () => <StatsPage context={context} onClose={() => context.ui.router.navigate(previous())} />,
})
context.ui.slot({
append: "app",
render() {
context.keymap.layer(() => ({
mode: "global",
commands: [
{
id: "stats.open",
title: "Usage statistics",
group: "System",
slash: { name: "stats" },
palette: true,
run() {
const current = context.ui.router.current()
if (current.type === "plugin" && current.name === "stats") return
// The router exposes a mutable store; retain the route before navigating.
setPrevious({ ...current })
context.ui.dialog.clear()
context.ui.router.navigate({ type: "plugin", name: "stats" })
},
},
],
}))
return null
},
})
},
})
+2
View File
@@ -7,6 +7,7 @@ import DiffViewer from "../feature-plugins/system/diff-viewer"
import Notifications from "../feature-plugins/system/notifications"
import Plugins from "../feature-plugins/system/plugins"
import Storybook from "../feature-plugins/system/storybook"
import Stats from "../feature-plugins/system/stats"
import Latex from "@opencode-ai/latex/plugin"
import Merman from "@opencode-ai/merman/plugin"
@@ -18,6 +19,7 @@ export const builtins = [
SidebarFooter,
Notifications,
Plugins,
Stats,
Merman,
Latex,
// The storybook is a development tool; keep its route and palette commands out of
+15 -4
View File
@@ -3087,11 +3087,13 @@ function StatusBadge(props: { children: string }) {
type BlockToolProps = {
title?: string
path?: { label: string; value: string }
headerColor?: RGBA
children?: JSX.Element
onClick?: () => void
part?: SessionMessageAssistantTool
spinner?: boolean
error?: string
errorColor?: RGBA
}
function BlockTool(props: BlockToolProps) {
@@ -3137,7 +3139,11 @@ function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
<Show
when={props.spinner}
fallback={
<text fg={permission() ? theme.text.feedback.warning.default : theme.text.subdued}>{title()}</text>
<text
fg={permission() ? theme.text.feedback.warning.default : (props.headerColor ?? theme.text.subdued)}
>
{title()}
</text>
}
>
<Spinner color={permission() ? theme.text.feedback.warning.default : theme.text.subdued}>
@@ -3153,7 +3159,10 @@ function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
<Show
when={props.spinner}
fallback={
<text flexShrink={0} fg={permission() ? theme.text.feedback.warning.default : theme.text.subdued}>
<text
flexShrink={0}
fg={permission() ? theme.text.feedback.warning.default : (props.headerColor ?? theme.text.subdued)}
>
{path().label}
</text>
}
@@ -3165,14 +3174,14 @@ function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
<FilePath
value={path().value}
maxWidth={Math.max(2, ctx.width - 4 - stringWidth(path().label) - (props.spinner ? 2 : 0))}
fg={permission() ? theme.text.feedback.warning.default : theme.text.subdued}
fg={permission() ? theme.text.feedback.warning.default : (props.headerColor ?? theme.text.subdued)}
/>
</box>
)}
</Show>
{props.children}
<Show when={error()}>
<text fg={theme.text.feedback.error.default}>{error()}</text>
<text fg={props.errorColor ?? theme.text.feedback.error.default}>{error()}</text>
</Show>
</box>
)
@@ -3777,6 +3786,8 @@ function ApplyPatch(props: ToolProps) {
}
part={props.part}
spinner={props.part.state.status === "streaming" || props.part.state.status === "running"}
headerColor={props.part.state.status === "error" ? theme.text.feedback.error.default : undefined}
errorColor={props.part.state.status === "error" ? theme.text.subdued : undefined}
/>
</Match>
</Switch>
+21
View File
@@ -0,0 +1,21 @@
export function errorDetails(input: { title: string; error: string; context?: string; diagnosticRef?: string }) {
const text = [
input.title,
...(input.context ? [input.context] : []),
`Error: ${input.error}`,
...(input.diagnosticRef ? [`Reference: ${input.diagnosticRef}`] : []),
].join("\n")
return {
text,
prompt: [
"Investigate why this OpenCode component failed in the current project.",
text,
...(input.diagnosticRef
? [
`Find the server log entry matching reference ${input.diagnosticRef} and inspect its original cause. If the server logs are not accessible from this session, say so and ask for the matching log entry; do not infer the cause from the reference alone.`,
]
: []),
"Inspect the relevant project and global OpenCode configuration, startup or loading behavior, required environment variables or credentials, dependencies, and logs. Identify the root cause and recommend a fix. Do not expose credentials.",
].join("\n\n"),
}
}
@@ -0,0 +1,110 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { onMount } from "solid-js"
import { DialogErrorDetails } from "../../src/component/dialog-error-details"
import { ConfigProvider } from "../../src/config"
import { ClientProvider } from "../../src/context/client"
import { DataProvider } from "../../src/context/data"
import { Keymap } from "../../src/context/keymap"
import { LocationProvider } from "../../src/context/location"
import { RouteProvider, useRoute } from "../../src/context/route"
import { ThemeProvider } from "../../src/context/theme"
import { DialogProvider, useDialog } from "../../src/ui/dialog"
import { ToastProvider } from "../../src/ui/toast"
import { emptyThemeSource, tmpdir } from "../fixture/fixture"
import { createApi, createEventStream, createFetch } from "../fixture/tui-client"
import { TestTuiContexts } from "../fixture/tui-environment"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
for (const width of [40, 100]) {
test(`error details stay compact at ${width} columns and investigate only prepares a draft`, async () => {
await using temporary = await tmpdir()
const source = "/private/var/folders/very-long-temporary-directory/opencode/project/plugins/broken/index.ts"
const copied: string[] = []
const submitted: string[] = []
let route: ReturnType<typeof useRoute> | undefined
const api = createApi(
createFetch((url, request) => {
if (request.method === "POST") submitted.push(url.pathname)
return undefined
}, createEventStream()).fetch,
)
function OpenDialog() {
route = useRoute()
const dialog = useDialog()
onMount(() =>
dialog.replace(
<DialogErrorDetails
title="Server plugin error"
source={source}
error="Plugin failed to load"
context={`Plugin: broken\nRuntime: server\nSource: ${source}`}
diagnosticRef="err_a1b2c3d4"
onBack={() => dialog.clear()}
/>,
),
)
return null
}
const app = await testRender(
() => (
<TestTuiContexts
directory={temporary.path}
paths={{ state: temporary.path }}
clipboard={{ read: async () => undefined, write: async (text) => void copied.push(text) }}
>
<ConfigProvider config={createTuiResolvedConfig()}>
<RouteProvider initialRoute={{ type: "home" }}>
<ClientProvider api={api}>
<DataProvider directory={temporary.path}>
<LocationProvider>
<ThemeProvider mode={width === 40 ? "light" : "dark"} source={emptyThemeSource}>
<Keymap.Provider>
<ToastProvider>
<DialogProvider>
<OpenDialog />
</DialogProvider>
</ToastProvider>
</Keymap.Provider>
</ThemeProvider>
</LocationProvider>
</DataProvider>
</ClientProvider>
</RouteProvider>
</ConfigProvider>
</TestTuiContexts>
),
{ width, height: 24, kittyKeyboard: true },
)
try {
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Reference: err_a1b2c3d4"))
const lines = app.captureCharFrame().split("\n")
const heading = lines.find((line) => line.includes("Server plugin error"))
expect(heading).toContain("esc")
expect(lines.some((line) => line.includes("broken/index.ts"))).toBe(true)
expect(lines.join("\n")).not.toContain("very-long-temporary-directory")
const message = lines.find((line) => line.includes("Plugin failed to load"))
const reference = lines.find((line) => line.includes("Reference:"))
expect(message?.indexOf("Plugin")).toBe(reference?.indexOf("Reference:"))
expect(lines.filter((line) => line.trim()).length).toBeLessThanOrEqual(6)
app.mockInput.pressKey("c")
await app.waitFor(() => copied.length === 1)
expect(copied[0]).toContain(source)
expect(copied[0]).toContain("Reference: err_a1b2c3d4")
app.mockInput.pressKey("i")
await app.waitFor(() => route?.data.type === "home" && !!route.data.prompt)
const current = route?.data
expect(current?.type === "home" && current.prompt?.text).toContain(source)
expect(current?.type === "home" && current.prompt?.text).toContain("matching reference err_a1b2c3d4")
expect(submitted).toEqual([])
} finally {
app.renderer.destroy()
}
})
}
@@ -4,6 +4,7 @@ import { MouseButton } from "@opentui/core"
import { expect, test } from "bun:test"
import { createSignal } from "solid-js"
import { ConfigProvider } from "../../src/config"
import { ClientProvider } from "../../src/context/client"
import { EMPTY_SESSION_TAB_STATUS, SessionTabs, type SessionTabsController } from "../../src/component/session-tabs"
import { Keymap } from "../../src/context/keymap"
import { ThemeProvider } from "../../src/context/theme"
@@ -11,6 +12,7 @@ import { DialogProvider } from "../../src/ui/dialog"
import { ToastProvider } from "../../src/ui/toast"
import { emptyThemeSource } from "../fixture/fixture"
import { TestTuiContexts } from "../fixture/tui-environment"
import { createApi, createFetch } from "../fixture/tui-client"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
test("releasing a transcript selection over tab controls does not activate them", async () => {
@@ -68,6 +70,7 @@ test("releasing a transcript selection over tab controls does not activate them"
test("the horizontal tab context menu keeps preview tabs open without selecting them", async () => {
const [active, setActive] = createSignal("first")
const promoted: string[] = []
const calls = createFetch()
const controller = {
tabs: () => [
{ sessionID: "first", title: "First" },
@@ -86,13 +89,15 @@ test("the horizontal tab context menu keeps preview tabs open without selecting
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<DialogProvider>
<SessionTabs controller={controller} animations={false} />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
<ClientProvider api={createApi(calls.fetch)}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<DialogProvider>
<SessionTabs controller={controller} animations={false} />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</ClientProvider>
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
@@ -113,8 +118,15 @@ test("the horizontal tab context menu keeps preview tabs open without selecting
expect(app.captureCharFrame()).toContain("Close")
expect(app.captureCharFrame()).not.toContain("Keep open")
app.mockInput.pressKey("c", { ctrl: true })
await app.waitForFrame((frame) => !frame.includes("Rename"))
const rename = app
.captureCharFrame()
.split("\n")
.findIndex((line) => line.includes("Rename"))
await app.mockMouse.click(app.captureCharFrame().split("\n")[rename]!.indexOf("Rename"), rename)
await app.waitForFrame((frame) => frame.includes("Rename session"))
app.mockInput.pressKey("ESCAPE")
await app.waitForFrame((frame) => !frame.includes("Rename session"))
const second = app
.captureCharFrame()
@@ -0,0 +1,205 @@
import { expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client"
import { createData } from "@opencode-ai/client/solid"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { createRoot, createSignal } from "solid-js"
import { createSessionRetention } from "../../src/context/session-retention"
import { createApi, createFetch, directory, json } from "../fixture/tui-client"
function setup(options: { keep?: string[]; current?: string; limit?: number } = {}) {
return createRoot((dispose) => {
const events = createGlobalEmitter<{
[Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }>
}>()
const api = createApi(
createFetch((url) => {
if (url.pathname.endsWith("/message"))
return json({
data: [{ id: "msg_test", type: "user", text: "Transcript", time: { created: 1 } }],
cursor: {},
})
if (url.pathname.endsWith("/inbox")) return json({ data: [] })
return undefined
}).fetch,
)
const data = createData({ api: () => api, event: events, directory })
const [current, setCurrent] = createSignal(options.current)
const [keep, setKeep] = createSignal(options.keep ?? [])
const evictions: string[] = []
createSessionRetention({
session: {
...data.session,
evict(id) {
evictions.push(id)
data.session.evict(id)
},
},
current,
keep,
limit: options.limit ?? 3,
})
return {
dispose,
data,
events,
evictions,
setCurrent,
setKeep,
remember(id: string, parentID?: string, updated = 0) {
data.session.remember({
id,
parentID,
projectID: "project",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated },
})
},
async view(id: string) {
setCurrent(id)
await data.session.message.sync(id)
},
cached: (id: string) => data.session.message.list(id).length > 0,
}
})
}
test("retains three recently viewed families and child views touch their root", async () => {
const scope = setup()
try {
for (const id of ["a", "b", "c", "d", "e"]) scope.remember(id)
scope.remember("child", "a")
scope.remember("grandchild", "child")
await scope.view("a")
await scope.view("grandchild")
await scope.view("b")
await scope.view("c")
await scope.view("child")
await scope.view("d")
expect(["a", "child", "grandchild", "c", "d"].every(scope.cached)).toBe(true)
expect(scope.cached("b")).toBe(false)
scope.setCurrent(undefined)
expect(scope.cached("a")).toBe(true)
await scope.view("e")
expect(scope.cached("c")).toBe(false)
await scope.view("c")
expect(["a", "child", "grandchild"].some(scope.cached)).toBe(false)
expect(["c", "d", "e"].every(scope.cached)).toBe(true)
expect(scope.data.session.family("a").toSorted()).toEqual(["a", "child", "grandchild"])
} finally {
scope.dispose()
}
})
test("explicitly kept families are exempt from the recent-family budget", async () => {
const scope = setup({ keep: ["a", "b", "c", "d"] })
try {
for (const id of ["a", "b", "c", "d", "e", "f", "g", "h"]) {
scope.remember(id)
await scope.view(id)
}
expect(["a", "b", "c", "d", "f", "g", "h"].every(scope.cached)).toBe(true)
expect(scope.cached("e")).toBe(false)
scope.setKeep(["b", "c", "d"])
expect(scope.cached("a")).toBe(false)
await scope.view("b")
scope.setKeep(["c", "d"])
expect(scope.cached("b")).toBe(true)
expect(scope.cached("f")).toBe(false)
scope.setKeep([])
expect(["c", "d"].some(scope.cached)).toBe(false)
expect(["b", "g", "h"].every(scope.cached)).toBe(true)
} finally {
scope.dispose()
}
})
test("background metadata changes do not refresh view recency or repeat eviction", async () => {
const scope = setup()
try {
for (const id of ["a", "b", "c"]) {
scope.remember(id)
await scope.view(id)
}
scope.remember("a", undefined, 100)
scope.remember("d")
await scope.view("d")
expect(scope.cached("a")).toBe(false)
expect(["b", "c", "d"].every(scope.cached)).toBe(true)
const count = scope.evictions.length
scope.remember("a", undefined, 200)
scope.remember("d", undefined, 300)
expect(scope.evictions.length).toBe(count)
} finally {
scope.dispose()
}
})
test("unviewed families and newly discovered descendants are evicted", async () => {
const scope = setup({ current: "current" })
try {
await scope.data.session.message.sync("background")
expect(scope.cached("background")).toBe(true)
scope.remember("background")
expect(scope.cached("background")).toBe(false)
await scope.data.session.message.sync("child")
scope.remember("child", "background")
expect(scope.cached("child")).toBe(false)
expect(scope.evictions.filter((id) => id === "background")).toHaveLength(2)
scope.remember("current")
scope.remember("kept-child", "current")
await scope.data.session.message.sync("kept-child")
scope.remember("kept-child", "current", 10)
expect(scope.cached("kept-child")).toBe(true)
expect(
scope.data.session
.list()
.map((session) => session.id)
.toSorted(),
).toEqual(["background", "child", "current", "kept-child"])
} finally {
scope.dispose()
}
})
test("current child protects late-arriving ancestry and reopening restores an evicted transcript", async () => {
const scope = setup({ current: "child" })
try {
scope.remember("root")
await scope.data.session.message.sync("child")
scope.remember("child", "parent")
scope.remember("parent", "root")
scope.remember("root")
expect(scope.cached("child")).toBe(true)
expect(scope.evictions).toEqual(["root"])
for (const id of ["a", "b", "c"]) {
scope.remember(id)
await scope.view(id)
}
expect(scope.cached("child")).toBe(false)
await scope.view("child")
expect(scope.cached("child")).toBe(true)
expect(scope.cached("a")).toBe(false)
} finally {
scope.dispose()
}
})
test("uses the caller's recent-family limit", async () => {
const scope = setup({ keep: ["a"], limit: 1 })
try {
for (const id of ["a", "b", "c"]) {
scope.remember(id)
await scope.view(id)
}
expect(scope.cached("a")).toBe(true)
expect(scope.cached("b")).toBe(false)
expect(scope.cached("c")).toBe(true)
} finally {
scope.dispose()
}
})
+27 -23
View File
@@ -42,7 +42,7 @@ async function renderSessionTabs(
launchDirectory?: string
tabsEnabled?: boolean
viewFailures?: number
preview?: boolean
experimental?: Record<string, boolean>
},
) {
const temporary = options?.state ? undefined : await tmpdir()
@@ -136,7 +136,7 @@ async function renderSessionTabs(
let config!: ReturnType<typeof useConfig>
let configuration = {
tabs: { enabled: options?.tabsEnabled ?? true },
experimental: options?.preview ? { "session-preview-tabs": true } : undefined,
experimental: options?.experimental,
session: { new_location: options?.newLocation ?? "launch" },
}
@@ -202,10 +202,10 @@ async function renderSessionTabs(
focus: () => app.renderer.emit("focus"),
blur: () => app.renderer.emit("blur"),
flush: () => storage.flush(),
setPreviews: (enabled: boolean) =>
setTabsEnabled: (enabled: boolean) =>
config.update((draft) => {
draft.experimental ??= {}
draft.experimental["session-preview-tabs"] = enabled
draft.tabs ??= {}
draft.tabs.enabled = enabled
}),
async destroy() {
app.renderer.destroy()
@@ -291,8 +291,11 @@ test("loads location metadata when an open session moves", async () => {
}
})
test("replaces session previews without replacing permanent tabs or opening existing tabs again", async () => {
const setup = await renderSessionTabs("first", { persisted: ["first", "permanent"], preview: true })
test.each([undefined, true, false])("replaces previews with legacy experiment setting %s", async (preview) => {
const setup = await renderSessionTabs("first", {
persisted: ["first", "permanent"],
experimental: preview === undefined ? undefined : { "session-preview-tabs": preview },
})
try {
await wait(() => setup.tabs.tabs().length === 2)
@@ -330,7 +333,7 @@ test("replaces session previews without replacing permanent tabs or opening exis
})
test("server-wide prompt admissions do not promote a local session preview", async () => {
const setup = await renderSessionTabs("preview", { preview: true })
const setup = await renderSessionTabs("preview")
try {
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "preview") && setup.tabs.isPreview("preview"))
@@ -360,7 +363,7 @@ test("server-wide prompt admissions do not promote a local session preview", asy
})
test("promotes a local preview before its tab has finished persisting", async () => {
const setup = await renderSessionTabs("permanent", { persisted: ["permanent"], preview: true })
const setup = await renderSessionTabs("permanent", { persisted: ["permanent"] })
try {
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "permanent"))
@@ -382,7 +385,7 @@ test("promotes a local preview before its tab has finished persisting", async ()
})
test("reopens a previously permanent session as a preview after a user prompt", async () => {
const setup = await renderSessionTabs("permanent", { persisted: ["permanent"], preview: true })
const setup = await renderSessionTabs("permanent", { persisted: ["permanent"] })
try {
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "permanent"))
@@ -401,7 +404,7 @@ test("reopens a previously permanent session as a preview after a user prompt",
})
test("keeps an explicitly promoted home session permanent when admission arrives before navigation", async () => {
const setup = await renderSessionTabs("created", { home: true, preview: true })
const setup = await renderSessionTabs("created", { home: true })
try {
setup.tabs.promote("created")
@@ -416,7 +419,7 @@ test("keeps an explicitly promoted home session permanent when admission arrives
})
test("stores preview tab membership without persisting preview identity", async () => {
const setup = await renderSessionTabs("preview", { preview: true })
const setup = await renderSessionTabs("preview")
try {
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "preview") && setup.tabs.isPreview("preview"))
@@ -433,7 +436,7 @@ test("stores preview tab membership without persisting preview identity", async
})
test("unrelated user admissions do not pre-promote an unopened local session", async () => {
const setup = await renderSessionTabs("remote", { home: true, preview: true })
const setup = await renderSessionTabs("remote", { home: true })
try {
setup.emit(admitted("remote", "msg_1"))
@@ -453,11 +456,11 @@ test("each client replaces only its own preview in shared tab storage", async ()
const clients: Awaited<ReturnType<typeof renderSessionTabs>>[] = []
try {
const first = await renderSessionTabs("first", { state: temporary.path, preview: true })
const first = await renderSessionTabs("first", { state: temporary.path })
clients.push(first)
await wait(() => first.tabs.tabs().some((tab) => tab.sessionID === "first"))
const second = await renderSessionTabs("first", { state: temporary.path, preview: true })
const second = await renderSessionTabs("first", { state: temporary.path })
clients.push(second)
expect(second.tabs.isPreview("first")).toBe(false)
@@ -478,7 +481,7 @@ test("each client replaces only its own preview in shared tab storage", async ()
})
test("reopening a closed preview makes it permanent without replacing the current preview", async () => {
const setup = await renderSessionTabs("permanent", { persisted: ["permanent"], preview: true })
const setup = await renderSessionTabs("permanent", { persisted: ["permanent"] })
try {
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "permanent"))
@@ -504,7 +507,7 @@ test("reopening a closed preview makes it permanent without replacing the curren
})
test("moving a preview promotes it before opening another preview", async () => {
const setup = await renderSessionTabs("first", { persisted: ["first", "last"], preview: true })
const setup = await renderSessionTabs("first", { persisted: ["first", "last"] })
try {
await wait(() => setup.tabs.tabs().length === 2)
@@ -525,16 +528,16 @@ test("moving a preview promotes it before opening another preview", async () =>
}
})
test("disabling previews clears local identity and prevents stale replacement after re-enabling", async () => {
const setup = await renderSessionTabs("first", { preview: true })
test("disabling tabs clears local preview identity and prevents stale replacement after re-enabling", async () => {
const setup = await renderSessionTabs("first")
try {
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "first") && setup.tabs.isPreview("first"))
await setup.setPreviews(false)
await setup.setTabsEnabled(false)
expect(setup.tabs.isPreview("first")).toBe(false)
await setup.setPreviews(true)
await setup.setTabsEnabled(true)
expect(setup.tabs.isPreview("first")).toBe(false)
setup.route.navigate({ type: "session", sessionID: "next" })
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "next"))
@@ -896,7 +899,7 @@ test("closing a tab is not undone by another TUI viewing the same session", asyn
})
test("user prompt admissions pulse an already-busy background tab", async () => {
const setup = await renderSessionTabs("background")
const setup = await renderSessionTabs("background", { persisted: ["background"] })
try {
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "background"))
@@ -933,7 +936,7 @@ test("user prompt admissions pulse an already-busy background tab", async () =>
})
test("tracks a temporary new session tab across close and creation", async () => {
const setup = await renderSessionTabs("first")
const setup = await renderSessionTabs("first", { persisted: ["first"] })
try {
await wait(() => setup.tabs.current() === "first")
@@ -952,6 +955,7 @@ test("tracks a temporary new session tab across close and creation", async () =>
setup.route.navigate({ type: "home" })
await wait(() => setup.tabs.newTab())
setup.tabs.promote("third")
setup.route.navigate({ type: "session", sessionID: "third" })
expect(setup.tabs.newTab()).toBe(true)
await wait(() => setup.tabs.current() === "third" && setup.tabs.tabs().some((tab) => tab.sessionID === "third"))
@@ -3,6 +3,7 @@ import { expect, test } from "bun:test"
import { RGBA, TextRenderable } from "@opentui/core"
import { testRender } from "@opentui/solid"
import type { Context } from "@opencode-ai/plugin/tui/context"
import { createSignal } from "solid-js"
import { PromptFooter } from "../../src/feature-plugins/prompt/footer"
test("prompt footer separates simultaneous subagent, shell, and usage status", async () => {
@@ -38,10 +39,13 @@ test("prompt footer separates simultaneous subagent, shell, and usage status", a
},
},
} as unknown as Context
const app = await testRender(() => <PromptFooter context={context} sessionID="session" mode="normal" />, {
width: 80,
height: 2,
})
const app = await testRender(
() => <PromptFooter context={context} sessionID="session" mode="normal" showDetails={true} />,
{
width: 80,
height: 2,
},
)
try {
await app.renderOnce()
@@ -59,3 +63,82 @@ test("prompt footer separates simultaneous subagent, shell, and usage status", a
app.renderer.destroy()
}
})
test("prompt footer can hide details", async () => {
const color = RGBA.fromInts(200, 200, 200)
const context = {
location: { directory: "/workspace" },
theme: {
text: {
default: color,
subdued: color,
},
},
keymap: {
shortcuts: (id: string) => {
if (id === "command.palette.show") return ["ctrl+p"]
if (id === "agent.cycle") return ["shift+tab"]
return []
},
},
data: {
session: {
family: () => ["session"],
status: () => "running",
get: () => ({ id: "session", location: { directory: "/workspace" } }),
cost: () => 1,
message: {
list: () => [
{
id: "message",
type: "assistant",
model: { providerID: "provider", id: "model" },
tokens: { input: 1_000, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
],
},
},
shell: { list: () => [] },
location: {
model: { list: () => [{ providerID: "provider", id: "model", limit: { context: 10_000 } }] },
},
},
} as unknown as Context
const [showDetails, setShowDetails] = createSignal(true)
const [sessionID, setSessionID] = createSignal<string | undefined>("session")
const app = await testRender(
() => (
<box width="100%" flexDirection="row" justifyContent="space-between" gap={2}>
<PromptFooter
context={context}
sessionID={sessionID()}
mode="normal"
showDetails={showDetails()}
/>
</box>
),
{
width: 80,
height: 1,
},
)
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain("1.0K (10%) · $1.00")
expect(app.captureCharFrame()).toContain("ctrl+p commands")
setShowDetails(false)
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).not.toContain("1.0K (10%)")
expect(frame).not.toContain("$1.00")
expect(frame).not.toContain("ctrl+p commands")
setSessionID(undefined)
await app.renderOnce()
expect(app.captureCharFrame()).not.toContain("shift+tab agents")
} finally {
app.renderer.destroy()
}
})
+5 -3
View File
@@ -2,7 +2,7 @@ import { expect, mock, test } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import { Global } from "@opencode-ai/util/global"
import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"
import { mkdir, readFile, symlink, utimes, writeFile } from "node:fs/promises"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { createEventStream, createFetch, json } from "./fixture/tui-client"
@@ -367,8 +367,10 @@ export default {
)
// Duplicate notifications for unchanged contents must not retry the broken
// generation and cycle the restored plugin again.
await writeFile(source, broken)
// generation and cycle the restored plugin again. Rewriting the file can
// expose truncated contents to the watcher, so change only its metadata.
const touched = new Date(Date.now() + 1000)
await utimes(source, touched, touched)
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")
+8 -8
View File
@@ -29,7 +29,7 @@ test.each([
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const session = {
id: "dummy",
id: "ses_test",
title: "Long history",
projectID: "project",
location: { directory },
@@ -63,8 +63,8 @@ test.each([
const events = createEventStream()
const calls = createFetch(async (url, request) => {
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === "/api/session/dummy") return json({ data: session })
if (url.pathname === "/api/session/dummy/message") {
if (url.pathname === "/api/session/ses_test") return json({ data: session })
if (url.pathname === "/api/session/ses_test/message") {
const end = Number(url.searchParams.get("cursor") ?? messages.length)
const limit = Number(url.searchParams.get("limit"))
const start = Math.max(0, end - limit)
@@ -79,8 +79,8 @@ test.each([
if (mode === "prepend-failure" && limit === 200) return json({ message: "offline" }, { status: 503 })
return json({ data: messages.slice(start, end).toReversed(), cursor: end ? { next: String(start) } : {} })
}
if (url.pathname === "/api/session/dummy/inbox") return json({ data: [] })
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
if (url.pathname === "/api/session/ses_test/inbox") return json({ data: [] })
if (url.pathname === "/api/session/ses_test/permission") return json({ data: [] })
return undefined
}, events)
const server = Bun.serve({ port: 0, idleTimeout: 0, fetch: (request) => calls.fetch(request) })
@@ -105,7 +105,7 @@ test.each([
},
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
args: { sessionID: "ses_test" },
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
@@ -279,9 +279,9 @@ test.each([
id: "evt_live",
created: 400,
type: "session.inbox.enqueued",
durable: { aggregateID: "dummy", seq: 1, version: 1 },
durable: { aggregateID: "ses_test", seq: 1, version: 1 },
data: {
sessionID: "dummy",
sessionID: "ses_test",
inboxID: "message-live",
item: { type: "user", payload: { text: "Live message after failure" }, delivery: "steer" },
},
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, test } from "bun:test"
import type { SessionStatsInfo } from "@opencode-ai/client"
import { statsMetrics, statsNumber } from "../src/feature-plugins/system/stats-data"
const stats: SessionStatsInfo = {
range: { from: new Date(2026, 0, 1).getTime(), to: new Date(2026, 0, 8).getTime() },
sessions: 12,
subagents: 99,
prompts: 24,
steps: 50,
activeDays: 2,
streak: 2,
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
cost: 0,
tools: { mode: "none" },
models: [],
activity: [
{ date: "2026-01-01", steps: 1 },
{ date: "2026-01-02", steps: 50 },
],
}
describe("stats poster", () => {
test("includes all token categories and replaces subagents with tokens", () => {
expect(statsMetrics(stats)).toEqual([
{ label: "tokens", value: 15 },
{ label: "best streak", value: 2 },
{ label: "active days", value: 2 },
{ label: "sessions", value: 12 },
])
expect([0, 685, 5284, 9_200_000_000].map(statsNumber)).toEqual(["0", "685", "5.3K", "9.2B"])
})
})
+90
View File
@@ -0,0 +1,90 @@
import { expect, test } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { Global } from "@opencode-ai/util/global"
import { Effect, FileSystem } from "effect"
import { createEventStream, createFetch, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
test("stats shows only this year and returns after errors or success", async () => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width: 100, height: 34, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const requests: URL[] = []
const calls = createFetch((url) => {
if (url.pathname !== "/api/session/stats") return undefined
requests.push(url)
if (requests.length === 1) return json({ message: "offline" }, { status: 503 })
return json({
data: {
range: { from: new Date(new Date().getFullYear(), 0, 1).getTime(), to: Date.now() },
sessions: 123,
subagents: 0,
prompts: 1,
steps: 1,
tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 20, write: 0 } },
cost: 0,
tools: { mode: "none" },
activeDays: 1,
streak: 1,
activity: [{ date: `${new Date().getFullYear()}-01-01`, steps: 1 }],
models: [],
},
})
}, createEventStream())
const server = Bun.serve({ port: 0, idleTimeout: 0, fetch: (request) => calls.fetch(request) })
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: {
get: async () => ({ animations: false, tabs: { enabled: false } }),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: {},
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
try {
await setup.waitForFrame((frame) => frame.includes("commands"))
await setup.mockInput.typeText("/stats")
await setup.waitForFrame((frame) => frame.includes("Usage statistics"))
setup.mockInput.pressKey("RETURN")
await setup.waitForFrame((frame) => frame.includes("Could not load stats"))
setup.mockInput.pressKey("ESCAPE")
await setup.waitForFrame((frame) => frame.includes("commands") && !frame.includes("Could not load stats"))
await setup.mockInput.typeText("/stats")
await setup.waitForFrame((frame) => frame.includes("Usage statistics"))
setup.mockInput.pressKey("RETURN")
await setup.waitForFrame((frame) => frame.includes("TOKENS") && frame.includes("123"))
expect(requests[1].searchParams.get("tools")).toBe("none")
expect(Number(requests[1].searchParams.get("from"))).toBe(new Date(new Date().getFullYear(), 0, 1).getTime())
expect(requests[1].searchParams.has("to")).toBe(false)
expect(setup.captureCharFrame()).not.toContain("all time")
expect(setup.captureCharFrame()).not.toContain("All projects")
expect(setup.captureCharFrame()).not.toContain("show this year")
expect(setup.captureCharFrame()).not.toContain("esc")
expect(
setup
.captureCharFrame()
.split("\n")
.find((line) => line.includes("opencode / stats")),
).not.toContain("tab")
setup.mockInput.pressKey("RETURN")
setup.mockInput.pressKey("SPACE")
await setup.mockInput.typeText("rhp")
setup.mockInput.pressKey("TAB")
setup.mockInput.pressKey("RIGHT")
await setup.waitForVisualIdle()
expect(requests).toHaveLength(2)
expect(setup.captureCharFrame()).toContain("TOKENS")
expect(setup.captureCharFrame()).not.toContain("headline")
setup.mockInput.pressKey("ESCAPE")
await setup.waitForFrame((frame) => frame.includes("commands") && !frame.includes("opencode / stats"))
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await task.finally(() => server.stop(true))
}
})
@@ -0,0 +1,26 @@
import { expect, test } from "bun:test"
import { errorDetails } from "../../src/util/error-details"
test("copy and investigation include the plugin context and matching log reference", () => {
const result = errorDetails({
title: "Server plugin: example",
error: "Plugin failed to load",
context: "Status: failed\nRuntime: server\nSource: /project/plugin.ts",
diagnosticRef: "err_a1b2c3d4",
})
expect(result.text).toBe(
"Server plugin: example\nStatus: failed\nRuntime: server\nSource: /project/plugin.ts\nError: Plugin failed to load\nReference: err_a1b2c3d4",
)
expect(result.prompt).toContain(result.text)
expect(result.prompt).toContain("Find the server log entry matching reference err_a1b2c3d4")
expect(result.prompt).toContain("If the server logs are not accessible")
expect(result.prompt).toContain("Do not expose credentials.")
})
test("errors without a reference still have useful copy and investigation text", () => {
const result = errorDetails({ title: "MCP server: example", error: "Connection refused" })
expect(result.text).toBe("MCP server: example\nError: Connection refused")
expect(result.prompt).toContain(result.text)
expect(result.prompt).not.toContain("matching reference")
expect(result.text).not.toContain("undefined")
})
+136
View File
@@ -0,0 +1,136 @@
import { describe, expect, test } from "bun:test"
import { activityCalendar } from "./activity-calendar.js"
const input = {
from: new Date(2026, 0, 1).getTime(),
to: new Date(2026, 0, 8).getTime(),
maxWeeks: 53,
activity: [
{ date: "2026-01-01", steps: 1 },
{ date: "2026-01-02", steps: 50 },
],
}
describe("activity calendar", () => {
test("aligns Monday-first weeks and excludes cells outside the end-exclusive range", () => {
const calendar = activityCalendar(input)
expect(calendar.clipped).toBe(false)
expect(calendar.weeks).toHaveLength(2)
expect(calendar.weeks[0][0]).toEqual({ date: "2025-12-29", steps: 0, level: -1 })
expect(calendar.weeks[0][3]).toEqual({ date: "2026-01-01", steps: 1, level: 2 })
expect(calendar.weeks[0][4]).toEqual({ date: "2026-01-02", steps: 50, level: 4 })
expect(calendar.weeks[1][2]).toEqual({ date: "2026-01-07", steps: 0, level: 0 })
expect(calendar.weeks[1][3].level).toBe(-1)
expect(calendar.months).toEqual([{ week: 0, label: "Jan", span: 2 }])
})
test("clips to the latest weeks and keeps empty days distinct from out-of-range cells", () => {
const calendar = activityCalendar({ ...input, activity: [], maxWeeks: 1 })
expect(calendar.clipped).toBe(true)
expect(calendar.weeks).toHaveLength(1)
expect(calendar.weeks[0][0]).toEqual({ date: "2026-01-05", steps: 0, level: 0 })
expect(calendar.weeks[0][3].level).toBe(-1)
})
test("caps long histories at 53 weeks", () => {
const calendar = activityCalendar({ ...input, from: new Date(2020, 0, 1).getTime(), maxWeeks: 200 })
expect(calendar.clipped).toBe(true)
expect(calendar.weeks).toHaveLength(53)
expect(calendar.weeks.at(-1)?.[2].date).toBe("2026-01-07")
expect(calendar.months.reduce((sum, month) => sum + month.span, 0)).toBe(53)
})
test("returns month positions and spans for partial first and last months", () => {
const calendar = activityCalendar({
...input,
from: new Date(2026, 3, 29).getTime(),
to: new Date(2026, 4, 20).getTime(),
})
expect(calendar.months).toEqual([
{ week: 0, label: "Apr", span: 1 },
{ week: 1, label: "May", span: 3 },
])
const partial = activityCalendar({
...input,
from: new Date(2026, 3, 1).getTime(),
to: new Date(2026, 3, 2).getTime(),
})
expect(partial.months).toEqual([{ week: 0, label: "Apr", span: 1 }])
})
test("places month labels across a year boundary", () => {
const calendar = activityCalendar({
...input,
from: new Date(2025, 11, 22).getTime(),
to: new Date(2026, 0, 12).getTime(),
})
expect(calendar.months).toEqual([
{ week: 0, label: "Dec", span: 1 },
{ week: 1, label: "Jan", span: 2 },
])
})
test("keeps exactly seven distinct cells per week across DST and leap days", () => {
const calendar = activityCalendar({
from: new Date(2024, 1, 26).getTime(),
to: new Date(2024, 2, 12).getTime(),
maxWeeks: 53,
activity: [
{ date: "2024-02-29", steps: 1 },
{ date: "2024-03-10", steps: 1 },
],
})
expect(calendar.weeks).toHaveLength(3)
expect(
calendar.weeks
.flat()
.filter((day) => day.steps > 0)
.map((day) => day.date),
).toEqual(["2024-02-29", "2024-03-10"])
expect(new Set(calendar.weeks.flat().map((day) => day.date)).size).toBe(21)
})
test("uses the highest intensity for a single distinct positive count", () => {
const calendar = activityCalendar({ ...input, activity: input.activity.map((day) => ({ ...day, steps: 10 })) })
expect(
calendar.weeks
.flat()
.filter((day) => day.steps > 0)
.map((day) => day.level),
).toEqual([4, 4])
})
test("ranks distinct activity counts in four levels", () => {
const calendar = activityCalendar({
...input,
to: new Date(2026, 0, 10).getTime(),
activity: Array.from({ length: 8 }, (_, index) => ({ date: `2026-01-0${index + 1}`, steps: index + 1 })),
})
expect(
calendar.weeks
.flat()
.filter((day) => day.steps > 0)
.map((day) => day.level),
).toEqual([1, 1, 2, 2, 3, 3, 4, 4])
})
test("keeps intensity relative to the requested range when older weeks are clipped", () => {
const calendar = activityCalendar({
...input,
maxWeeks: 1,
activity: [
{ date: "2026-01-01", steps: 50 },
{ date: "2026-01-05", steps: 1 },
],
})
expect(calendar.weeks[0][0].level).toBe(2)
})
test("returns an empty calendar for an empty range", () => {
expect(activityCalendar({ ...input, to: input.from, activity: [] })).toEqual({
weeks: [],
months: [],
clipped: false,
})
})
})
+50
View File
@@ -0,0 +1,50 @@
export function activityCalendar(input: {
activity: readonly { date: string; steps: number }[]
from: number
to: number
maxWeeks: number
}) {
// Extract local dates first, then use UTC ordinals so DST cannot add or drop cells.
const ordinal = (time: number) => {
const date = new Date(time)
return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000
}
const from = ordinal(input.from)
const to = ordinal(input.to - 1)
const monday = from - ((new Date(from * 86_400_000).getUTCDay() + 6) % 7)
const total = input.to <= input.from ? 0 : Math.floor((to - monday) / 7) + 1
const count = Math.min(total, 53, Math.max(1, Math.floor(input.maxWeeks)))
const start = monday + Math.max(0, total - count) * 7
const values = new Map(input.activity.map((day) => [day.date, day.steps]))
const levels = [...new Set([...values.values()].filter((steps) => steps > 0))].sort((a, b) => a - b)
const weeks = Array.from({ length: count }, (_, week) =>
Array.from({ length: 7 }, (_, day) => {
const date = start + week * 7 + day
const key = new Date(date * 86_400_000).toISOString().slice(0, 10)
const steps = values.get(key) ?? 0
return {
date: key,
steps,
level:
date < from || date > to
? -1
: steps === 0
? 0
: Math.max(1, Math.ceil(((levels.indexOf(steps) + 1) / levels.length) * 4)),
}
}),
)
const formatter = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" })
// Thursday identifies the month owning most of a week; partial edge weeks stay inside the range.
const months = weeks
.map((_, week) => ({
week,
label: formatter.format(Math.min(to, Math.max(from, start + week * 7 + 3)) * 86_400_000),
}))
.filter((month, index, months) => index === 0 || month.label !== months[index - 1].label)
return {
weeks,
months: months.map((month, index) => ({ ...month, span: (months[index + 1]?.week ?? count) - month.week })),
clipped: count < total,
}
}
+126
View File
@@ -486,6 +486,132 @@
"summary": "List plugins"
}
},
"/api/plugin/check": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.check",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.InfoEncoded"
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Plugin.Info"
}
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Check one or all package plugins for available updates.",
"summary": "Check plugin updates",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/plugin/update": {
"post": {
"tags": ["plugin"],
+126
View File
@@ -486,6 +486,132 @@
"summary": "List plugins"
}
},
"/api/plugin/check": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.check",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.InfoEncoded"
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Plugin.Info"
}
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Check one or all package plugins for available updates.",
"summary": "Check plugin updates",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/plugin/update": {
"post": {
"tags": ["plugin"],
@@ -77,9 +77,15 @@ Install, inspect, list, or remove global package plugins with the CLI.
opencode2 plugin add opencode-acme-plugin@1.2.0
opencode2 plugin list
opencode2 plugin list --builtin
opencode2 plugin check
opencode2 plugin update
opencode2 plugin update opencode-acme-plugin
opencode2 plugin remove opencode-acme-plugin@1.2.0
```
`plugin check` checks server and TUI-only package plugins for updates. `plugin update` updates every outdated package;
pass a configured package target to check or update only that package. Local plugins and exact package revisions are skipped.
Package installation accepts npm names with versions, tags, or ranges, plus npm-compatible Git package specifications.
Git repositories can use hosted shortcuts, HTTPS, or SSH, including private repositories available through your existing
Git credentials.