mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-12 04:29:50 -04:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32e2e3f2f7 | |||
| caae28e0d4 | |||
| c83933d1d4 | |||
| c86f1c41ff | |||
| 5c0cc8e617 | |||
| 1b45061afb | |||
| 07bcd290c2 | |||
| 04ad06e2e3 | |||
| 93965df860 |
@@ -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>
|
||||
|
||||
Vendored
+1
-1
@@ -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)
|
||||
|
||||
@@ -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")!)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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" }],
|
||||
[
|
||||
{
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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"
|
||||
|
||||
Vendored
+1
@@ -1,5 +1,6 @@
|
||||
interface ImportMetaEnv {
|
||||
readonly OPENCODE_CHANNEL: string
|
||||
readonly OPENCODE_VERSION?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
@@ -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} />)
|
||||
|
||||
@@ -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>>()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user