Compare commits

..

9 Commits

Author SHA1 Message Date
Kit Langton 32e2e3f2f7 feat(tui): surface plugin failures 2026-08-12 02:43:52 +00:00
Kit Langton caae28e0d4 fix(tui): render instruction updates as compact notices (#41900) 2026-08-11 22:21:24 -04:00
Kit Langton c83933d1d4 fix(core): gate tool snapshot on initial MCP registration (#41884) 2026-08-11 22:21:21 -04:00
Kit Langton c86f1c41ff fix(tui): show completed write output (#41883) 2026-08-11 22:20:55 -04:00
Kit Langton 5c0cc8e617 fix(tui): align running shell output (#41880) 2026-08-11 22:20:52 -04:00
Kit Langton 1b45061afb feat(tui): experiments via devtools bar, drafts stay put (#41917) 2026-08-12 02:08:06 +00:00
opencode-agent[bot] 07bcd290c2 chore: generate 2026-08-12 02:06:08 +00:00
Luke Parker 04ad06e2e3 fix(desktop): align local development identity (#41889) 2026-08-12 12:04:57 +10:00
Dax 93965df860 feat(session): record location switches (#41899) 2026-08-11 19:03:57 -07:00
51 changed files with 882 additions and 600 deletions
+1 -1
View File
@@ -658,7 +658,7 @@ function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () =
return (
<>
{["beta", "dev"].includes(channel) && (
{["local", "beta", "dev"].includes(channel) && (
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
{channel.toUpperCase()}
</div>
+1 -1
View File
@@ -1,7 +1,7 @@
interface ImportMetaEnv {
readonly VITE_OPENCODE_SERVER_HOST: string
readonly VITE_OPENCODE_SERVER_PORT: string
readonly VITE_OPENCODE_CHANNEL?: "dev" | "beta" | "prod"
readonly VITE_OPENCODE_CHANNEL?: "local" | "dev" | "beta" | "prod"
readonly VITE_SENTRY_DSN?: string
readonly VITE_SENTRY_ENVIRONMENT?: string
@@ -8,7 +8,7 @@ import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.service.commands.restart,
Effect.fn("cli.service.restart")(function* () {
const options = yield* ServiceConfig.options({ checkVersion: true })
const options = yield* ServiceConfig.options()
yield* Service.stop(options)
const transport = yield* Service.ensure(options)
process.stdout.write(transport.url + EOL)
@@ -57,7 +57,7 @@ function managedService(options: EnsureOptions) {
restart: () =>
Effect.gen(function* () {
yield* Service.stop(options)
yield* Service.ensure(options)
yield* Service.ensure(reconnectOptions)
}),
}
}
@@ -5,7 +5,6 @@ import { Service } from "@opencode-ai/client/effect/service"
import { Effect, FileSystem, Option, Schema } from "effect"
import { randomBytes } from "crypto"
import path from "path"
import semver from "semver"
import { selfCommand } from "../util/process"
// The CLI's service configuration file, plus the Service.EnsureOptions binding that
@@ -105,20 +104,10 @@ export const options = Effect.fnUntraced(function* (input: { readonly checkVersi
return {
file,
version: input.checkVersion ? OPENCODE_VERSION : undefined,
canReplace: (version: string | undefined) => canReplaceVersion(version),
command: [...selfCommand(), "serve", "--service"],
}
})
export function canReplaceVersion(serverVersion: string | undefined, clientVersion = OPENCODE_VERSION) {
if (serverVersion === undefined) return false
// Compare preview build numbers numerically rather than as semver prerelease strings.
const server = serverVersion.replace(/-(\d+)(?=(?:\.\d+)?$)/, ".$1")
const client = clientVersion.replace(/-(\d+)(?=(?:\.\d+)?$)/, ".$1")
if (!semver.valid(server) || !semver.valid(client)) return false
return semver.lt(server, client)
}
export const read = Effect.fn("cli.service-config.read")(function* () {
const { fs, configFile, legacyConfigFile } = yield* paths
if (legacyConfigFile) yield* migrateConfig(legacyConfigFile, configFile)
-29
View File
@@ -47,35 +47,6 @@ test("service filenames share release channels and identify preview channels", (
expect(ServiceConfig.versionBelongsToChannel("1.2.3", "preview-a")).toBe(false)
})
test("only newer clients replace managed service versions", () => {
expect(ServiceConfig.canReplaceVersion("0.0.0-next-17271", "0.0.0-next-17272")).toBe(true)
expect(ServiceConfig.canReplaceVersion("0.0.0-next-17272", "0.0.0-next-17271")).toBe(false)
expect(ServiceConfig.canReplaceVersion("0.0.0-next-17272", "0.0.0-next-17272")).toBe(false)
expect(ServiceConfig.canReplaceVersion(undefined, "0.0.0-next-17272")).toBe(false)
expect(ServiceConfig.canReplaceVersion("development-a", "development-b")).toBe(false)
expect(ServiceConfig.canReplaceVersion("development-b", "development-a")).toBe(false)
})
test("managed version replacement can never be mutual", () => {
const versions = [
undefined,
"1.0.0",
"1.0.1",
"0.0.0-next-9999",
"0.0.0-next-15000",
"0.0.0-next-15000.1",
"0.0.0-next-15000.2",
"development-a",
"development-b",
]
for (const left of versions) {
for (const right of versions) {
if (left === undefined || right === undefined) continue
expect(ServiceConfig.canReplaceVersion(left, right) && ServiceConfig.canReplaceVersion(right, left)).toBe(false)
}
}
})
test("service config migrates from the hashed channel filename", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-config-migration-"))
const legacy = path.join(root, ServiceConfig.legacyFilename("preview-a")!)
+48 -19
View File
@@ -2,7 +2,7 @@ import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
import { homedir } from "node:os"
import { join } from "node:path"
import { VersionMismatchError, type DiscoverOptions, type Endpoint, type EnsureOptions, type StopOptions } from "../service.js"
import type { DiscoverOptions, Endpoint, EnsureOptions, StopOptions } from "../service.js"
import {
contenderFailure,
contenderFinished,
@@ -55,6 +55,7 @@ const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOptions = {}) {
const timing = ensureTiming(options)
const contenders = new Set<ServiceContender>()
let timeouts: { readonly info: Info; readonly count: number } | undefined
let announced = false
let lastSpawn = 0
let spawnDelay = timing.spawnDelay
@@ -78,6 +79,18 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
const registration = yield* registered(options.file, true, timing.requestTimeout)
const info = registration.info
const service = registration.service
if (registration.timedOut && info !== undefined) {
timeouts = {
info,
count: timeouts !== undefined && same(timeouts.info, info) ? timeouts.count + 1 : 1,
}
if (timeouts.count >= 3) {
yield* announce("missing")
yield* evict(info, options, timing)
timeouts = undefined
lastSpawn = Date.now() - spawnDelay
}
} else timeouts = undefined
if (service !== undefined) {
spawnDelay = timing.spawnDelay
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
@@ -85,10 +98,8 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
if (compatible && service.state === "failed")
return yield* Effect.fail(new Error("Background service failed to start"))
if (compatible) return Option.none<LocalService>()
if (!service.legacy && options.canReplace?.(service.version) === false)
return yield* Effect.fail(new VersionMismatchError(options.version, service.version))
yield* announce("version-mismatch", service.version)
yield* kill(service, options, timing)
yield* kill(service, options, timing).pipe(Effect.ignore)
lastSpawn = 0
return Option.none<LocalService>()
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
@@ -123,8 +134,6 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
const existing = yield* find(options)
if (existing !== undefined) yield* kill(existing, options, defaultEnsureTiming)
if (existing === undefined && (yield* read(options.file)) !== undefined)
return yield* Effect.fail(new Error("Background service is not responding; stop its process manually and try again"))
})
function fallback() {
@@ -244,6 +253,9 @@ const find = Effect.fnUntraced(function* (options: { readonly file?: string }) {
const poll = (timing: EnsureTiming) =>
Schedule.max([Schedule.spaced(timing.stopPollInterval), Schedule.recurs(timing.stopPollAttempts)])
const signal = (pid: number, name: NodeJS.Signals) =>
Effect.try({ try: () => process.kill(pid, name), catch: (cause) => cause }).pipe(Effect.ignore)
const stopped = Effect.fnUntraced(function* (pid: number) {
const running = yield* Effect.try({ try: () => process.kill(pid, 0), catch: () => false }).pipe(
Effect.orElseSucceed(() => false),
@@ -253,25 +265,43 @@ const stopped = Effect.fnUntraced(function* (pid: number) {
})
function same(left: Info, right: Info) {
return (
left.id === right.id &&
left.version === right.version &&
left.url === right.url &&
left.pid === right.pid &&
left.password === right.password
)
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
}
const kill = Effect.fnUntraced(function* (service: LocalService, _options: { readonly file?: string }, timing: EnsureTiming) {
const evict = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
const current = yield* read(options.file)
if (current === undefined || !same(current, info)) return
yield* signal(info.pid, "SIGTERM")
const done = yield* stopped(info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
if (Option.isSome(done)) return
const latest = yield* read(options.file)
if (latest === undefined || !same(latest, info)) return
yield* signal(info.pid, "SIGKILL")
yield* stopped(info.pid).pipe(Effect.retry(poll(timing)))
})
const kill = Effect.fnUntraced(function* (
service: LocalService,
options: { readonly file?: string },
timing: EnsureTiming,
) {
const requested = yield* requestStop(service, timing.requestTimeout)
if (requested === "rejected") return yield* Effect.fail(new Error("Background service rejected the stop request"))
if (requested === "rejected") return
if (requested === "unsupported") {
return yield* Effect.fail(new Error("Background service does not support authenticated stop requests"))
// A stale registration may point at a reused PID. Authenticate again
// immediately before the legacy signal fallback.
const current = yield* find(options)
if (current === undefined || !same(current.info, service.info)) return
yield* signal(service.info.pid, "SIGTERM")
}
const done = yield* stopped(service.info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
if (Option.isSome(done)) return
return yield* Effect.fail(new Error("Background service accepted the stop request but did not exit"))
const latest = yield* find(options)
if (latest === undefined || !same(latest.info, service.info)) return
yield* signal(service.info.pid, "SIGKILL")
yield* stopped(service.info.pid).pipe(Effect.retry(poll(timing)))
})
const decodeStopResponse = Schema.decodeUnknownOption(ServiceStatus.StopResponse)
@@ -286,8 +316,7 @@ const requestStop = Effect.fnUntraced(function* (service: LocalService, timeout
signal: AbortSignal.timeout(timeout),
}),
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
if (response === undefined) return "rejected" as const
if (response.status === 404 || response.status === 405) return "unsupported" as const
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const
const body = yield* Effect.tryPromise(() => response.json()).pipe(Effect.option, Effect.map(Option.getOrUndefined))
const decoded = decodeStopResponse(body)
if (!response.ok || Option.isNone(decoded) || !decoded.value.accepted) return "rejected" as const
@@ -65,6 +65,7 @@ export type SessionMessageSystem = {
time: { created: number }
type: "system"
text: string
description?: string
}
export type SessionMessageSkill = {
@@ -408,6 +409,17 @@ export type ProviderRequest = {
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
export type SessionMessageLocationSwitched = {
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
type: "location-switched"
location: LocationRef
projectID?: string
subpath?: string
previous?: { location: LocationRef; projectID?: string; subpath?: string }
}
export type SessionCreated = {
id: string
created: number
@@ -1943,6 +1955,7 @@ export type SessionInputAdmitted = {
export type SessionMessageInfo =
| SessionMessageAgentSelected
| SessionMessageModelSelected
| SessionMessageLocationSwitched
| SessionMessageUser
| SessionMessageSynthetic
| SessionMessageSystem
@@ -2546,6 +2559,20 @@ export type SessionImportInput = {
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "location-switched"
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly projectID?: string
readonly subpath?: string
readonly previous?: {
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly projectID?: string
readonly subpath?: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
@@ -2585,6 +2612,7 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly type: "system"
readonly text: string
readonly description?: string
}
| {
readonly id: string
@@ -2798,6 +2826,20 @@ export type SessionImportInput = {
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "location-switched"
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly projectID?: string
readonly subpath?: string
readonly previous?: {
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly projectID?: string
readonly subpath?: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
@@ -2837,6 +2879,7 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly type: "system"
readonly text: string
readonly description?: string
}
| {
readonly id: string
@@ -3050,6 +3093,20 @@ export type SessionImportInput = {
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "location-switched"
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly projectID?: string
readonly subpath?: string
readonly previous?: {
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly projectID?: string
readonly subpath?: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
@@ -3089,6 +3146,7 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly type: "system"
readonly text: string
readonly description?: string
}
| {
readonly id: string
+52 -61
View File
@@ -1,14 +1,7 @@
import { readFile } from "node:fs/promises"
import { homedir } from "node:os"
import { join } from "node:path"
import {
VersionMismatchError,
type DiscoverOptions,
type Endpoint,
type Info,
type EnsureOptions,
type StopOptions,
} from "../service.js"
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
import {
contenderFailure,
contenderFinished,
@@ -43,6 +36,7 @@ export async function ensure(options: EnsureOptions = {}): 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 lastSpawn = 0
let spawnDelay = timing.spawnDelay
@@ -66,6 +60,19 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
while (true) {
if (Date.now() >= deadline) throw new Error("Timed out waiting for the background service to start")
const registration = await registered(options.file, true, timing.requestTimeout)
if (registration.timedOut && registration.info !== undefined) {
timeouts = {
info: registration.info,
count: timeouts !== undefined && same(timeouts.info, registration.info) ? timeouts.count + 1 : 1,
}
if (timeouts.count >= 3) {
announce("missing")
await evict(registration.info, options, timing)
timeouts = undefined
lastSpawn = Date.now() - spawnDelay
}
} else timeouts = undefined
if (registration.service !== undefined) {
spawnDelay = timing.spawnDelay
const service = registration.service
@@ -73,10 +80,8 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
if (compatible && service.state === "ready") return service.endpoint
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
if (!compatible) {
if (!service.legacy && options.canReplace?.(service.version) === false)
throw new VersionMismatchError(options.version, service.version)
announce("version-mismatch", service.version)
await kill(service, options, timing)
await kill(service, options, timing).catch(() => undefined)
lastSpawn = 0
}
} else {
@@ -106,8 +111,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
export async function stop(options: StopOptions = {}) {
const existing = await find(options)
if (existing !== undefined) await kill(existing, options, defaultEnsureTiming)
if (existing === undefined && (await read(options.file)) !== undefined)
throw new Error("Background service is not responding; stop its process manually and try again")
}
function fallback() {
@@ -126,15 +129,7 @@ async function read(file?: string) {
const text = await readFile(file ?? fallback(), "utf8").catch(() => undefined)
if (text === undefined) return undefined
try {
const value: unknown = JSON.parse(text)
if (typeof value !== "object" || value === null) return undefined
if (!("url" in value) || typeof value.url !== "string") return undefined
if (!("pid" in value) || !Number.isInteger(value.pid) || typeof value.pid !== "number" || value.pid <= 0)
return undefined
if ("id" in value && value.id !== undefined && typeof value.id !== "string") return undefined
if ("version" in value && value.version !== undefined && typeof value.version !== "string") return undefined
if ("password" in value && value.password !== undefined && typeof value.password !== "string") return undefined
return value as Info
return JSON.parse(text) as Info
} catch {
return undefined
}
@@ -176,18 +171,7 @@ async function probeResult(info: Info, allowLegacy = false, timeout = defaultEns
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
const response = result.value.response
const body = result.value.body
if (
typeof body === "object" &&
body !== null &&
"healthy" in body &&
body.healthy === true &&
"version" in body &&
typeof body.version === "string" &&
"pid" in body &&
typeof body.pid === "number" &&
Number.isInteger(body.pid) &&
body.pid > 0
) {
if (body !== undefined && "version" in body && "pid" in body) {
if (body.pid !== info.pid) return { service: undefined, timedOut: false }
if (info.version !== undefined && body.version !== info.version) return { service: undefined, timedOut: false }
return {
@@ -201,16 +185,7 @@ async function probeResult(info: Info, allowLegacy = false, timeout = defaultEns
timedOut: false,
}
}
if (
!allowLegacy ||
typeof body !== "object" ||
body === null ||
!("healthy" in body) ||
body.healthy !== true ||
"version" in body ||
"pid" in body
)
return { service: undefined, timedOut: false }
if (!allowLegacy || body?.healthy !== true) return { service: undefined, timedOut: false }
return {
service: { info, endpoint, state: "ready", legacy: true } satisfies LocalService,
timedOut: false,
@@ -227,6 +202,12 @@ async function find(options: { readonly file?: string }) {
return (await registered(options.file, true)).service
}
function signal(pid: number, name: NodeJS.Signals) {
try {
process.kill(pid, name)
} catch {}
}
function stopped(pid: number) {
try {
process.kill(pid, 0)
@@ -245,25 +226,36 @@ async function waitUntilStopped(pid: number, timing: EnsureTiming) {
}
function same(left: Info, right: Info) {
return (
left.id === right.id &&
left.version === right.version &&
left.url === right.url &&
left.pid === right.pid &&
left.password === right.password
)
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
}
async function kill(
service: LocalService,
_options: { readonly file?: string },
timing: EnsureTiming,
) {
async function evict(info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
const current = await read(options.file)
if (current === undefined || !same(current, info)) return
signal(info.pid, "SIGTERM")
if (await waitUntilStopped(info.pid, timing)) return
const latest = await read(options.file)
if (latest === undefined || !same(latest, info)) return
signal(info.pid, "SIGKILL")
if (!(await waitUntilStopped(info.pid, timing))) throw new Error(`Server process ${info.pid} is still running`)
}
async function kill(service: LocalService, options: { readonly file?: string }, timing: EnsureTiming) {
const requested = await requestStop(service, timing.requestTimeout)
if (requested === "rejected") throw new Error("Background service rejected the stop request")
if (requested === "unsupported") throw new Error("Background service does not support authenticated stop requests")
if (requested === "rejected") return
if (requested === "unsupported") {
const current = await find(options)
if (current === undefined || !same(current.info, service.info)) return
signal(service.info.pid, "SIGTERM")
}
if (await waitUntilStopped(service.info.pid, timing)) return
throw new Error("Background service accepted the stop request but did not exit")
const latest = await find(options)
if (latest === undefined || !same(latest.info, service.info)) return
signal(service.info.pid, "SIGKILL")
if (!(await waitUntilStopped(service.info.pid, timing)))
throw new Error(`Server process ${service.info.pid} is still running`)
}
async function requestStop(service: LocalService, timeout = defaultEnsureTiming.requestTimeout) {
@@ -274,8 +266,7 @@ async function requestStop(service: LocalService, timeout = defaultEnsureTiming.
body: JSON.stringify({ instanceID: service.info.id }),
signal: AbortSignal.timeout(timeout),
}).catch(() => undefined)
if (response === undefined) return "rejected" as const
if (response.status === 404 || response.status === 405) return "unsupported" as const
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const
const body = (await response.json().catch(() => undefined)) as ServiceStopResponse | undefined
if (!response.ok || body?.accepted !== true) return "rejected" as const
return "accepted" as const
-17
View File
@@ -28,27 +28,10 @@ export type EnsureReason = "missing" | "version-mismatch"
export type EnsureOptions = DiscoverOptions & {
/** Service command and arguments. Defaults to `opencode serve --service`. */
readonly command?: ReadonlyArray<string>
/** Decide whether a version-mismatched service may be replaced. Defaults to true. */
readonly canReplace?: (version: string | undefined) => boolean
/** Called once before spawning a new service process. */
readonly onStart?: (reason: EnsureReason, previousVersion?: string) => void
}
/** A healthy service exists, but the caller's replacement policy protects it. */
export class VersionMismatchError extends Error {
override readonly name = "VersionMismatchError"
constructor(
readonly clientVersion: string | undefined,
readonly serverVersion: string | undefined,
) {
super(
`Background service ${serverVersion ?? "unknown"} is newer than this client ${clientVersion ?? "unknown"}. ` +
"Run `opencode2 service restart` to activate this installed version.",
)
}
}
/** Options used to stop the local OpenCode service. */
export type StopOptions = {
/** Absolute registration file path. Defaults to the XDG state directory. */
+3 -7
View File
@@ -27,7 +27,7 @@ if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" ||
}
let requests = 0
const version = mode === "old" || mode === "reject-stop" || mode === "stop-hanging" ? "old" : "test"
const version = mode === "old" || mode === "reject-stop" ? "old" : "test"
const id = crypto.randomUUID()
const server = Bun.serve({
port: 0,
@@ -37,11 +37,7 @@ const server = Bun.serve({
await appendFile(registration + ".stop-attempts", process.pid + "\n")
return Response.json({ accepted: false })
}
if (pathname === "/api/service/stop" && mode === "stop-hanging") {
await appendFile(registration + ".stop-attempts", process.pid + "\n")
return new Promise<Response>(() => {})
}
if (pathname === "/api/service/stop" && (mode === "graceful" || mode === "old")) {
if (pathname === "/api/service/stop" && mode === "graceful") {
const body = await request.json()
if (typeof body !== "object" || body === null || body.instanceID !== id) return Response.json({ accepted: false })
await writeFile(registration + ".stop", JSON.stringify(body))
@@ -64,7 +60,7 @@ const server = Bun.serve({
if (mode === "starting" && !(await Bun.file(registration + ".release").exists()))
return Response.json({ healthy: true, version, pid: process.pid }, { status: 503 })
if (mode === "failed-owner") return Response.json({ healthy: true, version, pid: process.pid }, { status: 500 })
if (mode === "starting" || mode === "graceful" || mode === "reject-stop" || mode === "stop-hanging")
if (mode === "starting" || mode === "graceful" || mode === "reject-stop")
return Response.json({ healthy: true, version, pid: process.pid })
return Response.json({ healthy: true, version, pid: process.pid })
},
+11 -93
View File
@@ -25,50 +25,6 @@ test("discovers a registered service", async () => {
expect(await Service.discover({ file: registration, version: "other" })).toBeUndefined()
})
test("rejects malformed registrations without probing or signaling", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const malformed = [
null,
[],
{},
{ url: "http://127.0.0.1:1" },
{ url: "http://127.0.0.1:1", pid: 0 },
{ url: "http://127.0.0.1:1", pid: -1 },
{ url: "http://127.0.0.1:1", pid: 1.5 },
{ url: "http://127.0.0.1:1", pid: "1" },
{ url: "http://127.0.0.1:1", pid: 1, id: 1 },
]
for (const value of malformed) {
await Bun.write(registration, JSON.stringify(value))
expect(await Service.discover({ file: registration })).toBeUndefined()
}
})
test("rejects primitive and partial modern health responses", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const bodies = [
null,
1,
"healthy",
[],
{},
{ healthy: false, version: "test", pid: process.pid },
{ healthy: true, version: null, pid: process.pid },
{ healthy: true, version: "test", pid: "1" },
{ healthy: true, version: "test" },
{ healthy: true, pid: process.pid },
]
for (const body of bodies) {
using server = Bun.serve({ port: 0, fetch: () => Response.json(body) })
await Bun.write(registration, JSON.stringify({ url: server.url.toString(), pid: process.pid }))
expect(await Service.discover({ file: registration })).toBeUndefined()
}
})
test("ensures a missing service with native promises", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
@@ -131,7 +87,7 @@ test("reports a bounded contender stderr tail with native promises", async () =>
expect(error.message.length).toBeLessThan(9_000)
}, 10_000)
test("never evicts an unresponsive registered service automatically", async () => {
test("evicts an unresponsive registered service before starting its replacement", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = Bun.spawn([process.execPath, fixture, registration, "hanging"], {
@@ -142,46 +98,19 @@ test("never evicts an unresponsive registered service automatically", async () =
await waitForFile(registration)
const original = await Bun.file(registration).json()
const options = {
const endpoint = await ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "record-start"],
}
const result = ensure(options)
await waitForLines(registration + ".requests", 3)
command: [process.execPath, fixture, registration, "delayed", "10"],
})
const replacement = await Bun.file(registration).json()
expect(existing.exitCode).toBe(null)
expect(await Bun.file(registration).json()).toEqual(original)
await expect(result).rejects.toThrow()
})
test("explicit native stop refuses to signal an unidentified unresponsive PID", async () => {
const registration = await setup("hanging")
const info = await Bun.file(registration).json()
await expect(Service.stop({ file: registration })).rejects.toThrow("stop its process manually")
expect(process.kill(info.pid, 0)).toBe(true)
})
test("a stale native client refuses to replace a newer service", async () => {
const registration = await setup("graceful")
const directory = await temp()
const contender = join(directory, "contender.json")
const info = await Bun.file(registration).json()
await expect(
ensure({
file: registration,
version: "old",
canReplace: () => false,
command: [process.execPath, fixture, contender, "record-start"],
}),
).rejects.toThrow("Run `opencode2 service restart` to activate this installed version")
expect(await Bun.file(contender + ".started").exists()).toBe(false)
expect(process.kill(info.pid, 0)).toBe(true)
expect(await Bun.file(registration).json()).toEqual(info)
expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
expect(await existing.exited).toBe(0)
expect(replacement.pid).not.toBe(original.pid)
expect(endpoint.url).toBe(replacement.url)
process.kill(replacement.pid, "SIGTERM")
await waitForExit(replacement.pid)
})
test("requests graceful stop of the exact service instance", async () => {
@@ -214,14 +143,3 @@ async function waitForFile(file: string) {
}
throw new Error(`Timed out waiting for ${file}`)
}
async function waitForLines(file: string, count: number) {
for (let attempt = 0; attempt < 600; attempt++) {
const text = await Bun.file(file)
.text()
.catch(() => "")
if (text.trim().split("\n").length >= count) return
await Bun.sleep(5)
}
throw new Error(`Timed out waiting for ${count} lines in ${file}`)
}
+22 -145
View File
@@ -72,39 +72,29 @@ test("reports a failed registered service without spawning", async () => {
expect(process.exitCode).toBe(null)
})
test("never evicts an unresponsive registered service automatically", async () => {
test("evicts an unresponsive registered service before starting its replacement", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "hanging")
await waitForFile(registration)
const original = await Bun.file(registration).json()
const controller = new AbortController()
const result = Effect.runPromise(
const endpoint = await run(
ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "record-start"],
}).pipe(Effect.provide(NodeFileSystem.layer)),
{ signal: controller.signal },
command: [process.execPath, fixture, registration, "delayed", "10"],
}),
)
await waitForLines(registration + ".requests", 3)
controller.abort()
await result.catch(() => undefined)
const replacement = await Bun.file(registration).json()
expect(existing.exitCode).toBe(null)
expect(await Bun.file(registration).json()).toEqual(original)
})
test("explicit stop refuses to signal an unidentified unresponsive PID", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "hanging")
await waitForFile(registration)
await expect(run(Service.stop({ file: registration }))).rejects.toThrow("stop its process manually")
expect(existing.exitCode).toBe(null)
expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
expect(await existing.exited).toBe(0)
expect(replacement.pid).not.toBe(original.pid)
expect(endpoint.url).toBe(replacement.url)
expect(await health(endpoint.url)).toEqual({ healthy: true, version: "test", pid: replacement.pid })
process.kill(replacement.pid, "SIGTERM")
await waitForExit(replacement.pid)
})
test("requests graceful stop of the exact service instance", async () => {
@@ -125,108 +115,25 @@ test("does not spawn contenders while an incompatible service rejects replacemen
const contender = join(directory, "contender.json")
const existing = spawn(registration, "reject-stop")
await waitForFile(registration)
const starting = run(
const controller = new AbortController()
const starting = Effect.runPromise(
ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, contender, "record-start"],
}),
}).pipe(Effect.provide(NodeFileSystem.layer)),
{ signal: controller.signal },
)
await expect(starting).rejects.toThrow("Background service rejected the stop request")
expect(await Bun.file(contender + ".started").exists()).toBe(false)
expect((await Bun.file(registration + ".stop-attempts").text()).trim().split("\n")).toHaveLength(1)
expect(existing.exitCode).toBe(null)
})
test("does not signal a modern service when its stop request times out", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const contender = join(directory, "contender.json")
const existing = spawn(registration, "stop-hanging")
await waitForFile(registration)
const starting = run(
ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, contender, "record-start"],
}),
)
await expect(starting).rejects.toThrow("Background service rejected the stop request")
expect(await Bun.file(contender + ".started").exists()).toBe(false)
expect((await Bun.file(registration + ".stop-attempts").text()).trim().split("\n")).toHaveLength(1)
expect(existing.exitCode).toBe(null)
})
test("explicit stop refuses to signal when a modern stop request times out", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "stop-hanging")
await waitForFile(registration)
await expect(run(Service.stop({ file: registration }))).rejects.toThrow("Background service rejected the stop request")
expect(existing.exitCode).toBe(null)
})
test("a stale client refuses to replace a newer service", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const contender = join(directory, "contender.json")
const existing = spawn(registration, "graceful")
await waitForFile(registration)
const info = await Bun.file(registration).json()
await expect(
run(
ensure({
file: registration,
version: "old",
canReplace: () => false,
command: [process.execPath, fixture, contender, "record-start"],
}),
),
).rejects.toThrow("Run `opencode2 service restart` to activate this installed version")
await waitForLines(registration + ".stop-attempts", 2)
controller.abort()
await starting.catch(() => undefined)
expect(await Bun.file(contender + ".started").exists()).toBe(false)
expect(existing.exitCode).toBe(null)
expect(await Bun.file(registration).json()).toEqual(info)
})
test("explicit restart can activate an installed downgrade", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const current = spawn(registration, "graceful")
await waitForFile(registration)
const before = await Bun.file(registration).json()
const options = {
file: registration,
version: "old",
canReplace: () => false,
command: [process.execPath, fixture, registration, "old"],
}
await expect(run(ensure(options))).rejects.toThrow("Run `opencode2 service restart`")
expect(current.exitCode).toBe(null)
await run(Service.stop({ file: registration }))
const endpoint = await run(ensure(options))
const after = await Bun.file(registration).json()
try {
expect(after.pid).not.toBe(before.pid)
expect(after.version).toBe("old")
expect(endpoint.url).toBe(after.url)
} finally {
process.kill(after.pid, "SIGTERM")
await waitForExit(after.pid)
}
})
test("refuses to signal a legacy service without authenticated stop", async () => {
test("a legacy health response is still replaced", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "legacy")
@@ -235,9 +142,9 @@ test("refuses to signal a legacy service without authenticated stop", async () =
const starts: EnsureReason[] = []
const result = run(ensure({ file: registration, command: [], onStart: (reason) => starts.push(reason) }))
await expect(result).rejects.toThrow("does not support authenticated stop requests")
await expect(result).rejects.toThrow("Missing service command")
expect(starts).toEqual(["version-mismatch"])
expect(existing.exitCode).toBe(null)
await existing.exited
})
test("waits for a slow winner while bounding lock probes", async () => {
@@ -364,36 +271,6 @@ test("replaces an incompatible owner that appears during startup", async () => {
}
})
test("concurrent current-version launchers converge on one replacement", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const old = spawn(registration, "old")
await waitForFile(registration)
const endpoints = await Promise.all(
Array.from({ length: 20 }, (_, index) => {
const options = {
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "coordinated"],
canReplace: (version: string | undefined) => version === "old",
}
return index % 2 === 0 ? run(ensure(options)) : import("../src/promise/service").then((mod) => mod.Service.ensure(options))
}),
)
const info = await Bun.file(registration).json()
try {
expect(new Set(endpoints.map((endpoint) => endpoint.url))).toEqual(new Set([info.url]))
expect(info.version).toBe("test")
expect(old.exitCode).not.toBe(null)
expect(await health(info.url)).toEqual({ healthy: true, version: "test", pid: info.pid })
} finally {
process.kill(info.pid, "SIGTERM")
await waitForExit(info.pid)
}
})
function run<A, E>(effect: Effect.Effect<A, E>) {
return Effect.runPromise(effect.pipe(Effect.provide(NodeFileSystem.layer)))
}
+2
View File
@@ -136,6 +136,8 @@ const serialize = (message: SessionMessage.Info) => {
const skills = message.skills?.map((skill) => `[Attached skill: ${skill.name}]\n${skill.text}`) ?? []
return [`[User]: ${message.text}`, ...skills, ...files].join("\n")
}
if (message.type === "location-switched")
return `[User]: The working directory has been changed to ${message.location.directory}.`
if (message.type === "assistant") {
return message.content
.flatMap((part) => {
+4
View File
@@ -10,6 +10,7 @@ import { Instructions } from "../instructions/index.js"
import { InstructionBuiltIns } from "../instructions/builtins.js"
import { Location } from "../location.js"
import { McpInstructions } from "../mcp/instructions.js"
import { McpTool } from "../tool/mcp.js"
import { PluginSupervisor } from "../plugin/supervisor.js"
import { ReferenceInstructions } from "../reference/instructions.js"
import { SkillInstructions } from "../skill/instructions.js"
@@ -64,6 +65,7 @@ const layer = Layer.effect(
const entries = yield* InstructionEntry.Service
const location = yield* Location.Service
const mcpInstructions = yield* McpInstructions.Service
const mcpTools = yield* McpTool.Service
const models = yield* SessionRunnerModel.Service
const plugins = yield* PluginSupervisor.Service
const referenceInstructions = yield* ReferenceInstructions.Service
@@ -78,6 +80,7 @@ const layer = Layer.effect(
return yield* Effect.interrupt
yield* plugins.flush
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 })
const loaded = yield* Effect.all(
@@ -136,6 +139,7 @@ export const node = makeLocationNode({
InstructionEntry.node,
Location.node,
McpInstructions.node,
McpTool.node,
PluginSupervisor.node,
ReferenceInstructions.node,
SessionRunnerModel.node,
+18 -1
View File
@@ -6,6 +6,7 @@ import { SessionMessage } from "./message.js"
export interface Adapter {
readonly getAgent: () => Effect.Effect<SessionMessage.AgentSelected["agent"] | undefined, never, never>
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
readonly getLocation: () => Effect.Effect<SessionMessage.LocationSwitched["previous"], never, never>
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
readonly getAssistant: (
messageID: SessionMessage.ID,
@@ -89,7 +90,22 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
)
})
},
"session.moved": () => Effect.void,
"session.moved": (event) => {
return Effect.gen(function* () {
yield* adapter.appendMessage(
SessionMessage.LocationSwitched.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "location-switched",
metadata: event.metadata,
location: event.data.location,
projectID: event.data.projectID,
subpath: event.data.subpath,
previous: yield* adapter.getLocation(),
time: { created: event.created },
}),
)
})
},
"session.renamed": () => Effect.void,
"session.deleted": () => Effect.void,
"session.forked": () => Effect.void,
@@ -109,6 +125,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
id: SessionMessage.ID.fromEvent(event.id),
type: "system",
text: event.data.text,
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
metadata: event.metadata,
time: { created: event.created },
}),
+29
View File
@@ -16,6 +16,7 @@ import { InstructionState } from "./instruction-state.js"
import { SessionPendingTable, SessionMessageTable, SessionTable } from "./sql.js"
import { Slug } from "../util/slug.js"
import { Money } from "@opencode-ai/schema/money"
import { AbsolutePath, RelativePath } from "../schema.js"
import type { SessionSchema } from "./schema.js"
type DatabaseService = Database.Interface["db"]
@@ -253,6 +254,33 @@ function run(db: DatabaseService, event: MessageEvent) {
Effect.map((row) => (row?.model ? Schema.decodeUnknownSync(Model.Ref)(row.model) : undefined)),
)
},
getLocation() {
return db
.select({
directory: SessionTable.directory,
workspaceID: SessionTable.workspace_id,
projectID: SessionTable.project_id,
subpath: SessionTable.path,
})
.from(SessionTable)
.where(eq(SessionTable.id, event.data.sessionID))
.get()
.pipe(
Effect.orDie,
Effect.map((row) =>
row
? {
location: {
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined,
},
projectID: row.projectID,
subpath: row.subpath === null ? undefined : RelativePath.make(row.subpath),
}
: undefined,
),
)
},
getCurrentAssistant() {
return Effect.gen(function* () {
// A newer step supersedes stale incomplete rows; never resume an older assistant projection.
@@ -391,6 +419,7 @@ const layer = Layer.effectDiscard(
)
yield* bus.project(SessionEvent.Moved, (event) =>
Effect.gen(function* () {
yield* run(db, event)
yield* db
.update(SessionTable)
.set({
@@ -201,6 +201,15 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
case "agent-switched":
case "model-switched":
return []
case "location-switched":
return [
Message.make({
id: message.id,
role: "user",
content: `The working directory has been changed to ${message.location.directory}.`,
metadata: message.metadata,
}),
]
case "user":
const content = [
...(message.skills ?? []).map((skill) => Message.text(skill.text)),
+13 -4
View File
@@ -2,7 +2,7 @@ export * as McpTool from "./mcp.js"
import { ToolFailure } from "@opencode-ai/ai"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
import { Context, Effect, Exit, Fiber, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
@@ -16,7 +16,15 @@ import { Tool } from "../tool.js"
export const namespace = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, "_")
export const name = (server: string, tool: string) => `${namespace(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
export const layer = Layer.effectDiscard(
export interface Interface {
/** Wait for the initial MCP tool registration to settle. */
readonly flush: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/McpTool") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const mcp = yield* MCP.Service
const tools = yield* Tool.Service
@@ -113,16 +121,17 @@ export const layer = Layer.effectDiscard(
}),
)
yield* reconcile.pipe(Effect.forkScoped)
const initial = yield* reconcile.pipe(Effect.forkScoped)
yield* bus.subscribe(McpEvent.ToolsChanged).pipe(
Stream.runForEach(() => reconcile),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({ flush: Effect.asVoid(Fiber.await(initial)) })
}),
)
export const node = makeLocationNode({
name: "mcp-tools",
service: Service,
layer,
deps: [Tool.node, MCP.node, Bus.node, Permission.node],
})
+17
View File
@@ -50,6 +50,23 @@ describe("Session.move", () => {
yield* session.move({ sessionID: created.id, directory: destination })
expect((yield* session.get(created.id)).location.directory).toBe(destination)
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
expect(messages).toEqual([
expect.objectContaining({
type: "location-switched",
location: { directory: destination },
projectID: Project.ID.global,
previous: {
location: { directory: path.join(tmp.path, "deleted") },
projectID: Project.ID.global,
subpath: "",
},
subpath: "",
}),
])
yield* session.move({ sessionID: created.id, directory: destination })
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toEqual(messages)
}),
),
),
@@ -8,6 +8,8 @@ import { Skill } from "@opencode-ai/schema/skill"
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
import { Agent } from "@opencode-ai/core/agent"
import { Shell } from "@opencode-ai/schema/shell"
import { Location } from "@opencode-ai/schema/location"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { DateTime } from "effect"
const created = DateTime.makeUnsafe(0)
@@ -67,6 +69,15 @@ describe("toLLMMessages", () => {
model: { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") },
time: { created },
}),
SessionMessage.LocationSwitched.make({
id: id("location"),
type: "location-switched",
location: Location.Ref.make({ directory: AbsolutePath.make("/destination") }),
previous: {
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
},
time: { created },
}),
SessionMessage.System.make({
id: id("system"),
type: "system",
@@ -110,9 +121,16 @@ describe("toLLMMessages", () => {
model,
)
expect(messages.map((message) => message.role)).toEqual(["system", "user", "user", "user", "user"])
expect(messages[0]).toEqual(Message.system("Updated context\n\nOther context"))
expect(messages[1]).toEqual(
expect(messages.map((message) => message.role)).toEqual(["user", "system", "user", "user", "user", "user"])
expect(messages[0]).toEqual(
Message.make({
id: id("location"),
role: "user",
content: "The working directory has been changed to /destination.",
}),
)
expect(messages[1]).toEqual(Message.system("Updated context\n\nOther context"))
expect(messages[2]).toEqual(
Message.make({
id: id("user"),
role: "user",
@@ -123,7 +141,7 @@ describe("toLLMMessages", () => {
metadata: { agents: [{ name: "build" }] },
}),
)
expect(messages.slice(2).map((message) => message.content)).toEqual([
expect(messages.slice(3).map((message) => message.content)).toEqual([
[{ type: "text", text: "Synthetic context" }],
[
{
+1
View File
@@ -134,6 +134,7 @@ test("Core reuses the canonical shared schemas", async () => {
[coreSessionMessage.AssistantRetry, SessionMessage.AssistantRetry],
[coreSessionMessage.AgentSelected, SessionMessage.AgentSelected],
[coreSessionMessage.ModelSelected, SessionMessage.ModelSelected],
[coreSessionMessage.LocationSwitched, SessionMessage.LocationSwitched],
[coreSessionMessage.User, SessionMessage.User],
[coreSessionMessage.Synthetic, SessionMessage.Synthetic],
[coreSessionMessage.System, SessionMessage.System],
+5 -1
View File
@@ -4,7 +4,7 @@ import appPlugin from "@opencode-ai/app/vite"
const channel = (() => {
const raw = process.env.OPENCODE_CHANNEL
if (raw === "dev" || raw === "beta" || raw === "prod") return raw
if (raw === "local" || raw === "dev" || raw === "beta" || raw === "prod") return raw
if (process.env.OPENCODE_CHANNEL === "latest") return "prod"
return "dev"
})()
@@ -72,6 +72,10 @@ const require = __cjs_mod__.createRequire(import.meta.url);
},
},
renderer: {
define: {
"import.meta.env.OPENCODE_VERSION": JSON.stringify(process.env.OPENCODE_VERSION),
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel),
},
plugins: [appPlugin, sentry],
publicDir: "../../../app/public",
root: "src/renderer",
+4
View File
@@ -7,7 +7,11 @@ type ServerSource = { type: "build" } | { type: "download"; version: string }
type DevOptions = { server: ServerSource; electron: string[] }
async function main() {
process.env.OPENCODE_CHANNEL = "local"
process.env.OPENCODE_VERSION = `2.0.0-local-${Date.now()}`
process.env.OPENCODE_DISABLE_CHANNEL_DB = "0"
const options = selectOptions()
if (options.server.type === "build") process.env.OPENCODE_DESKTOP_SERVER_CHANNEL = "local"
await prepareDesktop()
await prepareServer(options.server)
await startDesktop(options.electron)
+1 -1
View File
@@ -93,7 +93,7 @@ export async function buildCliToResources(dest = windowsify("resources/opencode-
await $`bun ${join(import.meta.dirname, "../../cli/script/build.ts")} --single --skip-install --skip-web-ui --outdir=${directory}`.env(
{
...process.env,
OPENCODE_VERSION: `0.0.0-local-${Date.now()}`,
OPENCODE_VERSION: process.env.OPENCODE_VERSION,
},
)
if (stateHome && (await Bun.file(dest).exists())) {
@@ -25,6 +25,10 @@ export async function startBackgroundCli(logger: Logger) {
const binary = app.isPackaged || isolated ? await installCli(bundled, version, logger) : bundled
if (isolated) process.env.XDG_STATE_HOME = app.getPath("userData")
const service = await Service.ensure({
file:
isolated && process.env.OPENCODE_DESKTOP_SERVER_CHANNEL === "local"
? join(app.getPath("userData"), "opencode", "service-local.json")
: undefined,
version,
command: [binary, "serve", "--service"],
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
+3 -2
View File
@@ -1,7 +1,8 @@
import { app } from "electron"
type Channel = "dev" | "beta" | "prod"
type Channel = "local" | "dev" | "beta" | "prod"
const raw = import.meta.env.OPENCODE_CHANNEL
export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod" ? raw : "dev"
export const CHANNEL: Channel = raw === "local" || raw === "dev" || raw === "beta" || raw === "prod" ? raw : "dev"
export const VERSION = app.isPackaged ? app.getVersion() : (process.env.OPENCODE_VERSION ?? app.getVersion())
export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"
+1
View File
@@ -1,5 +1,6 @@
interface ImportMetaEnv {
readonly OPENCODE_CHANNEL: string
readonly OPENCODE_VERSION?: string
}
interface ImportMeta {
+3 -3
View File
@@ -12,7 +12,7 @@ import contextMenu from "electron-context-menu"
import type { ServerReadyData } from "../preload/types"
import { checkAppExists, resolveAppPath } from "./apps"
import { CHANNEL } from "./constants"
import { CHANNEL, VERSION } from "./constants"
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand } from "./ipc"
import { forwardInitializationFailure } from "./initialization"
import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
@@ -135,7 +135,7 @@ const main = Effect.gen(function* () {
initCrashReporter()
const wslServers = createWslServersController(
app.getVersion(),
VERSION,
async (distro) => {
logger.log("spawning wsl sidecar", { distro })
return spawnWslSidecar(distro, {
@@ -165,7 +165,7 @@ const main = Effect.gen(function* () {
}
logger.log("app starting", {
version: app.getVersion(),
version: VERSION,
packaged: app.isPackaged,
onboardingTest: Boolean(onboardingTestRoot),
})
+2 -1
View File
@@ -5,6 +5,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, wri
import { ZipWriter, BlobWriter, BlobReader } from "@zip.js/zip.js"
import { dirname, join } from "node:path"
import { homedir } from "node:os"
import { VERSION } from "./constants"
const MAX_LOG_AGE_DAYS = 7
const TAIL_LINES = 1000
@@ -133,7 +134,7 @@ function cleanup() {
function manifest() {
return {
generated: new Date().toISOString(),
version: app.getVersion(),
version: VERSION,
name: app.getName(),
packaged: app.isPackaged,
platform: process.platform,
+3 -2
View File
@@ -33,6 +33,7 @@ import { Splash } from "@opencode-ai/ui/logo"
import { useTheme } from "@opencode-ai/ui/theme/context"
const root = document.getElementById("root")
const version = import.meta.env.OPENCODE_VERSION ?? pkg.version
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
throw new Error(t("desktop.error.dev.rootNotFound"))
}
@@ -41,7 +42,7 @@ if (import.meta.env.VITE_SENTRY_DSN) {
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.VITE_SENTRY_ENVIRONMENT ?? import.meta.env.MODE,
release: import.meta.env.VITE_SENTRY_RELEASE ?? `desktop@${pkg.version}`,
release: import.meta.env.VITE_SENTRY_RELEASE ?? `desktop@${version}`,
initialScope: {
tags: {
platform: "desktop",
@@ -168,7 +169,7 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
return {
platform: "desktop",
os,
version: pkg.version,
version,
windowID: windowState.id,
async openDirectoryPickerDialog(opts) {
-2
View File
@@ -372,8 +372,6 @@ export interface KeymapCommand {
readonly aliases?: string[]
/** Keeps the slash command in the prompt and passes its raw input to run. */
readonly arguments?: true
/** Hides the command from slash completion until its exact name is typed. */
readonly secret?: true
}
/** Promotes the command in discovery UI. */
readonly suggested?: boolean | (() => boolean)
+60
View File
@@ -12795,6 +12795,63 @@
"required": ["id", "time", "type", "model"],
"additionalProperties": false
},
"Session.Message.LocationSwitched": {
"type": "object",
"properties": {
"id": {
"type": "string",
"allOf": [
{
"pattern": "^msg_"
}
]
},
"metadata": {
"type": "object"
},
"time": {
"type": "object",
"properties": {
"created": {
"type": "number"
}
},
"required": ["created"],
"additionalProperties": false
},
"type": {
"type": "string",
"enum": ["location-switched"]
},
"location": {
"$ref": "#/components/schemas/Location.Ref"
},
"projectID": {
"type": "string"
},
"subpath": {
"type": "string"
},
"previous": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.Ref"
},
"projectID": {
"type": "string"
},
"subpath": {
"type": "string"
}
},
"required": ["location"],
"additionalProperties": false
}
},
"required": ["id", "time", "type", "location"],
"additionalProperties": false
},
"Prompt.Base64": {
"type": "string",
"allOf": [
@@ -13719,6 +13776,9 @@
{
"$ref": "#/components/schemas/Session.Message.ModelSelected"
},
{
"$ref": "#/components/schemas/Session.Message.LocationSwitched"
},
{
"$ref": "#/components/schemas/Session.Message.User"
},
+31 -1
View File
@@ -3,7 +3,9 @@ export * as SessionMessage from "./session-message.js"
import { Schema } from "effect"
import { optional } from "./schema.js"
import { Content } from "./tool.js"
import { Location } from "./location.js"
import { Model } from "./model.js"
import { Project } from "./project.js"
import { Prompt } from "./prompt.js"
import { DateTimeUtcFromMillis, PositiveInt, RelativePath, statics } from "./schema.js"
import { ascending } from "./identifier.js"
@@ -53,6 +55,20 @@ export const ModelSelected = Schema.Struct({
previous: Model.Ref.pipe(optional),
}).annotate({ identifier: "Session.Message.ModelSelected" })
export interface LocationSwitched extends Schema.Schema.Type<typeof LocationSwitched> {}
export const LocationSwitched = Schema.Struct({
...Base,
type: Schema.tag("location-switched"),
location: Location.Ref,
projectID: Project.ID.pipe(optional),
subpath: RelativePath.pipe(optional),
previous: Schema.Struct({
location: Location.Ref,
projectID: Project.ID.pipe(optional),
subpath: RelativePath.pipe(optional),
}).pipe(optional),
}).annotate({ identifier: "Session.Message.LocationSwitched" })
export interface User extends Schema.Schema.Type<typeof User> {}
export const User = Schema.Struct({
...Base,
@@ -75,7 +91,10 @@ export interface System extends Schema.Schema.Type<typeof System> {}
export const System = Schema.Struct({
...Base,
type: Schema.tag("system"),
/** The model-facing update text, frozen at emit time. */
text: Schema.String,
/** A short human-readable summary for transcript display. */
description: Schema.String.pipe(optional),
}).annotate({ identifier: "Session.Message.System" })
export interface Skill extends Schema.Schema.Type<typeof Skill> {}
@@ -243,6 +262,7 @@ export type Compaction = CompactionRunning | CompactionCompleted | CompactionFai
export const Info = Schema.Union([
AgentSelected,
ModelSelected,
LocationSwitched,
User,
Synthetic,
System,
@@ -251,5 +271,15 @@ export const Info = Schema.Union([
Assistant,
Compaction,
]).annotate({ identifier: "Session.Message.Info" })
export type Info = AgentSelected | ModelSelected | User | Synthetic | System | Skill | Shell | Assistant | Compaction
export type Info =
| AgentSelected
| ModelSelected
| LocationSwitched
| User
| Synthetic
| System
| Skill
| Shell
| Assistant
| Compaction
export type Type = Info["type"]
+2 -30
View File
@@ -30,7 +30,7 @@ import {
batch,
Show,
} from "solid-js"
import { createStore, unwrap } from "solid-js/store"
import { createStore } from "solid-js/store"
import {
TuiLifecycleProvider,
TuiAppProvider,
@@ -62,7 +62,6 @@ import { useConnected } from "./component/use-connected"
import { DialogMcp } from "./component/dialog-mcp"
import { DialogStatus } from "./component/dialog-status"
import { DialogConfig } from "./component/dialog-config"
import { DialogExperiments } from "./component/dialog-experiments"
import { DialogDebug } from "./component/dialog-debug"
import { DialogPair, type DialogPairCredentials } from "./component/dialog-pair"
import { DialogThemeList } from "./component/dialog-theme-list"
@@ -497,7 +496,7 @@ function App(props: { pair?: DialogPairCredentials }) {
toast.show({
variant: "error",
title: `MCP server failed: ${server.name}`,
message: "Open MCP servers to view details.",
message: "Run /mcps to view details.",
})
}
})
@@ -658,22 +657,8 @@ function App(props: { pair?: DialogPairCredentials }) {
category: "Session",
slash: { name: "new", aliases: ["clear"] },
run: () => {
// With per-tab drafts, a new session is an explicit "this belongs
// elsewhere" gesture: move the in-progress draft instead of leaving
// a copy behind on the tab it came from.
const carried = (() => {
if (config.data.experimental?.tab_drafts !== true) return undefined
const current = promptRef.current
if (!current?.current.text) return undefined
// Copy before reset: reset() merges an empty prompt into the same
// underlying store object that unwrap exposes.
const prompt = { ...unwrap(current.current) }
current.reset()
return prompt
})()
route.navigate({
type: "home",
prompt: carried,
location:
route.data.type === "session"
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
@@ -885,19 +870,6 @@ function App(props: { pair?: DialogPairCredentials }) {
},
category: "System",
},
{
// Deliberately absent from the command palette; reachable only by the
// secret /baldbeard incantation.
name: "opencode.experiments",
title: "Experiments",
description: "look is my devrel meme face",
palette: undefined,
slash: { name: "baldbeard", secret: true as const },
run: () => {
dialog.replace(() => <DialogExperiments />)
},
category: "System",
},
{
name: "opencode.status",
title: "View status",
@@ -13,6 +13,8 @@ import { useRoute } from "../context/route"
import { Keymap } from "../context/keymap"
import { useTheme, useThemes } from "../context/theme"
import { DevTools } from "../devtools"
import { useDialog } from "../ui/dialog"
import { DialogExperiments } from "./dialog-experiments"
import { usePlugin } from "../plugin/context"
import { errorMessage } from "../util/error"
@@ -27,6 +29,7 @@ export type RuntimeStatus = "normal" | "medium" | "high"
export function DevToolsBar() {
const client = useClient()
const config = useConfig()
const dialog = useDialog()
const data = useData()
const location = useLocation()
const route = useRoute()
@@ -405,6 +408,15 @@ export function DevToolsBar() {
</PanelBox>
</Show>
</BarItem>
<BarItem
active={false}
onClick={() => {
close()
dialog.replace(() => <DialogExperiments />)
}}
>
<text fg={theme.text.subdued}>Experiments</text>
</BarItem>
<box flexGrow={1} minWidth={0}>
<TimeToFirstDraw visible={timing()} width="100%" fg={theme.text.subdued} label="Time to first draw" />
</box>
@@ -0,0 +1,85 @@
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { createMemo, createSignal, onMount } from "solid-js"
import { useConfig } from "../config"
import { useClipboard } from "../context/clipboard"
import { Keymap } from "../context/keymap"
import { getScrollAcceleration } from "../util/scroll"
import { useDialog } from "../ui/dialog"
import { useTheme } from "../context/theme"
import { useToast } from "../ui/toast"
export function DialogErrorDetails(props: { title: string; error: string; onBack: () => void }) {
const dialog = useDialog()
const clipboard = useClipboard()
const toast = useToast()
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
const dimensions = useTerminalDimensions()
const config = useConfig().data
const [copied, setCopied] = createSignal(false)
const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
let scroll: ScrollBoxRenderable | undefined
onMount(() => dialog.setSize("large"))
const copy = () => {
void clipboard
.write(props.error)
.then(() => setCopied(true))
.catch(toast.error)
}
Keymap.createLayer(() => ({
mode: "modal",
commands: [{ bind: "escape", title: "Back", group: "Dialog", run: props.onBack }],
}))
useKeyboard((event) => {
if (event.name === "c") return copy()
if (event.name === "up") return scroll?.scrollBy(-1)
if (event.name === "down") return scroll?.scrollBy(1)
if (event.name === "pageup") return scroll?.scrollBy(-height())
if (event.name === "pagedown") return scroll?.scrollBy(height())
if (event.name === "home") return scroll?.scrollTo(0)
if (event.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight)
})
return (
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
{props.title}
</text>
<text fg={theme.text.subdued} onMouseUp={props.onBack}>
esc back
</text>
</box>
<text fg={theme.text.feedback.error.default}> Failed</text>
<box
backgroundColor={overlayTheme.background.default}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
height={height()}
scrollbarOptions={{ visible: false }}
scrollAcceleration={getScrollAcceleration(config)}
>
<text fg={overlayTheme.text.default} wrapMode="word">
{props.error}
</text>
</scrollbox>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text.subdued}> scroll</text>
<text fg={theme.text.subdued} onMouseUp={copy}>
{copied() ? "✓ copied" : "c copy details"}
</text>
</box>
</box>
)
}
@@ -16,13 +16,14 @@ export const experiments: Experiment[] = [
{
id: "tab_drafts",
title: "Per-tab prompt drafts",
description: "Keep unsent prompt drafts on the tab where they were written. New session moves the current draft.",
description: "Keep unsent prompt drafts on the tab where they were written. New sessions start blank.",
},
]
export function DialogExperiments() {
const config = useConfig()
const toast = useToast()
const [selected, setSelected] = createSignal(0)
const [saving, setSaving] = createSignal(false)
const enabled = (experiment: Experiment) => config.data.experimental?.[experiment.id] === true
@@ -30,14 +31,15 @@ export function DialogExperiments() {
const options = createMemo(() =>
experiments.map((experiment, index) => ({
title: experiment.title,
description: experiment.description,
category: "Experiments",
searchText: experiment.description,
footer: enabled(experiment) ? "on" : "off",
value: index,
})),
)
async function toggle(index: number) {
// All experiments are booleans, so either direction toggles.
async function change(index = selected()) {
if (saving()) return
const experiment = experiments[index]
if (!experiment) return
@@ -56,8 +58,23 @@ export function DialogExperiments() {
<DialogSelect
title="Experiments"
options={options()}
onSelect={(option) => void toggle(option.value)}
footerHints={[{ title: "enter", label: "toggle" }]}
onMove={(option) => setSelected(option.value)}
onSelect={(option) => void change(option.value)}
footerHints={[{ title: "←/→", label: "change" }]}
bindings={[
{
bind: "left",
title: "Previous value",
group: "Experiments",
run: () => void change(),
},
{
bind: "right",
title: "Next value",
group: "Experiments",
run: () => void change(),
},
]}
/>
)
}
+6 -84
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js"
import { createEffect, createMemo, createSignal, Show } from "solid-js"
import { useData } from "../context/data"
import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
@@ -6,13 +6,10 @@ import { pipe, sortBy } from "remeda"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useTheme } from "../context/theme"
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { TextAttributes } from "@opentui/core"
import type { McpServer } from "@opencode-ai/client"
import { useClipboard } from "../context/clipboard"
import { useToast } from "../ui/toast"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config"
import { getScrollAcceleration } from "../util/scroll"
import { DialogErrorDetails } from "./dialog-error-details"
function statusError(status: McpServer["status"]) {
if (status.status === "failed") return status.error
@@ -143,8 +140,9 @@ export function DialogMcp() {
}
>
{(server) => (
<DialogMcpError
server={server()}
<DialogErrorDetails
title={`MCP server: ${server().name}`}
error={statusError(server().status) ?? "Unknown MCP connection error"}
onBack={() => {
setDetail()
dialog.setSize("medium")
@@ -155,79 +153,3 @@ export function DialogMcp() {
</box>
)
}
function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
const dialog = useDialog()
const clipboard = useClipboard()
const toast = useToast()
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
const dimensions = useTerminalDimensions()
const config = useConfig().data
const [copied, setCopied] = createSignal(false)
const error = () => statusError(props.server.status) ?? "Unknown MCP connection error"
const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
let scroll: ScrollBoxRenderable | undefined
onMount(() => dialog.setSize("large"))
const copy = () => {
void clipboard
.write(error())
.then(() => setCopied(true))
.catch(toast.error)
}
Keymap.createLayer(() => ({
mode: "modal",
commands: [{ bind: "escape", title: "Back to MCP servers", group: "Dialog", run: props.onBack }],
}))
useKeyboard((event) => {
if (event.name === "c") return copy()
if (event.name === "up") return scroll?.scrollBy(-1)
if (event.name === "down") return scroll?.scrollBy(1)
if (event.name === "pageup") return scroll?.scrollBy(-height())
if (event.name === "pagedown") return scroll?.scrollBy(height())
if (event.name === "home") return scroll?.scrollTo(0)
if (event.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight)
})
return (
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
MCP server: {props.server.name}
</text>
<text fg={theme.text.subdued} onMouseUp={props.onBack}>
esc back
</text>
</box>
<text fg={theme.text.feedback.error.default}> Failed</text>
<box
backgroundColor={overlayTheme.background.default}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
height={height()}
scrollbarOptions={{ visible: false }}
scrollAcceleration={getScrollAcceleration(config)}
>
<text fg={overlayTheme.text.default} wrapMode="word">
{error()}
</text>
</scrollbox>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text.subdued}> scroll</text>
<text fg={theme.text.subdued} onMouseUp={copy}>
{copied() ? "✓ copied" : "c copy details"}
</text>
</box>
</box>
)
}
@@ -1,4 +1,3 @@
import { ClientError } from "@opencode-ai/client"
import { createSignal, onCleanup, onMount, Show } from "solid-js"
import { useClient } from "../context/client"
import { useTheme } from "../context/theme"
@@ -19,18 +18,7 @@ export function MigrationOverlay() {
await Bun.sleep(1_000)
void (async () => {
while (true) {
const result = await client.api.migration.v1.status({ signal: abort.signal }).then(
(status) => ({ status }),
(error: unknown) => ({ error }),
)
if ("error" in result) {
if (result.error instanceof ClientError && result.error.reason === "Transport") {
await Bun.sleep(1_000)
continue
}
throw result.error
}
const status = result.status
const status = await client.api.migration.v1.status({ signal: abort.signal })
setProgress(status.status === "running" ? status.progress : undefined)
if (status.status === "completed") return
if (status.status === "error") throw new Error(status.error)
@@ -512,9 +512,6 @@ export function Autocomplete(props: {
const results: AutocompleteOption[] = keymapCommands().flatMap((command) => {
const slash = command.slash
if (!slash) return []
// Secret commands are incantations: absent from the "/" listing and from
// fuzzy matching until the exact name is typed.
if (slash.secret && search().toLowerCase() !== slash.name) return []
return {
display: `/${slash.name}`,
description: command.description ?? command.title,
@@ -8,10 +8,6 @@ import { useToast } from "../../ui/toast"
import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
import { useData } from "../../context/data"
function moveReminderText(directory: string) {
return `<system-reminder>The user has changed the current working directory to "${directory}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.</system-reminder>`
}
export function usePromptMove(input: { projectID: () => string | undefined; sessionID: () => string | undefined }) {
const dialog = useDialog()
const client = useClient()
@@ -103,9 +99,6 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
setProgress("Moving session")
try {
await client.api.session.move({ sessionID, directory })
await client.api.session
.synthetic({ sessionID, text: moveReminderText(directory), resume: false })
.catch(() => undefined)
dialog.clear()
} catch (error) {
toast.error(error)
+26 -11
View File
@@ -431,14 +431,32 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
setStore("session", "info", event.data.sessionID, "title", event.data.title)
})
break
case "session.moved":
if (store.session.info[event.data.sessionID]) {
case "session.moved": {
const current = store.session.info[event.data.sessionID]
if (current) {
const previous = {
location: { ...current.location },
projectID: current.projectID,
subpath: current.subpath,
}
setStore("session", "info", event.data.sessionID, "location", event.data.location)
if (event.data.projectID)
setStore("session", "info", event.data.sessionID, "projectID", event.data.projectID)
setStore("session", "info", event.data.sessionID, "subpath", event.data.subpath)
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
type: "location-switched",
location: event.data.location,
projectID: event.data.projectID,
subpath: event.data.subpath,
previous,
time: { created: event.created },
})
})
}
break
}
case "session.input.promoted": {
const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inputID) ?? false
removePending(event.data.sessionID, event.data.inputID)
@@ -505,19 +523,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.instructions.updated":
const instructions = event.metadata?.instructions
if (
typeof instructions === "object" &&
instructions !== null &&
"initial" in instructions &&
instructions.initial === true
)
break
// Mirror the projector: the initial baseline and empty-rendering deltas carry no text
// and produce no transcript message.
const updateText = event.data.text
if (updateText === undefined) break
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
type: "system",
text: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
text: updateText,
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
metadata: event.metadata,
time: { created: event.created },
})
-1
View File
@@ -24,7 +24,6 @@ declare module "@opentui/keymap" {
name: string
aliases?: string[]
arguments?: true
secret?: true
}
}
}
@@ -1,10 +1,11 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, Match, Show, Switch } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { usePlugin } from "../../plugin/context"
function Mcp(props: { context: Plugin.Context }) {
const list = createMemo(() => props.context.data.location.mcp.server.list(props.context.location) ?? [])
const failed = createMemo(() => list().some((item) => item.status.status === "failed"))
const failed = createMemo(() => list().filter((item) => item.status.status === "failed").length)
const count = createMemo(() => list().filter((item) => item.status.status === "connected").length)
return (
@@ -14,6 +15,7 @@ function Mcp(props: { context: Plugin.Context }) {
<Switch>
<Match when={failed()}>
<span style={{ fg: props.context.theme.text.feedback.error.default }}> </span>
{failed()} MCP failed
</Match>
<Match when={true}>
<span
@@ -24,11 +26,28 @@ function Mcp(props: { context: Plugin.Context }) {
>
{" "}
</span>
{count()} MCP
</Match>
</Switch>
{count()} MCP
</text>
<text fg={props.context.theme.text.subdued}>/status</text>
<text fg={props.context.theme.text.subdued}>/mcps</text>
</box>
</Show>
)
}
function Plugins(props: { context: Plugin.Context }) {
const plugins = usePlugin()
const failed = createMemo(() => plugins.list().filter((item) => item.status === "failed").length)
return (
<Show when={failed()}>
<box gap={1} flexDirection="row" flexShrink={0}>
<text fg={props.context.theme.text.default}>
<span style={{ fg: props.context.theme.text.feedback.error.default }}> </span>
{failed()} plugin{failed() === 1 ? "" : "s"} failed
</text>
<text fg={props.context.theme.text.subdued}>/plugins</text>
</box>
</Show>
)
@@ -50,6 +69,7 @@ function View(props: { context: Plugin.Context }) {
gap={2}
>
<Mcp context={props.context} />
<Plugins context={props.context} />
<box flexGrow={1} />
<box flexShrink={0}>
<text fg={props.context.theme.text.subdued}>{props.context.app.version}</text>
@@ -1,22 +1,26 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, createSignal } from "solid-js"
import { createEffect, createMemo, createSignal, Show } from "solid-js"
import { usePlugin } from "../../plugin/context"
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
import { useDialog } from "../../ui/dialog"
import { DialogErrorDetails } from "../../component/dialog-error-details"
const id = "opencode.plugins"
function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePlugin> }) {
const [locked, setLocked] = createSignal(false)
const options = createMemo(() =>
props.plugins
const [focused, setFocused] = createSignal<string>()
const [detail, setDetail] = createSignal<{ title: string; error: string }>()
const dialog = useDialog()
const options = createMemo(() => {
const builtins = props.plugins
.registered()
.filter((plugin) => plugin.id !== id)
.sort((a, b) => a.id.localeCompare(b.id))
.filter((plugin) => plugin.id !== id && plugin.source === "builtin")
.map(
(plugin): DialogSelectOption<string> => ({
title: plugin.id,
value: plugin.id,
category: plugin.source === "builtin" ? "Built-in" : "External",
category: "Built-in",
footer: (
<span
style={{
@@ -29,8 +33,43 @@ function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePl
</span>
),
}),
),
)
)
const external = props.plugins.list().map(
(plugin): DialogSelectOption<string> => ({
title: "id" in plugin ? plugin.id : plugin.target,
value: "id" in plugin ? plugin.id : plugin.target,
category: "External",
searchText: plugin.target,
footer: (
<span
style={{
fg:
plugin.status === "active"
? props.context.theme.text.feedback.success.default
: plugin.status === "failed"
? props.context.theme.text.feedback.error.default
: props.context.theme.text.subdued,
}}
>
{plugin.status}
</span>
),
}),
)
return [...builtins, ...external].sort((a, b) => a.title.localeCompare(b.title))
})
const failure = (value: string | undefined) =>
props.plugins.list().find((plugin) => {
if (plugin.status !== "failed") return false
return ("id" in plugin ? plugin.id : plugin.target) === value
})
createEffect(() => {
if (focused()) return
const first = options()[0]
if (first) setFocused(first.value)
})
const toggle = (plugin: DialogSelectOption<string>) => {
if (locked()) return
@@ -51,15 +90,53 @@ function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePl
.finally(() => setLocked(false))
}
const select = (plugin: DialogSelectOption<string>) => {
const failed = failure(plugin.value)
if (!failed || failed.status !== "failed") return toggle(plugin)
setDetail({ title: failed.target, error: failed.error })
}
return (
<DialogSelect
title="Plugins"
options={options()}
locked={locked()}
preserveSelection={true}
actions={[{ title: "toggle", command: "plugins.toggle", onTrigger: toggle }]}
onSelect={toggle}
/>
<box>
<Show
when={detail()}
fallback={
<DialogSelect
title="Plugins"
options={options()}
current={focused()}
locked={locked()}
preserveSelection={true}
onMove={(option) => setFocused(option.value)}
actions={[
{
title: "toggle",
command: "plugins.toggle",
disabled: (option) => Boolean(failure(option?.value)),
onTrigger: toggle,
},
]}
onSelect={select}
footer={
<Show when={failure(focused())}>
<text fg={props.context.theme.text.subdued}>enter to view error</text>
</Show>
}
/>
}
>
{(item) => (
<DialogErrorDetails
title={`Plugin: ${item().title}`}
error={item().error}
onBack={() => {
setDetail()
dialog.setSize("medium")
}}
/>
)}
</Show>
</box>
)
}
@@ -72,6 +149,7 @@ function Commands(props: { context: Plugin.Context }) {
id: "plugins.list",
title: "Plugins",
group: "System",
slash: { name: "plugins" },
palette: true,
run() {
props.context.ui.dialog.show(() => <View context={props.context} plugins={plugins} />)
+5 -1
View File
@@ -390,7 +390,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
(prev) => prev.status === "failed" && prev.target === state.target && prev.error === state.error,
)
)
host.toast.show({ variant: "error", title: "Plugin", message: `${state.target}: ${state.error}` })
host.toast.show({
variant: "error",
title: `Plugin failed: ${state.target}`,
message: "Run /plugins to view details.",
})
setStore("states", reconcileStore(states))
}
const slotItems = new WeakMap<SlotRender, Claim<SlotRender>>()
+28 -14
View File
@@ -1379,7 +1379,13 @@ function SessionMessageView(props: { message: SessionMessageInfo }) {
<Match when={props.message.type === "shell"}>
<ShellMessage message={props.message as Extract<SessionMessageInfo, { type: "shell" }>} />
</Match>
<Match when={props.message.type === "agent-switched" || props.message.type === "model-switched"}>
<Match
when={
props.message.type === "agent-switched" ||
props.message.type === "model-switched" ||
props.message.type === "location-switched"
}
>
<SessionSwitchMessageV2 message={props.message} />
</Match>
<Match
@@ -1670,6 +1676,7 @@ function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) {
}
if (props.message.type === "model-switched")
return switchLabel(props.message.model, ctx.models(), props.message.previous)
if (props.message.type === "location-switched") return `Switched location to ${props.message.location.directory}`
return ""
}
return (
@@ -1688,7 +1695,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
const state = () => stringValue(metadata()?.state)
const actor = () => (source() === "shell" ? "Shell" : Locale.titlecase(stringValue(metadata()?.agent) ?? "Subagent"))
const text = () => {
if (props.message.type === "system") return props.message.text
if (props.message.type === "system") return props.message.description ?? "Instructions updated"
if (props.message.type === "synthetic") return props.message.description ?? ""
return ""
}
@@ -2819,10 +2826,15 @@ function Shell(props: ToolProps) {
})
const maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
const prompt = createMemo(() => (workdir() && workdir() !== "." ? `${workdir()}$` : "$"))
const input = createMemo(() => {
if (!command()) return ""
const prompt = workdir() && workdir() !== "." ? `${workdir()}$ ` : isRunning() ? "" : "$ "
return `${prompt}${command()}`
const cmd = command()
if (!cmd) return ""
// While running, the workdir prompt shares the spinner's text column; when
// settled, the prompt renders as its own column so wrapped command lines
// keep a stable hanging indent instead of jumping to the card inset.
if (isRunning() && prompt() !== "$") return `${prompt()} ${cmd}`
return cmd
})
const content = createMemo(() => [input(), output()].filter(Boolean).join("\n\n"))
const collapsed = createMemo(() => collapseToolOutput(content(), maxLines, maxChars()))
@@ -2830,6 +2842,8 @@ function Shell(props: ToolProps) {
if (expanded() || !collapsed().overflow) return content()
return collapsed().output
})
const limitedInput = createMemo(() => limited().slice(0, input().length))
const limitedOutput = createMemo(() => limited().slice(Math.min(limited().length, input().length + 2)))
const expandable = createMemo(() => Boolean(shellID()) || collapsed().overflow)
const toggle = () => {
const next = !expanded()
@@ -2853,16 +2867,16 @@ function Shell(props: ToolProps) {
<Show
when={isRunning()}
fallback={
<text>
<span style={{ fg: theme.text.default }}>{limited().slice(0, input().length)}</span>
<span style={{ fg: theme.text.subdued }}>{limited().slice(input().length)}</span>
</text>
<box flexDirection="row" gap={1}>
<text fg={theme.text.default}>{prompt()}</text>
<text fg={theme.text.default}>{limitedInput()}</text>
</box>
}
>
<Spinner color={color()}>
<span style={{ fg: theme.text.default }}>{limited().slice(0, input().length)}</span>
<span style={{ fg: theme.text.subdued }}>{limited().slice(input().length)}</span>
</Spinner>
<Spinner color={color()}>{limitedInput()}</Spinner>
</Show>
<Show when={limitedOutput()}>
<text fg={theme.text.subdued}>{limitedOutput()}</text>
</Show>
</Show>
<Show when={background()}>
@@ -2883,7 +2897,7 @@ function Write(props: ToolProps) {
return (
<Switch>
<Match when={props.metadata.diagnostics !== undefined}>
<Match when={props.part.state.status === "completed"}>
<BlockTool
path={{ label: "# Wrote", value: pathFormatter.format(stringValue(props.input.path)) }}
part={props.part}
+28 -4
View File
@@ -655,6 +655,18 @@ test("updates session location when moved", async () => {
await wait(() => data.session.get("ses_test")?.location.directory === destination)
expect(data.session.get("ses_test")?.projectID).toBe("project-moved")
expect(data.session.get("ses_test")?.subpath).toBe("packages/cli")
expect(data.session.message.list("ses_test")).toContainEqual({
id: "msg_moved_1",
type: "location-switched",
location: { directory: destination },
projectID: "project-moved",
subpath: "packages/cli",
previous: {
location: { directory },
projectID: "proj_test",
},
time: { created: 1 },
})
} finally {
app.renderer.destroy()
}
@@ -2889,14 +2901,26 @@ test("skips initial instruction state and projects later updates with their mess
delta: { "core/date": "1".repeat(64) },
},
})
emitEvent(events, {
id: "evt_instructions_3",
created: 2,
type: "session.instructions.updated",
durable: durable("session-1", 2, 2),
data: {
sessionID: "session-1",
delta: { "core/date": "2".repeat(64) },
text: "The current date has changed.",
},
})
await wait(() => sync.session.message.list("session-1")?.some((message) => message.time.created === 1))
await wait(() => sync.session.message.list("session-1")?.some((message) => message.time.created === 2))
expect(sync.session.message.list("session-1")).toHaveLength(1)
expect(sync.session.message.list("session-1")?.[0]).toMatchObject({
id: SessionMessage.ID.fromEvent(Event.ID.make("evt_instructions_2")),
id: SessionMessage.ID.fromEvent(Event.ID.make("evt_instructions_3")),
type: "system",
text: "Instructions updated: core/date",
time: { created: 1 },
text: "The current date has changed.",
description: "Instructions updated: core/date",
time: { created: 2 },
})
} finally {
app.renderer.destroy()
+60
View File
@@ -12795,6 +12795,63 @@
"required": ["id", "time", "type", "model"],
"additionalProperties": false
},
"Session.Message.LocationSwitched": {
"type": "object",
"properties": {
"id": {
"type": "string",
"allOf": [
{
"pattern": "^msg_"
}
]
},
"metadata": {
"type": "object"
},
"time": {
"type": "object",
"properties": {
"created": {
"type": "number"
}
},
"required": ["created"],
"additionalProperties": false
},
"type": {
"type": "string",
"enum": ["location-switched"]
},
"location": {
"$ref": "#/components/schemas/Location.Ref"
},
"projectID": {
"type": "string"
},
"subpath": {
"type": "string"
},
"previous": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.Ref"
},
"projectID": {
"type": "string"
},
"subpath": {
"type": "string"
}
},
"required": ["location"],
"additionalProperties": false
}
},
"required": ["id", "time", "type", "location"],
"additionalProperties": false
},
"Prompt.Base64": {
"type": "string",
"allOf": [
@@ -13719,6 +13776,9 @@
{
"$ref": "#/components/schemas/Session.Message.ModelSelected"
},
{
"$ref": "#/components/schemas/Session.Message.LocationSwitched"
},
{
"$ref": "#/components/schemas/Session.Message.User"
},
+60
View File
@@ -12795,6 +12795,63 @@
"required": ["id", "time", "type", "model"],
"additionalProperties": false
},
"Session.Message.LocationSwitched": {
"type": "object",
"properties": {
"id": {
"type": "string",
"allOf": [
{
"pattern": "^msg_"
}
]
},
"metadata": {
"type": "object"
},
"time": {
"type": "object",
"properties": {
"created": {
"type": "number"
}
},
"required": ["created"],
"additionalProperties": false
},
"type": {
"type": "string",
"enum": ["location-switched"]
},
"location": {
"$ref": "#/components/schemas/Location.Ref"
},
"projectID": {
"type": "string"
},
"subpath": {
"type": "string"
},
"previous": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.Ref"
},
"projectID": {
"type": "string"
},
"subpath": {
"type": "string"
}
},
"required": ["location"],
"additionalProperties": false
}
},
"required": ["id", "time", "type", "location"],
"additionalProperties": false
},
"Prompt.Base64": {
"type": "string",
"allOf": [
@@ -13719,6 +13776,9 @@
{
"$ref": "#/components/schemas/Session.Message.ModelSelected"
},
{
"$ref": "#/components/schemas/Session.Message.LocationSwitched"
},
{
"$ref": "#/components/schemas/Session.Message.User"
},