Compare commits

...
55 changed files with 6399 additions and 909 deletions
+19 -1
View File
@@ -9,6 +9,7 @@ import type { BunPlugin } from "bun"
import pkg from "../package.json"
import { buildAppArchive } from "./app-assets"
import { verifyArtifact, verifySimulationGraph } from "./verify-artifact"
import { resolveOpencodePty } from "./opencode-pty"
const dir = path.resolve(import.meta.dirname, "..")
const binary = "opencode2"
@@ -78,6 +79,23 @@ const appAssetsPlugin: BunPlugin = {
}
for (const item of targets) {
const opencodePty = await resolveOpencodePty({
platform: item.os,
arch: item.arch,
...(item.os === "linux" ? { libc: item.abi ?? "glibc" } : {}),
})
const opencodePtyPlugin: BunPlugin = {
name: "opencode-pty-binary",
setup(build) {
build.onLoad({ filter: /persistent-pty[/\\]asset\.ts$/ }, () => ({
loader: "js",
contents: opencodePty
? `import file from ${JSON.stringify(opencodePty.source)} with { type: "file" }
export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sha256: ${JSON.stringify(opencodePty.sha256)} }`
: "export default undefined",
}))
},
}
const simulationInputs = new Set<string>()
const simulationGraphPlugin: BunPlugin = {
name: "opencode-simulation-graph",
@@ -105,7 +123,7 @@ for (const item of targets) {
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, simulationGraphPlugin],
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, opencodePtyPlugin, simulationGraphPlugin],
external: ["node-gyp"],
format: "esm",
minify: true,
+7
View File
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"
import { getNodeAssets } from "@opentui/core/node-assets"
import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target"
import { collectFiles } from "./files"
import { resolveOpencodePty } from "./opencode-pty"
const dir = path.resolve(import.meta.dirname, "..")
@@ -18,6 +19,11 @@ export type NodeAsset = {
}
export async function collectNodeAssets(target: NodeTarget) {
const opencodePty = await resolveOpencodePty({
platform: target.platform,
arch: target.arch,
...(target.platform === "linux" ? { libc: "glibc" as const } : {}),
})
const ptyEntry = fileURLToPath(import.meta.resolve(target.nodePtyPackage))
const ptyRoot = path.resolve(path.dirname(ptyEntry), "..")
const assets: NodeAsset[] = [
@@ -41,6 +47,7 @@ export async function collectNodeAssets(target: NodeTarget) {
key,
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
})),
...(opencodePty && target.opencodePtyAsset ? [{ key: target.opencodePtyAsset, source: opencodePty.source }] : []),
...(await collectFiles(ptyRoot))
.filter((relative) => !relative.endsWith(".map") && !relative.endsWith(".pdb"))
.map((relative) => ({
+102
View File
@@ -0,0 +1,102 @@
import { spawnSync } from "node:child_process"
import { createHash } from "node:crypto"
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
const VERSION = "0.1.4"
const RELEASE = `https://github.com/jlongster/opencode-pty/releases/download/v${VERSION}`
const SHA256 = {
"aarch64-apple-darwin": "a91b790ee14a9d75d3dccf5ee40ded1326e5250d6882d5274b72e41e1455f8ec",
"aarch64-unknown-linux-gnu": "53e28264e9bad28f1f2d4900f6ad5e04d034b900ff3f341cc57be73a5152d5a6",
"aarch64-unknown-linux-musl": "00f018af1f3b2f6adf93c6d9b8866216c4266fe4e1d06e47a76bc001ae4aac46",
"x86_64-apple-darwin": "12fe4c456ad7895994e6af7d67112b4f65871f41267a7a51341e70227052d114",
"x86_64-unknown-linux-gnu": "9c03efc505ce86a6204b9bdfc03b30e2eb7d6edaca1c6435b0a839538d4c812f",
"x86_64-unknown-linux-musl": "2c5803822d9d88f8d6e201d3afd28c3a861d679e8f8c88e68c54bbfa1c12214a",
} as const
export type OpencodePtyAsset = {
readonly source: string
readonly version: string
readonly sha256: string
}
type Target = {
readonly platform: string
readonly arch: string
readonly libc?: "glibc" | "musl"
}
const pending = new Map<string, Promise<OpencodePtyAsset | undefined>>()
export function resolveOpencodePty(target: Target) {
const rustTarget = targetName(target)
if (!rustTarget) return Promise.resolve(undefined)
const existing = pending.get(rustTarget)
if (existing) return existing
const result = acquire(rustTarget).catch((error) => {
pending.delete(rustTarget)
throw error
})
pending.set(rustTarget, result)
return result
}
async function acquire(target: keyof typeof SHA256): Promise<OpencodePtyAsset> {
const root = path.resolve(import.meta.dirname, "../.cache/opencode-pty", VERSION, target)
const executable = path.join(root, "opencode-pty")
const cached = await readFile(executable).catch(() => undefined)
if (cached)
return {
source: executable,
version: VERSION,
sha256: createHash("sha256").update(cached).digest("hex"),
}
await mkdir(root, { recursive: true })
const archiveName = `opencode-pty-${VERSION}-${target}.tar.gz`
const response = await fetch(`${RELEASE}/${archiveName}`)
if (!response.ok) throw new Error(`Failed to download ${archiveName}: ${response.status}`)
const archive = new Uint8Array(await response.arrayBuffer())
const actual = createHash("sha256").update(archive).digest("hex")
if (actual !== SHA256[target]) throw new Error(`Checksum mismatch for ${archiveName}`)
const temporary = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-build-"))
try {
const archivePath = path.join(temporary, archiveName)
await writeFile(archivePath, archive)
run("tar", ["-xzf", archivePath, "-C", temporary])
const source = path.join(temporary, `opencode-pty-${VERSION}-${target}`, "opencode-pty")
const bytes = await readFile(source)
const staged = path.join(root, `opencode-pty.${process.pid}.${crypto.randomUUID()}.tmp`)
await writeFile(staged, bytes, { flag: "wx", mode: 0o755 })
await rename(staged, executable).catch(async (error) => {
await rm(staged, { force: true })
if (!(await readFile(executable).catch(() => undefined))) throw error
})
const installed = await readFile(executable)
return {
source: executable,
version: VERSION,
sha256: createHash("sha256").update(installed).digest("hex"),
}
} finally {
await rm(temporary, { recursive: true, force: true })
}
}
function targetName(target: Target): keyof typeof SHA256 | undefined {
const arch = target.arch === "arm64" ? "aarch64" : target.arch === "x64" ? "x86_64" : undefined
if (!arch) return undefined
if (target.platform === "darwin") return arch === "aarch64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"
if (target.platform === "linux" && target.libc === "musl")
return arch === "aarch64" ? "aarch64-unknown-linux-musl" : "x86_64-unknown-linux-musl"
if (target.platform === "linux") return arch === "aarch64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"
return undefined
}
function run(command: string, args: readonly string[]) {
const result = spawnSync(command, args, { stdio: "inherit" })
if (result.error) throw result.error
if (result.status !== 0) throw new Error(`${command} exited with status ${result.status ?? "unknown"}`)
}
@@ -4,11 +4,13 @@ import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { ServerConnection } from "../../../services/server-connection"
export default Runtime.handler(
Commands.commands.service.commands.restart,
Effect.fn("cli.service.restart")(function* () {
const options = yield* ServiceConfig.options()
yield* ServerConnection.shutdownPersistentPty(options).pipe(Effect.ignore)
yield* Service.stop(options)
const transport = yield* Service.ensure(options)
process.stdout.write(transport.url + EOL)
@@ -3,10 +3,13 @@ import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { ServerConnection } from "../../../services/server-connection"
export default Runtime.handler(
Commands.commands.service.commands.stop,
Effect.fn("cli.service.stop")(function* () {
yield* Service.stop(yield* ServiceConfig.options())
const options = yield* ServiceConfig.options()
yield* ServerConnection.shutdownPersistentPty(options).pipe(Effect.ignore)
yield* Service.stop(options)
}),
)
+2
View File
@@ -13,6 +13,7 @@ export function nodeTarget(platform: string, arch: string) {
const parcelWatcherPackage = `@parcel/watcher-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-glibc" : ""}`
const fffPackage = `@ff-labs/fff-bin-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : ""}`
const fffFfiPackage = `@yuuang/ffi-rs-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}`
const opencodePtyAsset = targetPlatform === "win32" ? undefined : "opencode-pty/opencode-pty"
return {
platform: targetPlatform,
@@ -25,6 +26,7 @@ export function nodeTarget(platform: string, arch: string) {
fffAsset: `${fffPackage}/${targetPlatform === "darwin" ? "libfff_c.dylib" : targetPlatform === "win32" ? "fff_c.dll" : "libfff_c.so"}`,
fffFfiPackage,
fffFfiAsset: `${fffFfiPackage}/ffi-rs.${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}.node`,
opencodePtyAsset,
}
}
@@ -56,12 +56,22 @@ function managedService(options: EnsureOptions) {
reconnect: () => Service.ensure(reconnectOptions),
restart: () =>
Effect.gen(function* () {
yield* shutdownPersistentPty(options).pipe(Effect.ignore)
yield* Service.stop(options)
yield* Service.ensure(reconnectOptions)
}),
}
}
export const shutdownPersistentPty = Effect.fn("cli.server-connection.shutdown-persistent-pty")(function* (
options: EnsureOptions,
) {
const endpoint = yield* Service.discover({ ...options, version: undefined })
if (!endpoint) return
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
yield* Effect.tryPromise(() => client["server.persistentPty"].shutdown())
})
const resolveManaged = Effect.fnUntraced(function* (options: EnsureOptions, mismatch: NonNullable<Args["mismatch"]>) {
if (mismatch === "replace") return yield* Service.ensure(options)
if (mismatch === "ignore") return yield* Service.ensure({ ...options, version: undefined })
+1
View File
@@ -8,6 +8,7 @@ test("collects each SEA asset key once", async () => {
const keys = assets.map((asset) => asset.key)
expect(new Set(keys).size).toBe(keys.length)
if (process.platform !== "win32") expect(keys.filter((key) => key === "opencode-pty/opencode-pty")).toHaveLength(1)
expect(assets.filter((asset) => asset.key === shellParserWasmAssets.runtime)).toEqual([
{
key: shellParserWasmAssets.runtime,
+4
View File
@@ -120,6 +120,7 @@ function nodePrelude(input: NodeBuildInput) {
input.target.platform === "darwin"
? `${input.target.nodePtyPackage}/prebuilds/darwin-${input.target.arch}/spawn-helper`
: undefined
const opencodePtyAsset = input.target.opencodePtyAsset
const promiseModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
export const Agent = sdk.Agent
@@ -200,6 +201,7 @@ if (__ocIsSea()) {
const __ocAssetRoot = __ocIsSea()
? __ocPath.join(__ocCacheRoot, ${JSON.stringify(`${input.assetHash}-${input.target.platform}-${input.target.arch}`)})
: __ocFileURLToPath(new URL("./assets/", import.meta.url))
const __ocPersistentPty = ${JSON.stringify(opencodePtyAsset)}
if (__ocIsSea()) {
for (const __ocKey of __ocAssetKeys()) {
const __ocTarget = __ocPath.join(__ocAssetRoot, __ocKey)
@@ -216,6 +218,7 @@ if (__ocIsSea()) {
}
const __ocPtySpawnHelper = ${JSON.stringify(nodePtySpawnHelper)}
if (__ocPtySpawnHelper) __ocChmod(__ocPath.join(__ocAssetRoot, __ocPtySpawnHelper), 0o755)
if (__ocPersistentPty && process.platform !== "win32") __ocChmod(__ocPath.join(__ocAssetRoot, __ocPersistentPty), 0o755)
}
process.env.OPENCODE_NODE_ASSETS_DIR = __ocAssetRoot
process.env.OTUI_ASSET_ROOT = __ocAssetRoot
@@ -227,6 +230,7 @@ process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH = __ocPath.join(__ocAssetRoot, $
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.powershell)})
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
if (__ocPersistentPty && !process.env.OPENCODE_PTY_BIN) process.env.OPENCODE_PTY_BIN = __ocPath.join(__ocAssetRoot, __ocPersistentPty)
try {
globalThis.__OPENCODE_FFF_FFI = require(process.env.OPENCODE_FFF_FFI_PATH)
} catch {}
+214
View File
@@ -1586,6 +1586,219 @@ export interface PtyApi<E = never> {
readonly connect: { readonly token: PtyConnectTokenOperation<E> }
}
export type ServerPersistentPtyGroupListOutput = ReadonlyArray<{
readonly id: string & Brand.Brand<"GroupID">
readonly items: ReadonlyArray<
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
>
}>
export type ServerPersistentPtyGroupListOperation<E = never> = () => Effect.Effect<
ServerPersistentPtyGroupListOutput,
E
>
export type ServerPersistentPtyGroupCreateInput = {
readonly items?:
| ReadonlyArray<
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
>
| undefined
}
export type ServerPersistentPtyGroupCreateOutput = {
readonly id: string & Brand.Brand<"GroupID">
readonly items: ReadonlyArray<
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
>
}
export type ServerPersistentPtyGroupCreateOperation<E = never> = (
input?: ServerPersistentPtyGroupCreateInput,
) => Effect.Effect<ServerPersistentPtyGroupCreateOutput, E>
export type ServerPersistentPtyGroupGetInput = { readonly groupID: string & Brand.Brand<"GroupID"> }
export type ServerPersistentPtyGroupGetOutput = {
readonly id: string & Brand.Brand<"GroupID">
readonly items: ReadonlyArray<
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
>
}
export type ServerPersistentPtyGroupGetOperation<E = never> = (
input: ServerPersistentPtyGroupGetInput,
) => Effect.Effect<ServerPersistentPtyGroupGetOutput, E>
export type ServerPersistentPtyGroupSetInput = {
readonly groupID: string & Brand.Brand<"GroupID">
readonly items: ReadonlyArray<
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
>
}
export type ServerPersistentPtyGroupSetOutput = {
readonly id: string & Brand.Brand<"GroupID">
readonly items: ReadonlyArray<
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
>
}
export type ServerPersistentPtyGroupSetOperation<E = never> = (
input: ServerPersistentPtyGroupSetInput,
) => Effect.Effect<ServerPersistentPtyGroupSetOutput, E>
export type ServerPersistentPtyGroupRemoveInput = { readonly groupID: string & Brand.Brand<"GroupID"> }
export type ServerPersistentPtyGroupRemoveOutput = void
export type ServerPersistentPtyGroupRemoveOperation<E = never> = (
input: ServerPersistentPtyGroupRemoveInput,
) => Effect.Effect<ServerPersistentPtyGroupRemoveOutput, E>
export type ServerPersistentPtyListInput = { readonly groupID: string & Brand.Brand<"GroupID"> }
export type ServerPersistentPtyListOutput = ReadonlyArray<{
readonly id: Pty.ID
readonly title: string
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly status: "running" | "exited"
readonly pid: number
readonly exitCode?: number | undefined
readonly groupID: string & Brand.Brand<"GroupID">
readonly foregroundProcess: string | null
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}>
export type ServerPersistentPtyListOperation<E = never> = (
input: ServerPersistentPtyListInput,
) => Effect.Effect<ServerPersistentPtyListOutput, E>
export type ServerPersistentPtyCreateInput = {
readonly groupID: string & Brand.Brand<"GroupID">
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly title: string
readonly env: { readonly [x: string]: string }
readonly size?: { readonly cols: number; readonly rows: number } | undefined
}
export type ServerPersistentPtyCreateOutput = {
readonly id: Pty.ID
readonly title: string
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly status: "running" | "exited"
readonly pid: number
readonly exitCode?: number | undefined
readonly groupID: string & Brand.Brand<"GroupID">
readonly foregroundProcess: string | null
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
export type ServerPersistentPtyCreateOperation<E = never> = (
input: ServerPersistentPtyCreateInput,
) => Effect.Effect<ServerPersistentPtyCreateOutput, E>
export type ServerPersistentPtyShutdownOutput = void
export type ServerPersistentPtyShutdownOperation<E = never> = () => Effect.Effect<ServerPersistentPtyShutdownOutput, E>
export type ServerPersistentPtyGetInput = { readonly ptyID: Pty.ID }
export type ServerPersistentPtyGetOutput = {
readonly id: Pty.ID
readonly title: string
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly status: "running" | "exited"
readonly pid: number
readonly exitCode?: number | undefined
readonly groupID: string & Brand.Brand<"GroupID">
readonly foregroundProcess: string | null
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
export type ServerPersistentPtyGetOperation<E = never> = (
input: ServerPersistentPtyGetInput,
) => Effect.Effect<ServerPersistentPtyGetOutput, E>
export type ServerPersistentPtyUpdateInput = {
readonly ptyID: Pty.ID
readonly attachmentID?: string | undefined
readonly size: { readonly cols: number; readonly rows: number }
}
export type ServerPersistentPtyUpdateOutput = {
readonly id: Pty.ID
readonly title: string
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly status: "running" | "exited"
readonly pid: number
readonly exitCode?: number | undefined
readonly groupID: string & Brand.Brand<"GroupID">
readonly foregroundProcess: string | null
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
export type ServerPersistentPtyUpdateOperation<E = never> = (
input: ServerPersistentPtyUpdateInput,
) => Effect.Effect<ServerPersistentPtyUpdateOutput, E>
export type ServerPersistentPtySnapshotInput = { readonly ptyID: Pty.ID }
export type ServerPersistentPtySnapshotOutput = {
readonly info: {
readonly id: Pty.ID
readonly title: string
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly status: "running" | "exited"
readonly pid: number
readonly exitCode?: number | undefined
readonly groupID: string & Brand.Brand<"GroupID">
readonly foregroundProcess: string | null
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
readonly text: string
readonly checkpoint: globalThis.Uint8Array
readonly cursor: { readonly x: number; readonly y: number }
}
export type ServerPersistentPtySnapshotOperation<E = never> = (
input: ServerPersistentPtySnapshotInput,
) => Effect.Effect<ServerPersistentPtySnapshotOutput, E>
export type ServerPersistentPtyRemoveInput = { readonly ptyID: Pty.ID }
export type ServerPersistentPtyRemoveOutput = void
export type ServerPersistentPtyRemoveOperation<E = never> = (
input: ServerPersistentPtyRemoveInput,
) => Effect.Effect<ServerPersistentPtyRemoveOutput, E>
export type ServerPersistentPtyConnectTokenInput = { readonly ptyID: Pty.ID }
export type ServerPersistentPtyConnectTokenOutput = PtyTicket.ConnectToken
export type ServerPersistentPtyConnectTokenOperation<E = never> = (
input: ServerPersistentPtyConnectTokenInput,
) => Effect.Effect<ServerPersistentPtyConnectTokenOutput, E>
export type ServerPersistentPtyConnectInput = { readonly ptyID: Pty.ID }
export type ServerPersistentPtyConnectOutput = boolean
export type ServerPersistentPtyConnectOperation<E = never> = (
input: ServerPersistentPtyConnectInput,
) => Effect.Effect<ServerPersistentPtyConnectOutput, E>
export interface ServerPersistentPtyApi<E = never> {
readonly group: {
readonly list: ServerPersistentPtyGroupListOperation<E>
readonly create: ServerPersistentPtyGroupCreateOperation<E>
readonly get: ServerPersistentPtyGroupGetOperation<E>
readonly set: ServerPersistentPtyGroupSetOperation<E>
readonly remove: ServerPersistentPtyGroupRemoveOperation<E>
}
readonly list: ServerPersistentPtyListOperation<E>
readonly create: ServerPersistentPtyCreateOperation<E>
readonly shutdown: ServerPersistentPtyShutdownOperation<E>
readonly get: ServerPersistentPtyGetOperation<E>
readonly update: ServerPersistentPtyUpdateOperation<E>
readonly snapshot: ServerPersistentPtySnapshotOperation<E>
readonly remove: ServerPersistentPtyRemoveOperation<E>
readonly connectToken: ServerPersistentPtyConnectTokenOperation<E>
readonly connect: ServerPersistentPtyConnectOperation<E>
}
export type ShellListInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
@@ -1819,6 +2032,7 @@ export interface AppApi<E = never> {
readonly skill: SkillApi<E>
readonly event: EventApi<E>
readonly pty: PtyApi<E>
readonly "server.persistentPty": ServerPersistentPtyApi<E>
readonly shell: ShellApi<E>
readonly reference: ReferenceApi<E>
readonly worktree: WorktreeApi<E>
@@ -192,6 +192,32 @@ import type {
PtyRemoveOutput,
PtyConnectTokenInput,
PtyConnectTokenOutput,
ServerPersistentPtyGroupListOutput,
ServerPersistentPtyGroupCreateInput,
ServerPersistentPtyGroupCreateOutput,
ServerPersistentPtyGroupGetInput,
ServerPersistentPtyGroupGetOutput,
ServerPersistentPtyGroupSetInput,
ServerPersistentPtyGroupSetOutput,
ServerPersistentPtyGroupRemoveInput,
ServerPersistentPtyGroupRemoveOutput,
ServerPersistentPtyListInput,
ServerPersistentPtyListOutput,
ServerPersistentPtyCreateInput,
ServerPersistentPtyCreateOutput,
ServerPersistentPtyShutdownOutput,
ServerPersistentPtyGetInput,
ServerPersistentPtyGetOutput,
ServerPersistentPtyUpdateInput,
ServerPersistentPtyUpdateOutput,
ServerPersistentPtySnapshotInput,
ServerPersistentPtySnapshotOutput,
ServerPersistentPtyRemoveInput,
ServerPersistentPtyRemoveOutput,
ServerPersistentPtyConnectTokenInput,
ServerPersistentPtyConnectTokenOutput,
ServerPersistentPtyConnectInput,
ServerPersistentPtyConnectOutput,
ShellListInput,
ShellListOutput,
ShellCreateInput,
@@ -1180,6 +1206,155 @@ const adaptGroupPty = (raw: RawClient["server.pty"]) => ({
connect: { token: EndpointPtyConnectToken(raw) },
})
const EndpointServerPersistentPtyGroupList = (raw: RawClient["server.persistentPty"]) => () =>
preserveEffect<ServerPersistentPtyGroupListOutput>()(
raw["persistentPty.group.list"]({}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointServerPersistentPtyGroupCreate =
(raw: RawClient["server.persistentPty"]) => (input?: ServerPersistentPtyGroupCreateInput) =>
preserveEffect<ServerPersistentPtyGroupCreateOutput>()(
raw["persistentPty.group.create"]({ payload: { items: input?.["items"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointServerPersistentPtyGroupGet =
(raw: RawClient["server.persistentPty"]) => (input: ServerPersistentPtyGroupGetInput) =>
preserveEffect<ServerPersistentPtyGroupGetOutput>()(
raw["persistentPty.group.get"]({ params: { groupID: input["groupID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointServerPersistentPtyGroupSet =
(raw: RawClient["server.persistentPty"]) => (input: ServerPersistentPtyGroupSetInput) =>
preserveEffect<ServerPersistentPtyGroupSetOutput>()(
raw["persistentPty.group.set"]({
params: { groupID: input["groupID"] },
payload: { items: input["items"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointServerPersistentPtyGroupRemove =
(raw: RawClient["server.persistentPty"]) => (input: ServerPersistentPtyGroupRemoveInput) =>
preserveEffect<ServerPersistentPtyGroupRemoveOutput>()(
raw["persistentPty.group.remove"]({ params: { groupID: input["groupID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointServerPersistentPtyList =
(raw: RawClient["server.persistentPty"]) => (input: ServerPersistentPtyListInput) =>
preserveEffect<ServerPersistentPtyListOutput>()(
raw["persistentPty.list"]({ params: { groupID: input["groupID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointServerPersistentPtyCreate =
(raw: RawClient["server.persistentPty"]) => (input: ServerPersistentPtyCreateInput) =>
preserveEffect<ServerPersistentPtyCreateOutput>()(
raw["persistentPty.create"]({
params: { groupID: input["groupID"] },
payload: {
command: input["command"],
args: input["args"],
cwd: input["cwd"],
title: input["title"],
env: input["env"],
size: input["size"],
},
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointServerPersistentPtyShutdown = (raw: RawClient["server.persistentPty"]) => () =>
preserveEffect<ServerPersistentPtyShutdownOutput>()(
raw["persistentPty.shutdown"]({}).pipe(Effect.mapError(mapClientError)),
)
const EndpointServerPersistentPtyGet =
(raw: RawClient["server.persistentPty"]) => (input: ServerPersistentPtyGetInput) =>
preserveEffect<ServerPersistentPtyGetOutput>()(
raw["persistentPty.get"]({ params: { ptyID: input["ptyID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointServerPersistentPtyUpdate =
(raw: RawClient["server.persistentPty"]) => (input: ServerPersistentPtyUpdateInput) =>
preserveEffect<ServerPersistentPtyUpdateOutput>()(
raw["persistentPty.update"]({
params: { ptyID: input["ptyID"] },
payload: { attachmentID: input["attachmentID"], size: input["size"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointServerPersistentPtySnapshot =
(raw: RawClient["server.persistentPty"]) => (input: ServerPersistentPtySnapshotInput) =>
preserveEffect<ServerPersistentPtySnapshotOutput>()(
raw["persistentPty.snapshot"]({ params: { ptyID: input["ptyID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointServerPersistentPtyRemove =
(raw: RawClient["server.persistentPty"]) => (input: ServerPersistentPtyRemoveInput) =>
preserveEffect<ServerPersistentPtyRemoveOutput>()(
raw["persistentPty.remove"]({ params: { ptyID: input["ptyID"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointServerPersistentPtyConnectToken =
(raw: RawClient["server.persistentPty"]) => (input: ServerPersistentPtyConnectTokenInput) =>
preserveEffect<ServerPersistentPtyConnectTokenOutput>()(
raw["persistentPty.connectToken"]({ params: { ptyID: input["ptyID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointServerPersistentPtyConnect =
(raw: RawClient["server.persistentPty"]) => (input: ServerPersistentPtyConnectInput) =>
preserveEffect<ServerPersistentPtyConnectOutput>()(
raw["persistentPty.connect"]({ params: { ptyID: input["ptyID"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupServerPersistentPty = (raw: RawClient["server.persistentPty"]) => ({
group: {
list: EndpointServerPersistentPtyGroupList(raw),
create: EndpointServerPersistentPtyGroupCreate(raw),
get: EndpointServerPersistentPtyGroupGet(raw),
set: EndpointServerPersistentPtyGroupSet(raw),
remove: EndpointServerPersistentPtyGroupRemove(raw),
},
list: EndpointServerPersistentPtyList(raw),
create: EndpointServerPersistentPtyCreate(raw),
shutdown: EndpointServerPersistentPtyShutdown(raw),
get: EndpointServerPersistentPtyGet(raw),
update: EndpointServerPersistentPtyUpdate(raw),
snapshot: EndpointServerPersistentPtySnapshot(raw),
remove: EndpointServerPersistentPtyRemove(raw),
connectToken: EndpointServerPersistentPtyConnectToken(raw),
connect: EndpointServerPersistentPtyConnect(raw),
})
const EndpointShellList = (raw: RawClient["server.shell"]) => (input?: ShellListInput) =>
preserveEffect<ShellListOutput>()(
raw["shell.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
@@ -1367,6 +1542,7 @@ const adaptClient = (raw: RawClient) => ({
skill: adaptGroupSkill(raw["server.skill"]),
event: adaptGroupEvent(raw["server.event"]),
pty: adaptGroupPty(raw["server.pty"]),
"server.persistentPty": adaptGroupServerPersistentPty(raw["server.persistentPty"]),
shell: adaptGroupShell(raw["server.shell"]),
reference: adaptGroupReference(raw["server.reference"]),
worktree: adaptGroupWorktree(raw["server.worktree"]),
@@ -188,6 +188,32 @@ import type {
PtyRemoveOutput,
PtyConnectTokenInput,
PtyConnectTokenOutput,
ServerPersistentPtyGroupListOutput,
ServerPersistentPtyGroupCreateInput,
ServerPersistentPtyGroupCreateOutput,
ServerPersistentPtyGroupGetInput,
ServerPersistentPtyGroupGetOutput,
ServerPersistentPtyGroupSetInput,
ServerPersistentPtyGroupSetOutput,
ServerPersistentPtyGroupRemoveInput,
ServerPersistentPtyGroupRemoveOutput,
ServerPersistentPtyListInput,
ServerPersistentPtyListOutput,
ServerPersistentPtyCreateInput,
ServerPersistentPtyCreateOutput,
ServerPersistentPtyShutdownOutput,
ServerPersistentPtyGetInput,
ServerPersistentPtyGetOutput,
ServerPersistentPtyUpdateInput,
ServerPersistentPtyUpdateOutput,
ServerPersistentPtySnapshotInput,
ServerPersistentPtySnapshotOutput,
ServerPersistentPtyRemoveInput,
ServerPersistentPtyRemoveOutput,
ServerPersistentPtyConnectTokenInput,
ServerPersistentPtyConnectTokenOutput,
ServerPersistentPtyConnectInput,
ServerPersistentPtyConnectOutput,
ShellListInput,
ShellListOutput,
ShellCreateInput,
@@ -1620,6 +1646,175 @@ export function make(options: ClientOptions) {
),
},
},
"server.persistentPty": {
group: {
list: (requestOptions?: RequestOptions) =>
request<{ readonly data: ServerPersistentPtyGroupListOutput }>(
{
method: "GET",
path: `/api/pty-group`,
successStatus: 200,
declaredStatuses: [400, 503, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
create: (input?: ServerPersistentPtyGroupCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ServerPersistentPtyGroupCreateOutput }>(
{
method: "POST",
path: `/api/pty-group`,
body: { items: input?.["items"] },
successStatus: 200,
declaredStatuses: [400, 503, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
get: (input: ServerPersistentPtyGroupGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ServerPersistentPtyGroupGetOutput }>(
{
method: "GET",
path: `/api/pty-group/${encodeURIComponent(input.groupID)}`,
successStatus: 200,
declaredStatuses: [400, 503, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
set: (input: ServerPersistentPtyGroupSetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ServerPersistentPtyGroupSetOutput }>(
{
method: "PUT",
path: `/api/pty-group/${encodeURIComponent(input.groupID)}`,
body: { items: input["items"] },
successStatus: 200,
declaredStatuses: [400, 503, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
remove: (input: ServerPersistentPtyGroupRemoveInput, requestOptions?: RequestOptions) =>
request<ServerPersistentPtyGroupRemoveOutput>(
{
method: "DELETE",
path: `/api/pty-group/${encodeURIComponent(input.groupID)}`,
successStatus: 204,
declaredStatuses: [400, 503, 401],
empty: true,
},
requestOptions,
),
},
list: (input: ServerPersistentPtyListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ServerPersistentPtyListOutput }>(
{
method: "GET",
path: `/api/pty-group/${encodeURIComponent(input.groupID)}/terminal`,
successStatus: 200,
declaredStatuses: [400, 503, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
create: (input: ServerPersistentPtyCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ServerPersistentPtyCreateOutput }>(
{
method: "POST",
path: `/api/pty-group/${encodeURIComponent(input.groupID)}/terminal`,
body: {
command: input["command"],
args: input["args"],
cwd: input["cwd"],
title: input["title"],
env: input["env"],
size: input["size"],
},
successStatus: 200,
declaredStatuses: [400, 503, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
shutdown: (requestOptions?: RequestOptions) =>
request<ServerPersistentPtyShutdownOutput>(
{
method: "POST",
path: `/api/persistent-pty/shutdown`,
successStatus: 204,
declaredStatuses: [503, 401, 400],
empty: true,
},
requestOptions,
),
get: (input: ServerPersistentPtyGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ServerPersistentPtyGetOutput }>(
{
method: "GET",
path: `/api/persistent-pty/${encodeURIComponent(input.ptyID)}`,
successStatus: 200,
declaredStatuses: [404, 503, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
update: (input: ServerPersistentPtyUpdateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ServerPersistentPtyUpdateOutput }>(
{
method: "PUT",
path: `/api/persistent-pty/${encodeURIComponent(input.ptyID)}`,
body: { attachmentID: input["attachmentID"], size: input["size"] },
successStatus: 200,
declaredStatuses: [404, 503, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
snapshot: (input: ServerPersistentPtySnapshotInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ServerPersistentPtySnapshotOutput }>(
{
method: "GET",
path: `/api/persistent-pty/${encodeURIComponent(input.ptyID)}/snapshot`,
successStatus: 200,
declaredStatuses: [404, 503, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
remove: (input: ServerPersistentPtyRemoveInput, requestOptions?: RequestOptions) =>
request<ServerPersistentPtyRemoveOutput>(
{
method: "DELETE",
path: `/api/persistent-pty/${encodeURIComponent(input.ptyID)}`,
successStatus: 204,
declaredStatuses: [404, 503, 401, 400],
empty: true,
},
requestOptions,
),
connectToken: (input: ServerPersistentPtyConnectTokenInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ServerPersistentPtyConnectTokenOutput }>(
{
method: "POST",
path: `/api/persistent-pty/${encodeURIComponent(input.ptyID)}/connect-token`,
successStatus: 200,
declaredStatuses: [403, 404, 503, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
connect: (input: ServerPersistentPtyConnectInput, requestOptions?: RequestOptions) =>
request<ServerPersistentPtyConnectOutput>(
{
method: "GET",
path: `/api/persistent-pty/${encodeURIComponent(input.ptyID)}/connect`,
successStatus: 200,
declaredStatuses: [403, 404, 503, 401, 400],
empty: false,
},
requestOptions,
),
},
shell: {
list: (input?: ShellListInput, requestOptions?: RequestOptions) =>
request<ShellListOutput>(
File diff suppressed because it is too large Load Diff
+6
View File
@@ -41,6 +41,12 @@
"node": "./src/pty/pty.node.ts",
"default": "./src/pty/pty.bun.ts"
},
"#persistent-pty-binary": {
"workerd": "./src/persistent-pty/binary.workerd.ts",
"bun": "./src/persistent-pty/binary.bun.ts",
"node": "./src/persistent-pty/binary.node.ts",
"default": "./src/persistent-pty/binary.bun.ts"
},
"#fff": {
"workerd": "./src/filesystem/fff.workerd.ts",
"bun": "./src/filesystem/fff.bun.ts",
+2
View File
@@ -0,0 +1,2 @@
export { PersistentPty } from "./persistent-pty/index.js"
export { Group } from "./persistent-pty/group.js"
@@ -0,0 +1,3 @@
const asset: { readonly path: string; readonly version: string; readonly sha256: string } | undefined = undefined
export default asset
@@ -0,0 +1,82 @@
import { createHash } from "node:crypto"
import { chmod, lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises"
import path from "node:path"
import asset from "./asset.js"
export async function resolveBinary(bin: string) {
if (process.env.OPENCODE_PTY_BIN) return process.env.OPENCODE_PTY_BIN
if (!asset) return "opencode-pty"
return install(bin, asset)
}
export async function install(
bin: string,
input: { readonly path: string; readonly version: string; readonly sha256: string },
) {
const root = path.join(bin, "opencode-pty")
await privateDirectory(root)
const directory = path.join(root, `${input.version}-${input.sha256.slice(0, 16)}`)
await privateDirectory(directory)
const destination = path.join(directory, "opencode-pty")
if (await exists(destination, input.sha256)) return destination
const bytes = new Uint8Array(await Bun.file(input.path).arrayBuffer())
if (sha256(bytes) !== input.sha256) throw new Error("Embedded opencode-pty checksum mismatch")
const temporary = path.join(directory, `opencode-pty.${process.pid}.${crypto.randomUUID()}.tmp`)
try {
const file = await open(temporary, "wx", 0o700)
try {
await file.writeFile(bytes)
await file.sync()
} finally {
await file.close()
}
await chmod(temporary, 0o755)
await rename(temporary, destination).catch(async (error) => {
if (!(await exists(destination, input.sha256))) throw error
})
} finally {
await rm(temporary, { force: true })
}
return validate(destination, input.sha256)
}
async function privateDirectory(directory: string) {
await mkdir(directory, { recursive: true, mode: 0o700 })
const info = await lstat(directory)
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Unsafe opencode-pty directory: ${directory}`)
const uid = typeof process.getuid === "function" ? process.getuid() : undefined
if (uid !== undefined && info.uid !== uid)
throw new Error(`opencode-pty directory is owned by another user: ${directory}`)
await chmod(directory, 0o700)
}
async function exists(file: string, expected: string) {
try {
await validate(file, expected)
return true
} catch (error) {
if (isMissing(error)) return false
throw error
}
}
async function validate(file: string, expected?: string) {
const info = await lstat(file)
if (!info.isFile() || info.isSymbolicLink()) throw new Error(`Unsafe opencode-pty executable: ${file}`)
const uid = typeof process.getuid === "function" ? process.getuid() : undefined
if (uid !== undefined && info.uid !== uid)
throw new Error(`opencode-pty executable is owned by another user: ${file}`)
if (expected && sha256(await readFile(file)) !== expected)
throw new Error(`Cached opencode-pty checksum mismatch: ${file}`)
await chmod(file, 0o755)
return file
}
function sha256(bytes: Uint8Array) {
return createHash("sha256").update(bytes).digest("hex")
}
function isMissing(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && error.code === "ENOENT"
}
@@ -0,0 +1,3 @@
export async function resolveBinary() {
return process.env.OPENCODE_PTY_BIN || "opencode-pty"
}
@@ -0,0 +1,3 @@
export async function resolveBinary(): Promise<string> {
throw new Error("Persistent PTYs are unavailable in this runtime")
}
+103
View File
@@ -0,0 +1,103 @@
export * as Group from "./group.js"
import { Group } from "@opencode-ai/schema/group"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Schema, Semaphore } from "effect"
import { Bus } from "../bus.js"
import { KV } from "../kv.js"
export const ID = Group.ID
export type ID = Group.ID
export const Item = Group.Item
export type Item = Group.Item
export const Info = Group.Info
export type Info = Group.Info
export const Event = Group.Event
export interface Interface {
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
readonly get: (id: ID) => Effect.Effect<Info | undefined>
readonly create: (items?: ReadonlyArray<Item>) => Effect.Effect<Info>
readonly set: (group: Info) => Effect.Effect<void>
readonly remove: (id: ID) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Group") {}
const key = "group:v1"
const Document = Schema.Array(Info)
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const kv = yield* KV.Service
const bus = yield* Bus.Service
const lock = Semaphore.makeUnsafe(1)
const list = Effect.fn("Group.list")(function* () {
const value = yield* kv.get(key)
return Schema.is(Document)(value) ? value : []
})
return Service.of({
list,
get: Effect.fn("Group.get")(function* (id) {
return (yield* list()).find((group) => group.id === id)
}),
create: Effect.fn("Group.create")(function* (items = []) {
return yield* lock.withPermit(
Effect.gen(function* () {
const group = Info.make({ id: ID.create(), items: Array.from(items) })
yield* kv.set(key, (yield* list()).concat(group))
return group
}),
)
}),
set: Effect.fn("Group.set")(function* (group) {
yield* lock.withPermit(
Effect.gen(function* () {
const groups = yield* list()
const index = groups.findIndex((item) => item.id === group.id)
yield* kv.set(
key,
index === -1 ? groups.concat(group) : groups.map((item) => (item.id === group.id ? group : item)),
)
const previous = groups[index]
if (!previous) return
yield* Effect.forEach(
group.items.filter(
(item) => !previous.items.some((current) => current.type === item.type && current.id === item.id),
),
(item) => bus.publish(Event.ItemAdded, { groupID: group.id, item }),
{ discard: true },
)
yield* Effect.forEach(
previous.items.filter(
(item) => !group.items.some((next) => next.type === item.type && next.id === item.id),
),
(item) => bus.publish(Event.ItemRemoved, { groupID: group.id, item }),
{ discard: true },
)
}),
)
}),
remove: Effect.fn("Group.remove")(function* (id) {
yield* lock.withPermit(
Effect.gen(function* () {
const groups = yield* list()
const group = groups.find((group) => group.id === id)
yield* kv.set(key, groups.filter((group) => group.id !== id))
if (!group) return
yield* Effect.forEach(
group.items,
(item) => bus.publish(Event.ItemRemoved, { groupID: id, item }),
{ discard: true },
)
}),
)
}),
})
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [KV.node, Bus.node] })
+754
View File
@@ -0,0 +1,754 @@
export * as PersistentPty from "./index.js"
import { spawn } from "node:child_process"
import { createHash } from "node:crypto"
import { readFile } from "node:fs/promises"
import net from "node:net"
import os from "node:os"
import path from "node:path"
import { setTimeout } from "node:timers/promises"
import { Context, Effect, Layer, Schema } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Group } from "./group.js"
import { Database } from "../database/database.js"
import { Pty } from "@opencode-ai/schema/pty"
import { Global } from "@opencode-ai/util/global"
import { resolveBinary } from "#persistent-pty-binary"
const ProtocolVersion = 6
const MaxFrameBytes = 8 * 1024 * 1024
const Lifecycle = Schema.Union([
Schema.Struct({ status: Schema.Literal("running") }),
Schema.Struct({ status: Schema.Literal("exited"), exit_code: Schema.NullOr(Schema.Number) }),
Schema.Struct({ status: Schema.Literal("failed"), message: Schema.String }),
])
const WireTerminal = Schema.Struct({
id: Schema.Number,
pid: Schema.NullOr(Schema.Number),
title: Schema.String,
foreground_process: Schema.NullOr(Schema.String),
group_id: Schema.String,
command: Schema.Array(Schema.String),
cwd: Schema.String,
cols: Schema.Number,
rows: Schema.Number,
lifecycle: Lifecycle,
output_head: Schema.Number,
output_tail: Schema.Number,
})
const Registration = Schema.Struct({
instance_id: Schema.String,
pid: Schema.Number,
protocol: Schema.Number,
socket: Schema.String,
token: Schema.String,
})
const Response = Schema.Union([
Schema.Struct({
type: Schema.Literal("pong"),
instance_id: Schema.String,
pid: Schema.Number,
protocol: Schema.Number,
}),
Schema.Struct({ type: Schema.Literal("created"), terminal: WireTerminal }),
Schema.Struct({ type: Schema.Literal("terminals"), terminals: Schema.Array(WireTerminal) }),
Schema.Struct({ type: Schema.Literal("ok") }),
Schema.Struct({
type: Schema.Literal("snapshot"),
terminal: WireTerminal,
text: Schema.String,
checkpoint_base64: Schema.String,
cursor_x: Schema.Number,
cursor_y: Schema.Number,
}),
Schema.Struct({
type: Schema.Literal("attached"),
terminal: WireTerminal,
role: Schema.Literals(["controller", "observer"]),
generation: Schema.Number,
requested_offset: Schema.Number,
available_offset: Schema.Number,
end_offset: Schema.Number,
truncated: Schema.Boolean,
replay_base64: Schema.String,
}),
Schema.Struct({
type: Schema.Literal("resized"),
cols: Schema.Number,
rows: Schema.Number,
generation: Schema.Number,
checkpoint_base64: Schema.String,
}),
Schema.Struct({
type: Schema.Literal("exited"),
exit_code: Schema.NullOr(Schema.Number),
final_offset: Schema.Number,
}),
Schema.Struct({
type: Schema.Literal("controller_changed"),
attachment_id: Schema.NullOr(Schema.String),
generation: Schema.Number,
}),
Schema.Struct({ type: Schema.Literal("title_changed"), title: Schema.String }),
Schema.Struct({ type: Schema.Literal("foreground_process_changed"), process: Schema.NullOr(Schema.String) }),
Schema.Struct({ type: Schema.Literal("error"), message: Schema.String }),
])
type WireTerminal = typeof WireTerminal.Type
type WireResponse = typeof Response.Type
type Registration = typeof Registration.Type
export type Role = "controller" | "observer"
export type Info = Pty.Info & {
readonly groupID: Group.ID
readonly foregroundProcess: string | null
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
export type Snapshot = {
readonly info: Info
readonly text: string
readonly checkpoint: Uint8Array
readonly cursor: { readonly x: number; readonly y: number }
}
export type StreamEvent =
| { readonly type: "output"; readonly start: number; readonly end: number; readonly data: Uint8Array }
| {
readonly type: "resized"
readonly cols: number
readonly rows: number
readonly generation: number
readonly checkpoint: Uint8Array
}
| { readonly type: "exited"; readonly exitCode?: number; readonly finalOffset: number }
| { readonly type: "controller_changed"; readonly attachmentID?: string; readonly generation: number }
| { readonly type: "title_changed"; readonly title: string }
| { readonly type: "foreground_process_changed"; readonly process: string | null }
export type Attachment = {
readonly info: Info
readonly role: Role
readonly generation: number
readonly replay: {
readonly requestedOffset: number
readonly availableOffset: number
readonly endOffset: number
readonly truncated: boolean
readonly data: Uint8Array
}
readonly activate: () => void
readonly detach: () => void
}
export class UnavailableError extends Schema.TaggedError<UnavailableError>()("PersistentPty.UnavailableError", {
message: Schema.String,
}) {}
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("PersistentPty.NotFoundError", {
ptyID: Pty.ID,
}) {}
export class GroupNotFoundError extends Schema.TaggedError<GroupNotFoundError>()(
"PersistentPty.GroupNotFoundError",
{ groupID: Group.ID },
) {}
export interface Interface {
readonly list: (groupID?: Group.ID) => Effect.Effect<Info[], UnavailableError>
readonly get: (id: Pty.ID) => Effect.Effect<Info, NotFoundError | UnavailableError>
readonly create: (
groupID: Group.ID,
input: {
readonly command: string
readonly args: readonly string[]
readonly cwd: string
readonly title: string
readonly env: Readonly<Record<string, string>>
readonly cols?: number
readonly rows?: number
},
) => Effect.Effect<Info, GroupNotFoundError | UnavailableError>
readonly write: (
id: Pty.ID,
data: string,
attachmentID?: string,
) => Effect.Effect<void, NotFoundError | UnavailableError>
readonly resize: (
id: Pty.ID,
cols: number,
rows: number,
attachmentID?: string,
) => Effect.Effect<void, NotFoundError | UnavailableError>
readonly control: (
id: Pty.ID,
attachmentID: string,
cols: number,
rows: number,
) => Effect.Effect<void, NotFoundError | UnavailableError>
readonly input: (
id: Pty.ID,
attachmentID: string,
cols: number,
rows: number,
data: Uint8Array,
) => Effect.Effect<void, NotFoundError | UnavailableError>
readonly snapshot: (id: Pty.ID) => Effect.Effect<Snapshot, NotFoundError | UnavailableError>
readonly remove: (id: Pty.ID) => Effect.Effect<void, NotFoundError | UnavailableError>
readonly shutdown: () => Effect.Effect<void, UnavailableError>
readonly attach: (
id: Pty.ID,
input: {
readonly cursor: number
readonly attachmentID: string
readonly role: Role
readonly takeover?: boolean
readonly onEvent: (event: StreamEvent) => void
readonly onEnd: () => void
},
) => Effect.Effect<Attachment, NotFoundError | UnavailableError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PersistentPty") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const groups = yield* Group.Service
const database = yield* Database.Service
const global = yield* Global.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
let binary: Promise<string> | undefined
const client = new Client(runtimeDirectory(databasePath(database.db)), () => (binary ??= resolveBinary(global.bin)))
const removing = new Set<Pty.ID>()
const list = Effect.fn("PersistentPty.list")(function* (groupID?: Group.ID) {
const response = yield* optionalRequest(client, { op: "list" })
if (!response) return []
if (response.type !== "terminals") return yield* unexpected(response)
return response.terminals
.map(toInfo)
.filter((terminal) => groupID === undefined || terminal.groupID === groupID)
})
const get = Effect.fn("PersistentPty.get")(function* (id: Pty.ID) {
const found = (yield* list()).find((terminal) => terminal.id === id)
if (!found) return yield* new NotFoundError({ ptyID: id })
return found
})
const create = Effect.fn("PersistentPty.create")(function* (
groupID: Group.ID,
input: {
readonly command: string
readonly args: readonly string[]
readonly cwd: string
readonly title: string
readonly env: Readonly<Record<string, string>>
readonly cols?: number
readonly rows?: number
},
) {
const group = yield* groups.get(groupID)
if (!group) return yield* new GroupNotFoundError({ groupID })
const response = yield* request(client, {
op: "create",
program: input.command,
args: input.args,
cwd: input.cwd,
title: input.title,
group_id: groupID,
env: input.env,
cols: input.cols ?? 80,
rows: input.rows ?? 24,
}, true)
if (response.type !== "created") return yield* unexpected(response)
const terminal = toInfo(response.terminal)
yield* groups.set(
Group.Info.make({
id: group.id,
items: group.items.concat({ type: "terminal", id: terminal.id }),
}),
)
return terminal
})
const write = Effect.fn("PersistentPty.write")(function* (
id: Pty.ID,
data: string,
attachmentID?: string,
) {
yield* get(id)
const response = yield* request(client, {
op: "write",
id: fromID(id),
attachment_id: attachmentID ?? null,
data_base64: Buffer.from(data).toString("base64"),
})
if (response.type !== "ok") return yield* unexpected(response)
return undefined
})
const resize = Effect.fn("PersistentPty.resize")(function* (
id: Pty.ID,
cols: number,
rows: number,
attachmentID?: string,
) {
yield* get(id)
const response = yield* request(client, {
op: "resize",
id: fromID(id),
attachment_id: attachmentID ?? null,
cols,
rows,
})
if (response.type !== "ok") return yield* unexpected(response)
return undefined
})
const control = Effect.fn("PersistentPty.control")(function* (
id: Pty.ID,
attachmentID: string,
cols: number,
rows: number,
) {
yield* get(id)
const response = yield* request(client, {
op: "control",
id: fromID(id),
attachment_id: attachmentID,
cols,
rows,
})
if (response.type !== "ok") return yield* unexpected(response)
return undefined
})
const input = Effect.fn("PersistentPty.input")(function* (
id: Pty.ID,
attachmentID: string,
cols: number,
rows: number,
data: Uint8Array,
) {
yield* get(id)
const response = yield* request(client, {
op: "input",
id: fromID(id),
attachment_id: attachmentID,
cols,
rows,
data_base64: Buffer.from(data).toString("base64"),
})
if (response.type !== "ok") return yield* unexpected(response)
return undefined
})
const snapshot = Effect.fn("PersistentPty.snapshot")(function* (id: Pty.ID) {
yield* get(id)
const response = yield* request(client, { op: "snapshot", id: fromID(id) })
if (response.type !== "snapshot") return yield* unexpected(response)
return {
info: toInfo(response.terminal),
text: response.text,
checkpoint: Buffer.from(response.checkpoint_base64, "base64"),
cursor: { x: response.cursor_x, y: response.cursor_y },
}
})
const remove = Effect.fn("PersistentPty.remove")(function* (id: Pty.ID) {
const terminal = yield* get(id)
const response = yield* request(client, { op: "terminate", id: fromID(id) })
if (response.type !== "ok") return yield* unexpected(response)
const group = yield* groups.get(terminal.groupID)
if (!group) return undefined
yield* groups.set(
Group.Info.make({
id: group.id,
items: group.items.filter((item) => item.type !== "terminal" || item.id !== id),
}),
)
return undefined
})
const shutdown = Effect.fn("PersistentPty.shutdown")(function* () {
const response = yield* Effect.tryPromise({ try: () => client.shutdown(), catch: unavailable })
if (!response) return
if (response.type !== "ok") return yield* unexpected(response)
})
const removeVisibleExit = (id: Pty.ID) => {
if (removing.has(id)) return
removing.add(id)
runFork(
remove(id).pipe(
Effect.catchTags({
"PersistentPty.NotFoundError": () => Effect.void,
"PersistentPty.UnavailableError": (error) =>
Effect.logWarning("failed to remove visible exited terminal", { id, error: error.message }),
}),
Effect.ensuring(Effect.sync(() => removing.delete(id))),
),
)
}
const attach = Effect.fn("PersistentPty.attach")(function* (
id: Pty.ID,
input: {
readonly cursor: number
readonly attachmentID: string
readonly role: Role
readonly takeover?: boolean
readonly onEvent: (event: StreamEvent) => void
readonly onEnd: () => void
},
) {
yield* get(id)
return yield* Effect.tryPromise({
try: () =>
client.subscribe(fromID(id), {
...input,
onEvent: (event) => {
if (event.type === "exited") removeVisibleExit(id)
input.onEvent(event)
},
}),
catch: (error) => unavailable(error),
})
})
return Service.of({ list, get, create, write, resize, control, input, snapshot, remove, shutdown, attach })
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [Group.node, Database.node, Global.node] })
class Client {
private registration?: Promise<Registration>
constructor(
private readonly directory: string,
private readonly binary: () => Promise<string>,
) {}
request(value: object, start = false): Promise<WireResponse> {
return this.connect(start)
.then((registration) => oneShot(registration, value))
.catch((error) => {
if (!(error instanceof ConnectError)) throw error
this.registration = undefined
if (!start) throw error
return this.connect(true).then((registration) => oneShot(registration, value))
})
}
requestIfRunning(value: object) {
return this.request(value).catch(() => undefined)
}
async shutdown() {
const response = await this.requestIfRunning({ op: "shutdown" })
this.registration = undefined
if (!response) return
const deadline = Date.now() + 5_000
while (Date.now() < deadline) {
const running = await discover(this.directory).then(
() => true,
() => false,
)
if (!running) return response
await setTimeout(50)
}
throw new Error("opencode-pty did not stop")
}
async subscribe(
id: number,
input: {
readonly cursor: number
readonly attachmentID: string
readonly role: Role
readonly takeover?: boolean
readonly onEvent: (event: StreamEvent) => void
readonly onEnd: () => void
},
): Promise<Attachment> {
const registration = await this.connect(false)
const socket = net.createConnection(registration.socket)
const frames = decoder(socket)
await connected(socket)
socket.write(
encode({
token: registration.token,
request: {
op: "subscribe",
id,
offset: input.cursor,
attachment_id: input.attachmentID,
role: input.role,
takeover: input.takeover ?? false,
},
}),
)
const initial = await frames.next()
if (initial.done) throw new Error("opencode-pty closed before attachment")
const response = decode(initial.value)
if (response.type === "error") throw new Error(response.message)
if (response.type !== "attached") throw new Error(`unexpected opencode-pty response: ${response.type}`)
let detached = false
const pump = async () => {
try {
for await (const frame of frames) {
if (frame[0] === 0) {
if (frame.length < 17) throw new Error("invalid opencode-pty output frame")
input.onEvent({
type: "output",
start: Number(frame.readBigUInt64BE(1)),
end: Number(frame.readBigUInt64BE(9)),
data: frame.subarray(17),
})
continue
}
const event = decode(frame)
if (event.type === "resized")
input.onEvent({
type: "resized",
cols: event.cols,
rows: event.rows,
generation: event.generation,
checkpoint: Buffer.from(event.checkpoint_base64, "base64"),
})
if (event.type === "controller_changed")
input.onEvent({
type: "controller_changed",
attachmentID: event.attachment_id ?? undefined,
generation: event.generation,
})
if (event.type === "title_changed") input.onEvent({ type: "title_changed", title: event.title })
if (event.type === "foreground_process_changed")
input.onEvent({ type: "foreground_process_changed", process: event.process })
if (event.type === "exited") {
input.onEvent({
type: "exited",
exitCode: event.exit_code ?? undefined,
finalOffset: event.final_offset,
})
return
}
}
} finally {
if (!detached) input.onEnd()
}
}
let activated = false
return {
info: toInfo(response.terminal),
role: response.role,
generation: response.generation,
replay: {
requestedOffset: response.requested_offset,
availableOffset: response.available_offset,
endOffset: response.end_offset,
truncated: response.truncated,
data: Buffer.from(response.replay_base64, "base64"),
},
activate() {
if (activated || detached) return
activated = true
void pump().catch(() => {})
},
detach() {
if (detached) return
detached = true
socket.destroy()
},
}
}
private connect(start: boolean) {
this.registration ??= start ? ensure(this.directory, this.binary) : discover(this.directory)
return this.registration.catch((error) => {
this.registration = undefined
throw error
})
}
}
const request = (client: Client, value: object, start = false) =>
Effect.tryPromise({ try: () => client.request(value, start), catch: (error) => unavailable(error) })
const optionalRequest = (client: Client, value: object) =>
Effect.promise(() => client.requestIfRunning(value))
const unexpected = (response: WireResponse) =>
Effect.fail(new UnavailableError({ message: `unexpected opencode-pty response: ${response.type}` }))
const unavailable = (error: unknown) =>
new UnavailableError({ message: error instanceof Error ? error.message : String(error) })
function databasePath(db: Database.Interface["db"]) {
const client: unknown = db.$client
if ((typeof client !== "object" && typeof client !== "function") || client === null || !("config" in client))
return undefined
const config = client.config
if (typeof config !== "object" || config === null || !("filename" in config)) return undefined
if (typeof config.filename !== "string" || config.filename === ":memory:") return undefined
return path.resolve(config.filename)
}
const runtimeDirectory = (databasePath?: string) => {
const root =
process.env.OPENCODE_PTY_RUNTIME_DIR ??
(process.env.XDG_RUNTIME_DIR
? path.join(process.env.XDG_RUNTIME_DIR, "opencode-pty")
: path.join(
os.tmpdir(),
`opencode-pty-${typeof process.getuid === "function" ? process.getuid() : process.env.USER || "unknown"}`,
))
const identity = databasePath ?? `memory:${crypto.randomUUID()}`
return path.join(root, createHash("sha256").update(identity).digest("hex").slice(0, 16))
}
const registrationPath = (directory: string) => path.join(directory, "service.json")
async function ensure(directory: string, binary: () => Promise<string>) {
const found = await discover(directory).catch(() => undefined)
if (found) return found
const executable = await binary()
await new Promise<void>((resolve, reject) => {
const child = spawn(executable, ["daemon"], {
detached: true,
stdio: "ignore",
env: { ...process.env, OPENCODE_PTY_RUNTIME_DIR: directory },
})
child.once("spawn", () => {
child.unref()
resolve()
})
child.once("error", reject)
})
const deadline = Date.now() + 5_000
let last: unknown
while (Date.now() < deadline) {
try {
return await discover(directory)
} catch (error) {
last = error
await setTimeout(50)
}
}
throw last instanceof Error ? last : new Error("opencode-pty did not become ready")
}
async function discover(directory: string) {
const registration = Schema.decodeUnknownSync(Registration)(
JSON.parse(await readFile(registrationPath(directory), "utf8")),
)
if (registration.protocol !== ProtocolVersion) throw new Error("opencode-pty protocol mismatch")
const response = await oneShot(registration, { op: "ping" })
if (
response.type !== "pong" ||
response.instance_id !== registration.instance_id ||
response.pid !== registration.pid ||
response.protocol !== ProtocolVersion
)
throw new Error("opencode-pty registration mismatch")
return registration
}
async function oneShot(registration: Registration, request: object) {
const socket = net.createConnection(registration.socket)
const frames = decoder(socket)
await connected(socket).catch((cause) => {
socket.destroy()
throw new ConnectError(cause)
})
socket.write(encode({ token: registration.token, request }))
const first = await frames.next()
socket.end()
if (first.done) throw new Error("opencode-pty closed without response")
const response = decode(first.value)
if (response.type === "error") throw new Error(response.message)
return response
}
class ConnectError extends Error {
constructor(cause: unknown) {
super(cause instanceof Error ? cause.message : String(cause))
}
}
function connected(socket: net.Socket) {
return new Promise<void>((resolve, reject) => {
socket.once("connect", resolve)
socket.once("error", reject)
})
}
function encode(value: unknown) {
const payload = Buffer.from(JSON.stringify(value))
if (payload.length > MaxFrameBytes) throw new Error("opencode-pty frame too large")
const output = Buffer.allocUnsafe(payload.length + 4)
output.writeUInt32BE(payload.length)
payload.copy(output, 4)
return output
}
async function* decoder(socket: net.Socket) {
let pending = Buffer.alloc(0)
for await (const value of socket) {
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value)
pending = pending.length === 0 ? chunk : Buffer.concat([pending, chunk])
while (pending.length >= 4) {
const length = pending.readUInt32BE(0)
if (length > MaxFrameBytes) throw new Error("opencode-pty frame too large")
if (pending.length < length + 4) break
yield pending.subarray(4, length + 4)
pending = pending.subarray(length + 4)
}
}
if (pending.length !== 0) throw new Error("opencode-pty truncated frame")
}
function decode(payload: Uint8Array) {
return Schema.decodeUnknownSync(Response)(JSON.parse(Buffer.from(payload).toString("utf8")))
}
function toInfo(value: WireTerminal): Info {
const status = value.lifecycle.status
return {
...Pty.Info.make({
id: toID(value.id),
title: value.title,
command: value.command[0] || "",
args: value.command.slice(1),
cwd: value.cwd,
status: status === "running" ? "running" : "exited",
pid: value.pid ?? 0,
...(status === "exited" ? { exitCode: value.lifecycle.exit_code ?? undefined } : {}),
}),
groupID: Group.ID.make(value.group_id),
foregroundProcess: value.foreground_process,
size: { cols: value.cols, rows: value.rows },
output: { head: value.output_head, tail: value.output_tail },
}
}
function toID(value: number) {
return Pty.ID.make(`pty_persistent_${value}`)
}
function fromID(value: Pty.ID) {
if (!value.startsWith("pty_persistent_")) throw new Error(`invalid persistent PTY ID: ${value}`)
const parsed = Number(value.slice("pty_persistent_".length))
if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error(`invalid persistent PTY ID: ${value}`)
return parsed
}
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect } from "bun:test"
import { Group } from "@opencode-ai/core/persistent-pty"
import { Bus } from "@opencode-ai/core/bus"
import { KV } from "@opencode-ai/core/kv"
import { Pty } from "@opencode-ai/schema/pty"
import { Session } from "@opencode-ai/schema/session"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Effect, Fiber, Stream } from "effect"
import { testEffect } from "./lib/effect"
const it = testEffect(LayerNode.compile(LayerNode.group([Group.node, KV.node, Bus.node])))
describe("Group", () => {
it.effect("persists ordered groups in one versioned KV document", () =>
Effect.gen(function* () {
const groups = yield* Group.Service
const kv = yield* KV.Service
const created = yield* groups.create([
{ type: "session", id: Session.ID.make("ses_one") },
{ type: "terminal", id: Pty.ID.make("pty_one") },
])
expect(yield* groups.get(created.id)).toEqual(created)
expect(yield* groups.list()).toEqual([created])
expect(yield* kv.get("group:v1")).toEqual([created])
const updated = Group.Info.make({
id: created.id,
items: [{ type: "terminal", id: Pty.ID.make("pty_two") }],
})
yield* groups.set(updated)
expect(yield* groups.list()).toEqual([updated])
yield* groups.remove(created.id)
expect(yield* groups.get(created.id)).toBeUndefined()
expect(yield* kv.get("group:v1")).toEqual([])
}),
)
it.effect("serializes concurrent document mutations", () =>
Effect.gen(function* () {
const groups = yield* Group.Service
yield* Effect.all(
Array.from({ length: 20 }, (_, index) =>
groups.create([{ type: "session", id: Session.ID.make(`ses_${index}`) }]),
),
{ concurrency: "unbounded" },
)
expect(yield* groups.list()).toHaveLength(20)
}),
)
it.effect("publishes every removed group item", () =>
Effect.gen(function* () {
const groups = yield* Group.Service
const bus = yield* Bus.Service
const session = { type: "session" as const, id: Session.ID.make("ses_one") }
const terminal = { type: "terminal" as const, id: Pty.ID.make("pty_one") }
const group = yield* groups.create([session, terminal])
const events = yield* bus
.subscribe(Group.Event.ItemRemoved)
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* groups.set(Group.Info.make({ id: group.id, items: [session] }))
yield* groups.remove(group.id)
expect(Array.from(yield* Fiber.join(events)).map((event) => event.data)).toEqual([
{ groupID: group.id, item: terminal },
{ groupID: group.id, item: session },
])
}),
)
it.effect("publishes every added group item", () =>
Effect.gen(function* () {
const groups = yield* Group.Service
const bus = yield* Bus.Service
const session = { type: "session" as const, id: Session.ID.make("ses_one") }
const terminal = { type: "terminal" as const, id: Pty.ID.make("pty_one") }
const group = yield* groups.create([session])
const event = yield* bus.subscribe(Group.Event.ItemAdded).pipe(Stream.runHead, Effect.forkScoped)
yield* Effect.yieldNow
yield* groups.set(Group.Info.make({ id: group.id, items: [session, terminal] }))
expect((yield* Fiber.join(event)).valueOrUndefined?.data).toEqual({ groupID: group.id, item: terminal })
}),
)
})
@@ -0,0 +1,35 @@
import { expect, test } from "bun:test"
import { createHash } from "node:crypto"
import { chmod, lstat, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { install } from "../src/persistent-pty/binary.bun"
test("installs an embedded persistent PTY executable into a content-addressed private directory", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-binary-test-"))
try {
const source = path.join(root, "embedded-opencode-pty")
const bytes = Buffer.from("test opencode-pty executable")
const sha256 = createHash("sha256").update(bytes).digest("hex")
await writeFile(source, bytes)
const first = await install(path.join(root, "bin"), { path: source, version: "test", sha256 })
const second = await install(path.join(root, "bin"), { path: source, version: "test", sha256 })
expect(second).toBe(first)
expect(await readFile(first)).toEqual(bytes)
expect((await lstat(first)).mode & 0o777).toBe(0o755)
expect((await lstat(path.dirname(first))).mode & 0o777).toBe(0o700)
await chmod(first, 0o600)
expect(await install(path.join(root, "bin"), { path: source, version: "test", sha256 })).toBe(first)
expect((await lstat(first)).mode & 0o777).toBe(0o755)
await writeFile(first, "tampered executable")
await expect(install(path.join(root, "bin"), { path: source, version: "test", sha256 })).rejects.toThrow(
"Cached opencode-pty checksum mismatch",
)
} finally {
await rm(root, { recursive: true, force: true })
}
})
File diff suppressed because it is too large Load Diff
+3
View File
@@ -19,6 +19,7 @@ import { HealthGroup } from "./groups/health.js"
import { ServerGroup } from "./groups/server.js"
import { DebugGroup } from "./groups/debug.js"
import { PtyGroup } from "./groups/pty.js"
import { PersistentPtyGroup } from "./groups/persistent-pty.js"
import { ShellGroup } from "./groups/shell.js"
import { ReferenceGroup } from "./groups/reference.js"
import { Authorization } from "./middleware/authorization.js"
@@ -88,6 +89,7 @@ type ApiGroups<
| typeof MigrationGroup
| typeof WorktreeGroup
| typeof WorkspaceGroup
| typeof PersistentPtyGroup
| LocationGroups<LocationId>
| FormGroups<LocationId, LocationService, FormLocationId, FormLocationService>
| SessionGroups<SessionLocationId, SessionLocationService>
@@ -168,6 +170,7 @@ const makeApiFromGroup = <
.add(SkillGroup.middleware(locationMiddleware))
.add(eventGroup)
.add(PtyGroup.middleware(locationMiddleware))
.add(PersistentPtyGroup)
.add(ShellGroup.middleware(locationMiddleware))
.add(ReferenceGroup.middleware(locationMiddleware))
.add(WorktreeGroup)
@@ -0,0 +1,137 @@
import { Group } from "@opencode-ai/schema/group"
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { Pty } from "@opencode-ai/schema/pty"
import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import {
ForbiddenError,
InvalidRequestError,
PtyNotFoundError,
ServiceUnavailableError,
} from "../errors.js"
import {
PTY_CONNECT_TICKET_QUERY,
PTY_CONNECT_TOKEN_HEADER,
PTY_CONNECT_TOKEN_HEADER_VALUE,
} from "./pty.js"
export { PTY_CONNECT_TICKET_QUERY, PTY_CONNECT_TOKEN_HEADER, PTY_CONNECT_TOKEN_HEADER_VALUE }
const CONNECT_PATH = /^\/api\/persistent-pty\/[^/]+\/connect$/
export function hasPersistentPtyConnectTicketURL(url: URL) {
return CONNECT_PATH.test(url.pathname) && !!url.searchParams.get(PTY_CONNECT_TICKET_QUERY)
}
const errors = [InvalidRequestError, ServiceUnavailableError] as const
const terminalErrors = [PtyNotFoundError, ServiceUnavailableError] as const
export const PersistentPtyGroup = HttpApiGroup.make("server.persistentPty")
.add(
HttpApiEndpoint.get("persistentPty.group.list", "/api/pty-group", {
success: Schema.Struct({ data: Schema.Array(Group.Info) }),
error: errors,
}),
)
.add(
HttpApiEndpoint.post("persistentPty.group.create", "/api/pty-group", {
payload: Schema.Struct({ items: Schema.optional(Schema.Array(Group.Item)) }),
success: Schema.Struct({ data: Group.Info }),
error: errors,
}),
)
.add(
HttpApiEndpoint.get("persistentPty.group.get", "/api/pty-group/:groupID", {
params: { groupID: Group.ID },
success: Schema.Struct({ data: Group.Info }),
error: errors,
}),
)
.add(
HttpApiEndpoint.put("persistentPty.group.set", "/api/pty-group/:groupID", {
params: { groupID: Group.ID },
payload: Schema.Struct({ items: Schema.Array(Group.Item) }),
success: Schema.Struct({ data: Group.Info }),
error: errors,
}),
)
.add(
HttpApiEndpoint.delete("persistentPty.group.remove", "/api/pty-group/:groupID", {
params: { groupID: Group.ID },
success: HttpApiSchema.NoContent,
error: errors,
}),
)
.add(
HttpApiEndpoint.get("persistentPty.list", "/api/pty-group/:groupID/terminal", {
params: { groupID: Group.ID },
success: Schema.Struct({ data: Schema.Array(PersistentPty.Info) }),
error: errors,
}),
)
.add(
HttpApiEndpoint.post("persistentPty.create", "/api/pty-group/:groupID/terminal", {
params: { groupID: Group.ID },
payload: PersistentPty.CreateInput,
success: Schema.Struct({ data: PersistentPty.Info }),
error: errors,
}),
)
.add(
HttpApiEndpoint.post("persistentPty.shutdown", "/api/persistent-pty/shutdown", {
success: HttpApiSchema.NoContent,
error: [ServiceUnavailableError],
}),
)
.add(
HttpApiEndpoint.get("persistentPty.get", "/api/persistent-pty/:ptyID", {
params: { ptyID: Pty.ID },
success: Schema.Struct({ data: PersistentPty.Info }),
error: terminalErrors,
}),
)
.add(
HttpApiEndpoint.put("persistentPty.update", "/api/persistent-pty/:ptyID", {
params: { ptyID: Pty.ID },
payload: PersistentPty.UpdateInput,
success: Schema.Struct({ data: PersistentPty.Info }),
error: terminalErrors,
}),
)
.add(
HttpApiEndpoint.get("persistentPty.snapshot", "/api/persistent-pty/:ptyID/snapshot", {
params: { ptyID: Pty.ID },
success: Schema.Struct({ data: PersistentPty.Snapshot }),
error: terminalErrors,
}),
)
.add(
HttpApiEndpoint.delete("persistentPty.remove", "/api/persistent-pty/:ptyID", {
params: { ptyID: Pty.ID },
success: HttpApiSchema.NoContent,
error: terminalErrors,
}),
)
.add(
HttpApiEndpoint.post("persistentPty.connectToken", "/api/persistent-pty/:ptyID/connect-token", {
params: { ptyID: Pty.ID },
success: Schema.Struct({ data: PtyTicket.ConnectToken }),
error: [ForbiddenError, PtyNotFoundError, ServiceUnavailableError],
}),
)
.add(
HttpApiEndpoint.get("persistentPty.connect", "/api/persistent-pty/:ptyID/connect", {
params: { ptyID: Pty.ID },
success: Schema.Boolean,
error: [ForbiddenError, PtyNotFoundError, ServiceUnavailableError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.persistentPty.connect",
summary: "Connect to a persistent PTY",
description: "Stream persistent PTY output through the OpenCode server.",
transform: (operation) => ({ ...operation, "x-websocket": true }),
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "persistentPty", description: "Prototype persistent PTY routes." }))
+2
View File
@@ -10,6 +10,7 @@ import { Event } from "./event.js"
import { FileSystem } from "./filesystem.js"
import { FileSystemV1 } from "./filesystem-v1.js"
import { Form } from "./form.js"
import { Group } from "./group.js"
import { InstallationEvent } from "./installation-event.js"
import { Integration } from "./integration.js"
import { LegacyEventV1 } from "./legacy-event.js"
@@ -56,6 +57,7 @@ const featureDefinitions = Event.inventory(
...Pty.Event.Definitions,
...Shell.Event.Definitions,
...Form.Event.Definitions,
...Group.Event.Definitions,
...WebSearch.Event.Definitions,
)
+43
View File
@@ -0,0 +1,43 @@
export * as Group from "./group.js"
import { Schema } from "effect"
import { ephemeral, inventory } from "./event.js"
import { ascending } from "./identifier.js"
import { Pty } from "./pty.js"
import { statics } from "./schema.js"
import { Session } from "./session.js"
const IDSchema = Schema.String.check(Schema.isStartsWith("grp_")).pipe(Schema.brand("GroupID"))
export const ID = IDSchema.pipe(
statics((schema: typeof IDSchema) => ({ create: () => schema.make("grp_" + ascending()) })),
)
export type ID = typeof ID.Type
export const SessionItem = Schema.Struct({
type: Schema.tag("session"),
id: Session.ID,
})
export interface SessionItem extends Schema.Schema.Type<typeof SessionItem> {}
export const TerminalItem = Schema.Struct({
type: Schema.tag("terminal"),
id: Pty.ID,
})
export interface TerminalItem extends Schema.Schema.Type<typeof TerminalItem> {}
export const Item = Schema.Union([SessionItem, TerminalItem]).pipe(
Schema.toTaggedUnion("type"),
Schema.annotate({ identifier: "Group.Item" }),
)
export type Item = typeof Item.Type
export const Info = Schema.Struct({
id: ID,
items: Schema.Array(Item),
}).annotate({ identifier: "Group.Info" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
const ItemAdded = ephemeral({ type: "group.item.added", schema: { groupID: ID, item: Item } })
const ItemRemoved = ephemeral({ type: "group.item.removed", schema: { groupID: ID, item: Item } })
export const Event = { ItemAdded, ItemRemoved, Definitions: inventory(ItemAdded, ItemRemoved) }
+2
View File
@@ -6,6 +6,7 @@ export { Credential } from "./credential.js"
export { Event } from "./event.js"
export { FileSystem } from "./filesystem.js"
export { Form } from "./form.js"
export { Group } from "./group.js"
export { Integration } from "./integration.js"
export { LLM } from "./llm.js"
export { Location } from "./location.js"
@@ -31,6 +32,7 @@ export { Shell } from "./shell.js"
export { Skill } from "./skill.js"
export { TokenUsage } from "./token-usage.js"
export { Pty } from "./pty.js"
export { PersistentPty } from "./persistent-pty.js"
export { PtyTicket } from "./pty-ticket.js"
export { Question } from "./question.js"
export { Workspace } from "./workspace.js"
+39
View File
@@ -0,0 +1,39 @@
export * as PersistentPty from "./persistent-pty.js"
import { Schema } from "effect"
import { Group } from "./group.js"
import { Pty } from "./pty.js"
import { NonNegativeInt, PositiveInt, optional } from "./schema.js"
export const Info = Schema.Struct({
...Pty.Info.fields,
groupID: Group.ID,
foregroundProcess: Schema.NullOr(Schema.String),
size: Schema.Struct({ cols: PositiveInt, rows: PositiveInt }),
output: Schema.Struct({ head: NonNegativeInt, tail: NonNegativeInt }),
}).annotate({ identifier: "PersistentPty.Info" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const CreateInput = Schema.Struct({
command: Schema.String,
args: Schema.Array(Schema.String),
cwd: Schema.String,
title: Schema.String,
env: Schema.Record(Schema.String, Schema.String),
size: optional(Schema.Struct({ cols: PositiveInt, rows: PositiveInt })),
}).annotate({ identifier: "PersistentPty.CreateInput" })
export interface CreateInput extends Schema.Schema.Type<typeof CreateInput> {}
export const UpdateInput = Schema.Struct({
attachmentID: optional(Schema.String),
size: Schema.Struct({ cols: PositiveInt, rows: PositiveInt }),
}).annotate({ identifier: "PersistentPty.UpdateInput" })
export interface UpdateInput extends Schema.Schema.Type<typeof UpdateInput> {}
export const Snapshot = Schema.Struct({
info: Info,
text: Schema.String,
checkpoint: Schema.Uint8Array,
cursor: Schema.Struct({ x: NonNegativeInt, y: NonNegativeInt }),
}).annotate({ identifier: "PersistentPty.Snapshot" })
export interface Snapshot extends Schema.Schema.Type<typeof Snapshot> {}
@@ -4,6 +4,7 @@ import {
Config,
FileSystem,
Form,
Group,
Integration,
Permission,
Project,
@@ -64,6 +65,7 @@ describe("public event manifest", () => {
expect(Integration.Event.Definitions).toEqual([Integration.Event.Updated, Integration.Event.ConnectionUpdated])
expect(Permission.Event.Definitions).toEqual([Permission.Event.Asked, Permission.Event.Replied])
expect(Form.Event.Definitions).toEqual([Form.Event.Created, Form.Event.Replied, Form.Event.Cancelled])
expect(Group.Event.Definitions).toEqual([Group.Event.ItemAdded, Group.Event.ItemRemoved])
expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated])
expect(Plugin.Event.Definitions).toEqual([Plugin.Event.Added, Plugin.Event.Updated])
expect(McpEvent.Definitions).toEqual([McpEvent.ToolsChanged, McpEvent.ResourcesChanged, McpEvent.StatusChanged])
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Group } from "../src/group.js"
import { Pty } from "../src/pty.js"
import { Session } from "../src/session.js"
describe("Group", () => {
test("creates branded group IDs", () => {
expect(Group.ID.create()).toStartWith("grp_")
expect(() => Schema.decodeUnknownSync(Group.ID)("ses_invalid")).toThrow()
})
test("preserves one ordered session and terminal item list", () => {
const group = Schema.decodeUnknownSync(Group.Info)({
id: Group.ID.create(),
items: [
{ type: "session", id: Session.ID.make("ses_one") },
{ type: "terminal", id: Pty.ID.make("pty_one") },
{ type: "session", id: Session.ID.make("ses_two") },
],
})
expect(group.items.map((item) => item.type)).toEqual(["session", "terminal", "session"])
expect(() =>
Schema.decodeUnknownSync(Group.Info)({
id: group.id,
items: [{ type: "other", id: "other_one" }],
}),
).toThrow()
})
})
+2
View File
@@ -16,6 +16,7 @@ import { HealthHandler } from "./handlers/health"
import { ServerHandler } from "./handlers/server"
import { DebugHandler } from "./handlers/debug"
import { PtyHandler } from "./handlers/pty"
import { PersistentPtyHandler } from "./handlers/persistent-pty"
import { ShellHandler } from "./handlers/shell"
import { ReferenceHandler } from "./handlers/reference"
import { LocationHandler } from "./handlers/location"
@@ -56,6 +57,7 @@ export const handlers = Layer.mergeAll(
SkillHandler,
EventHandler.pipe(Layer.provide(EventFeed.layer)),
PtyHandler,
PersistentPtyHandler,
ShellHandler,
ReferenceHandler,
WorktreeHandler,
@@ -0,0 +1,295 @@
import { Group, PersistentPty } from "@opencode-ai/core/persistent-pty"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import {
ForbiddenError,
InvalidRequestError,
PtyNotFoundError,
ServiceUnavailableError,
} from "@opencode-ai/protocol/errors"
import {
PTY_CONNECT_TICKET_QUERY,
PTY_CONNECT_TOKEN_HEADER,
PTY_CONNECT_TOKEN_HEADER_VALUE,
} from "@opencode-ai/protocol/groups/persistent-pty"
import { Effect, Queue, Semaphore } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Socket } from "effect/unstable/socket"
import { Api } from "../api"
import { CorsConfig, isAllowedRequestOrigin } from "../cors"
export const PersistentPtyHandler = HttpApiBuilder.group(Api, "server.persistentPty", (handlers) =>
Effect.gen(function* () {
const tickets = yield* PtyTicket.Service
const cors = yield* CorsConfig
const groups = yield* Group.Service
const pty = yield* PersistentPty.Service
return handlers
.handle(
"persistentPty.group.list",
Effect.fn(function* () {
return { data: yield* groups.list() }
}),
)
.handle(
"persistentPty.group.create",
Effect.fn(function* (ctx) {
return { data: yield* groups.create(ctx.payload.items) }
}),
)
.handle(
"persistentPty.group.get",
Effect.fn(function* (ctx) {
const group = yield* groups.get(ctx.params.groupID)
if (!group)
return yield* new InvalidRequestError({
message: `Group not found: ${ctx.params.groupID}`,
field: "groupID",
})
return { data: group }
}),
)
.handle(
"persistentPty.group.set",
Effect.fn(function* (ctx) {
const group = Group.Info.make({ id: ctx.params.groupID, items: ctx.payload.items })
yield* groups.set(group)
return { data: group }
}),
)
.handle(
"persistentPty.group.remove",
Effect.fn(function* (ctx) {
yield* groups.remove(ctx.params.groupID)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"persistentPty.list",
Effect.fn(function* (ctx) {
return { data: yield* pty.list(ctx.params.groupID).pipe(mapUnavailable) }
}),
)
.handle(
"persistentPty.create",
Effect.fn(function* (ctx) {
return {
data: yield* pty
.create(ctx.params.groupID, {
command: ctx.payload.command,
args: ctx.payload.args,
cwd: ctx.payload.cwd,
title: ctx.payload.title,
env: ctx.payload.env,
cols: ctx.payload.size?.cols,
rows: ctx.payload.size?.rows,
})
.pipe(
Effect.catchTags({
"PersistentPty.GroupNotFoundError": () =>
new InvalidRequestError({
message: `Group not found: ${ctx.params.groupID}`,
field: "groupID",
}),
"PersistentPty.UnavailableError": unavailable,
}),
),
}
}),
)
.handle(
"persistentPty.shutdown",
Effect.fn(function* () {
yield* pty.shutdown().pipe(mapUnavailable)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"persistentPty.get",
Effect.fn(function* (ctx) {
return { data: yield* pty.get(ctx.params.ptyID).pipe(mapTerminalError) }
}),
)
.handle(
"persistentPty.update",
Effect.fn(function* (ctx) {
yield* pty
.resize(
ctx.params.ptyID,
ctx.payload.size.cols,
ctx.payload.size.rows,
ctx.payload.attachmentID,
)
.pipe(mapTerminalError)
return { data: yield* pty.get(ctx.params.ptyID).pipe(mapTerminalError) }
}),
)
.handle(
"persistentPty.snapshot",
Effect.fn(function* (ctx) {
return { data: yield* pty.snapshot(ctx.params.ptyID).pipe(mapTerminalError) }
}),
)
.handle(
"persistentPty.remove",
Effect.fn(function* (ctx) {
yield* pty.remove(ctx.params.ptyID).pipe(mapTerminalError)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"persistentPty.connectToken",
Effect.fn(function* (ctx) {
const request = yield* HttpServerRequest.HttpServerRequest
if (
request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE ||
!isAllowedRequestOrigin(request.headers.origin, request.headers.host, cors)
)
return yield* new ForbiddenError({ message: "Invalid persistent PTY connect token request" })
yield* pty.get(ctx.params.ptyID).pipe(mapTerminalError)
return { data: yield* tickets.issue({ ptyID: ctx.params.ptyID }) }
}),
)
.handleRaw(
"persistentPty.connect",
Effect.fn("PersistentPtyHandler.connect")(function* (ctx) {
const exists = yield* pty.get(ctx.params.ptyID).pipe(
Effect.as(true),
Effect.catchTag("PersistentPty.NotFoundError", () => Effect.succeed(false)),
Effect.catchTag("PersistentPty.UnavailableError", () => Effect.succeed(false)),
)
if (!exists) return HttpServerResponse.empty({ status: 404 })
const url = new URL(ctx.request.url, "http://localhost")
const ticket = url.searchParams.get(PTY_CONNECT_TICKET_QUERY)
if (ticket) {
const valid = isAllowedRequestOrigin(ctx.request.headers.origin, ctx.request.headers.host, cors)
? yield* tickets.consume({ ticket, ptyID: ctx.params.ptyID })
: false
if (!valid) return HttpServerResponse.empty({ status: 403 })
}
const cursor = Number(url.searchParams.get("cursor") ?? "0")
const role = url.searchParams.get("role") === "observer" ? "observer" : "controller"
const framedInput = url.searchParams.get("input_protocol") === "1"
const attachmentID = url.searchParams.get("attachment_id") ?? crypto.randomUUID()
if (!Number.isSafeInteger(cursor) || cursor < 0) return HttpServerResponse.empty({ status: 400 })
const socket = yield* Effect.orDie(ctx.request.upgrade)
const write = yield* socket.writer
const outbox = yield* Queue.unbounded<string | Uint8Array | Socket.CloseEvent>()
const input = yield* Semaphore.make(1)
const attachment = yield* pty
.attach(ctx.params.ptyID, {
cursor,
attachmentID,
role,
takeover: url.searchParams.get("takeover") === "true",
onEvent: (event) => {
if (event.type === "output") Queue.offerUnsafe(outbox, event.data)
if (event.type === "resized")
Queue.offerUnsafe(
outbox,
JSON.stringify({ ...event, checkpoint: Buffer.from(event.checkpoint).toString("base64") }),
)
if (event.type !== "output" && event.type !== "resized")
Queue.offerUnsafe(outbox, JSON.stringify(event))
},
onEnd: () => Queue.offerUnsafe(outbox, new Socket.CloseEvent(1000)),
})
.pipe(
Effect.catchTags({
"PersistentPty.NotFoundError": () => Effect.succeed(undefined),
"PersistentPty.UnavailableError": () => Effect.succeed(undefined),
}),
)
if (!attachment) return HttpServerResponse.empty({ status: 404 })
Queue.offerUnsafe(
outbox,
JSON.stringify({
type: "attached",
attachmentID,
inputProtocol: framedInput ? 1 : 0,
info: attachment.info,
role: attachment.role,
generation: attachment.generation,
replay: {
requestedOffset: attachment.replay.requestedOffset,
availableOffset: attachment.replay.availableOffset,
endOffset: attachment.replay.endOffset,
truncated: attachment.replay.truncated,
},
}),
)
if (attachment.replay.data.length > 0) Queue.offerUnsafe(outbox, attachment.replay.data)
Queue.offerUnsafe(
outbox,
JSON.stringify({ type: "replay_complete", endOffset: attachment.replay.endOffset }),
)
attachment.activate()
const drain = Effect.gen(function* () {
while (true) {
const item = yield* Queue.take(outbox)
yield* write(item)
if (item instanceof Socket.CloseEvent) return
}
})
yield* Effect.race(
drain,
socket.runRaw((message) =>
input.withPermit(
Effect.suspend(() => {
if (role !== "controller") return Effect.void
const data = typeof message === "string" ? Buffer.from(message) : message
if (!framedInput)
return pty
.input(
ctx.params.ptyID,
attachmentID,
attachment.info.size.cols,
attachment.info.size.rows,
data,
)
.pipe(Effect.ignore)
if (data.byteLength < 5) return Effect.void
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
const type = data[0]
const cols = view.getUint16(1)
const rows = view.getUint16(3)
if ((type !== 0 && type !== 1) || cols === 0 || rows === 0) return Effect.void
if (type === 0) return pty.control(ctx.params.ptyID, attachmentID, cols, rows).pipe(Effect.ignore)
return pty.input(ctx.params.ptyID, attachmentID, cols, rows, data.subarray(5)).pipe(Effect.ignore)
}),
),
),
).pipe(
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
Effect.ensuring(Effect.sync(() => attachment.detach())),
Effect.orDie,
)
return HttpServerResponse.empty()
}),
)
}),
)
const mapUnavailable = <A>(effect: Effect.Effect<A, PersistentPty.UnavailableError>) =>
effect.pipe(Effect.catchTag("PersistentPty.UnavailableError", unavailable))
const mapTerminalError = <A>(
effect: Effect.Effect<A, PersistentPty.NotFoundError | PersistentPty.UnavailableError>,
) =>
effect.pipe(
Effect.catchTags({
"PersistentPty.NotFoundError": (error) =>
new PtyNotFoundError({ ptyID: error.ptyID, message: `PTY session not found: ${error.ptyID}` }),
"PersistentPty.UnavailableError": unavailable,
}),
)
const unavailable = (error: PersistentPty.UnavailableError) =>
new ServiceUnavailableError({ message: error.message, service: "opencode-pty" })
@@ -3,6 +3,7 @@ import { UnauthorizedError } from "@opencode-ai/protocol/errors"
import { Authorization } from "@opencode-ai/protocol/middleware/authorization"
export { Authorization } from "@opencode-ai/protocol/middleware/authorization"
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
import { hasPersistentPtyConnectTicketURL } from "@opencode-ai/protocol/groups/persistent-pty"
import { Effect, Encoding, Layer, Redacted } from "effect"
import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
@@ -49,7 +50,8 @@ export const authorizationLayer = Layer.effect(
const request = yield* HttpServerRequest.HttpServerRequest
// Browsers cannot set headers on WebSocket upgrades, so a ticketed PTY connect skips
// credential checks here; the connect handler consumes and validates the ticket.
if (hasPtyConnectTicketURL(new URL(request.url, "http://localhost"))) return yield* effect
const url = new URL(request.url, "http://localhost")
if (hasPtyConnectTicketURL(url) || hasPersistentPtyConnectTicketURL(url)) return yield* effect
if (yield* authorizedRequest(request, config)) return yield* effect
yield* HttpEffect.appendPreResponseHandler((_request, response) =>
Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", WWW_AUTHENTICATE)),
+6 -1
View File
@@ -3,6 +3,7 @@ export * as ServerProcess from "./process"
import { NodeHttpServer } from "@effect/platform-node"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
import { hasPersistentPtyConnectTicketURL } from "@opencode-ai/protocol/groups/persistent-pty"
import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect"
import {
HttpMiddleware,
@@ -182,7 +183,11 @@ function dispatch(
const state = yield* status.current
const app = yield* Ref.get(application)
const ready = state.type === "ready" && Option.isSome(app)
if ((!ready || !hasPtyConnectTicketURL(url)) && !(yield* authorizedRequest(request, auth))) return unauthorized()
if (
(!ready || (!hasPtyConnectTicketURL(url) && !hasPersistentPtyConnectTicketURL(url))) &&
!(yield* authorizedRequest(request, auth))
)
return unauthorized()
if (ready) return yield* app.value
return unavailable(state)
})
+3
View File
@@ -11,6 +11,7 @@ import { Credential } from "@opencode-ai/core/credential"
import { Config } from "@opencode-ai/core/config"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Group, PersistentPty } from "@opencode-ai/core/persistent-pty"
import { Project } from "@opencode-ai/core/project"
import { Session } from "@opencode-ai/core/session"
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
@@ -59,6 +60,8 @@ const applicationServiceNodes = [
SdkPlugins.node,
PermissionSaved.node,
PtyTicket.node,
Group.node,
PersistentPty.node,
Credential.node,
WellKnown.node,
PtyEnvironment.node,
+422
View File
@@ -0,0 +1,422 @@
import { existsSync } from "node:fs"
import fs from "node:fs/promises"
import { createHash } from "node:crypto"
import os from "node:os"
import path from "node:path"
import { expect } from "bun:test"
import { Group } from "@opencode-ai/schema/group"
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { Effect, Schema } from "effect"
import { HttpServer } from "effect/unstable/http"
import { it } from "../../core/test/lib/effect"
import { ServerProcess } from "../src/process"
const binary = process.env.OPENCODE_PTY_BIN ?? "/root/projects/opencode-pty/target/debug/opencode-pty"
const smoke = existsSync(binary) ? it.live : it.live.skip
smoke(
"creates a group with two persistent terminals through the client API",
() =>
Effect.acquireUseRelease(
Effect.promise(async () => {
const environment = {
binary: process.env.OPENCODE_PTY_BIN,
runtime: process.env.OPENCODE_PTY_RUNTIME_DIR,
xdg: process.env.XDG_RUNTIME_DIR,
}
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-pty-server-test-"))
const database = path.join(root, "opencode.db")
const runtime = path.join(root, "runtime")
process.env.OPENCODE_PTY_BIN = binary
delete process.env.OPENCODE_PTY_RUNTIME_DIR
process.env.XDG_RUNTIME_DIR = runtime
return {
database,
directory: path.join(
runtime,
"opencode-pty",
createHash("sha256").update(database).digest("hex").slice(0, 16),
),
environment,
root,
}
}),
(fixture) =>
Effect.gen(function* () {
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: fixture.database },
fs: { filewatcher: false },
})
const base = HttpServer.formatAddress(server.address)
expect(existsSync(path.join(fixture.directory, "service.json"))).toBeFalse()
const group = Schema.decodeUnknownSync(Group.Info)(
(yield* request(base, "POST", "/api/pty-group", { items: [] })).data,
)
expect((yield* request(base, "GET", `/api/pty-group/${group.id}/terminal`)).data).toEqual([])
expect(existsSync(path.join(fixture.directory, "service.json"))).toBeFalse()
const first = Schema.decodeUnknownSync(PersistentPty.Info)(
(
yield* request(base, "POST", `/api/pty-group/${group.id}/terminal`, {
command: "/bin/sh",
args: ["-c", "stty -echo; printf terminal-one; cat"],
cwd: process.cwd(),
title: "first",
env: {},
})
).data,
)
expect(first.size).toEqual({ cols: 80, rows: 24 })
expect(existsSync(path.join(fixture.directory, "service.json"))).toBeTrue()
const second = Schema.decodeUnknownSync(PersistentPty.Info)(
(
yield* request(base, "POST", `/api/pty-group/${group.id}/terminal`, {
command: "/bin/sh",
args: ["-c", "printf terminal-two; sleep 30"],
cwd: process.cwd(),
title: "second",
env: {},
})
).data,
)
const updated = Schema.decodeUnknownSync(Group.Info)(
(yield* request(base, "GET", `/api/pty-group/${group.id}`)).data,
)
expect(updated.items).toEqual([
{ type: "terminal", id: first.id },
{ type: "terminal", id: second.id },
])
const terminals = Schema.decodeUnknownSync(Schema.Array(PersistentPty.Info))(
(yield* request(base, "GET", `/api/pty-group/${group.id}/terminal`)).data,
)
expect(terminals.map((terminal) => terminal.id).sort()).toEqual([first.id, second.id].sort())
expect(yield* waitForText(base, first.id, "terminal-one")).toContain("terminal-one")
expect(yield* waitForText(base, second.id, "terminal-two")).toContain("terminal-two")
yield* Effect.promise(() => verifySharedControl(base, first.id))
const snapshot = yield* request(base, "GET", `/api/persistent-pty/${first.id}/snapshot`)
if (
!isRecord(snapshot.data) ||
typeof snapshot.data.checkpoint !== "string" ||
!isRecord(snapshot.data.info) ||
!isRecord(snapshot.data.info.output) ||
typeof snapshot.data.info.output.tail !== "number"
)
throw new Error("Persistent PTY snapshot response was invalid")
expect(Buffer.from(snapshot.data.checkpoint, "base64").byteLength).toBeGreaterThan(0)
expect(snapshot.data.info.output.tail).toBeGreaterThan(0)
yield* request(base, "DELETE", `/api/persistent-pty/${first.id}`)
yield* request(base, "DELETE", `/api/persistent-pty/${second.id}`)
expect((yield* request(base, "GET", `/api/pty-group/${group.id}`)).data).toMatchObject({ items: [] })
yield* request(base, "POST", "/api/persistent-pty/shutdown")
const unattended = Schema.decodeUnknownSync(PersistentPty.Info)(
(
yield* request(base, "POST", `/api/pty-group/${group.id}/terminal`, {
command: "/bin/sh",
args: ["-c", "exit 7"],
cwd: process.cwd(),
title: "unattended",
env: {},
})
).data,
)
yield* waitForStatus(base, unattended.id, "exited")
expect((yield* request(base, "GET", `/api/pty-group/${group.id}`)).data).toMatchObject({
items: [{ type: "terminal", id: unattended.id }],
})
yield* request(base, "DELETE", `/api/persistent-pty/${unattended.id}`)
const visible = Schema.decodeUnknownSync(PersistentPty.Info)(
(
yield* request(base, "POST", `/api/pty-group/${group.id}/terminal`, {
command: "/bin/sh",
args: ["-c", "read value"],
cwd: process.cwd(),
title: "visible",
env: {},
})
).data,
)
yield* attachAndExit(base, visible.id)
yield* waitForGroupItems(base, group.id, [])
yield* request(base, "DELETE", `/api/pty-group/${group.id}`)
}),
(fixture) =>
Effect.promise(async () => {
await Bun.spawn([binary, "stop"], {
env: { ...process.env, OPENCODE_PTY_RUNTIME_DIR: fixture.directory },
stdout: "ignore",
stderr: "ignore",
}).exited
await fs.rm(fixture.root, { recursive: true, force: true })
restore("OPENCODE_PTY_BIN", fixture.environment.binary)
restore("OPENCODE_PTY_RUNTIME_DIR", fixture.environment.runtime)
restore("XDG_RUNTIME_DIR", fixture.environment.xdg)
}),
),
20_000,
)
function request(base: string, method: string, pathname: string, body?: unknown, headers?: Record<string, string>) {
return Effect.tryPromise({
try: async () => {
const response = await fetch(new URL(pathname, base), {
method,
headers: {
authorization: `Basic ${btoa("opencode:secret")}`,
...headers,
...(body === undefined ? {} : { "content-type": "application/json" }),
},
body: body === undefined ? undefined : JSON.stringify(body),
})
if (!response.ok) throw new Error(`${method} ${pathname} failed (${response.status}): ${await response.text()}`)
if (response.status === 204) return {}
const value: unknown = await response.json()
if (!isRecord(value)) throw new Error(`${method} ${pathname} returned a non-object response`)
return value
},
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
})
}
function waitForText(base: string, ptyID: string, expected: string) {
return Effect.tryPromise({
try: async () => {
for (let attempt = 0; attempt < 40; attempt++) {
const response = await Effect.runPromise(request(base, "GET", `/api/persistent-pty/${ptyID}/snapshot`))
if (isRecord(response.data) && typeof response.data.text === "string" && response.data.text.includes(expected))
return response.data.text
await Bun.sleep(50)
}
throw new Error(`Persistent PTY snapshot did not contain ${expected}`)
},
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
})
}
function waitForStatus(base: string, ptyID: string, status: string) {
return Effect.tryPromise({
try: async () => {
for (let attempt = 0; attempt < 40; attempt++) {
const response = await Effect.runPromise(request(base, "GET", `/api/persistent-pty/${ptyID}`))
if (isRecord(response.data) && response.data.status === status) return
await Bun.sleep(50)
}
throw new Error(`Persistent PTY ${ptyID} did not reach status ${status}`)
},
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
})
}
function attachAndExit(base: string, ptyID: string) {
return Effect.tryPromise({
try: async () => {
const response = await Effect.runPromise(
request(base, "POST", `/api/persistent-pty/${ptyID}/connect-token`, undefined, {
"x-opencode-ticket": "1",
}),
)
if (!isRecord(response.data) || typeof response.data.ticket !== "string")
throw new Error("Persistent PTY connect token response was invalid")
const url = new URL(`/api/persistent-pty/${ptyID}/connect`, base)
url.protocol = "ws:"
url.searchParams.set("ticket", response.data.ticket)
await new Promise<void>((resolve, reject) => {
const socket = new WebSocket(url)
const timeout = setTimeout(() => {
socket.close()
reject(new Error("Persistent PTY did not exit while attached"))
}, 5_000)
socket.addEventListener("message", (event) => {
if (typeof event.data !== "string") return
const message: unknown = JSON.parse(event.data)
if (!isRecord(message)) return
if (message.type === "attached") socket.send(new Uint8Array([4]))
if (message.type !== "exited") return
clearTimeout(timeout)
socket.close()
resolve()
})
socket.addEventListener("error", () => {
clearTimeout(timeout)
reject(new Error("Persistent PTY WebSocket failed"))
})
})
},
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
})
}
async function verifySharedControl(base: string, ptyID: string) {
const first = await openTerminalSocket(base, ptyID, "first")
const second = await openTerminalSocket(base, ptyID, "second")
try {
first.socket.send(controlFrame(90, 25))
first.socket.send(inputFrame(90, 25, "from-first\n"))
await waitForSocketOutput([first, second], "from-first")
second.socket.send(inputFrame(70, 20, "from-second\n"))
await waitForSocketOutput([first, second], "from-second")
await waitForForegroundProcess([first, second], "cat")
for (const character of "printf abc | rev\n") second.socket.send(inputFrame(70, 20, character))
await waitForSocketOutput([first, second], "printf abc | rev")
second.socket.send(inputFrame(70, 20, "x".repeat(1024)))
second.socket.send(inputFrame(70, 20, "after-burst\n"))
await waitForSocketOutput([first, second], "after-burst")
expect(first.closed).toBeFalse()
expect(second.closed).toBeFalse()
expect(first.resizes).toBeGreaterThan(0)
expect(second.resizes).toBeGreaterThan(0)
expect(first.output).not.toContain("\0")
expect(second.output).not.toContain("\0")
} finally {
first.socket.close()
second.socket.close()
}
}
async function openTerminalSocket(base: string, ptyID: string, attachmentID: string) {
const response = await Effect.runPromise(
request(base, "POST", `/api/persistent-pty/${ptyID}/connect-token`, undefined, {
"x-opencode-ticket": "1",
}),
)
if (!isRecord(response.data) || typeof response.data.ticket !== "string")
throw new Error("Persistent PTY connect token response was invalid")
const url = new URL(`/api/persistent-pty/${ptyID}/connect`, base)
url.protocol = "ws:"
url.searchParams.set("ticket", response.data.ticket)
url.searchParams.set("attachment_id", attachmentID)
url.searchParams.set("takeover", "true")
url.searchParams.set("input_protocol", "1")
const state = { socket: new WebSocket(url), output: "", closed: false, resizes: 0, foregroundProcess: null as string | null }
state.socket.binaryType = "arraybuffer"
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("Persistent PTY WebSocket did not attach")), 5_000)
let attached = false
state.socket.addEventListener("message", (event) => {
if (event.data instanceof ArrayBuffer) {
state.output += new TextDecoder().decode(event.data)
return
}
if (typeof event.data !== "string") return
const message: unknown = JSON.parse(event.data)
if (!isRecord(message)) return
if (message.type === "resized") {
if (typeof message.checkpoint !== "string") {
clearTimeout(timeout)
reject(new Error("Persistent PTY resize omitted its checkpoint"))
return
}
state.resizes++
return
}
if (message.type === "foreground_process_changed") {
state.foregroundProcess = typeof message.process === "string" ? message.process : null
return
}
if (message.type === "attached") {
if (message.inputProtocol === 1) {
if (isRecord(message.info) && typeof message.info.foregroundProcess === "string")
state.foregroundProcess = message.info.foregroundProcess
attached = true
return
}
clearTimeout(timeout)
reject(new Error("Persistent PTY WebSocket did not negotiate framed input"))
return
}
if (message.type !== "replay_complete" || !attached) return
clearTimeout(timeout)
resolve()
})
state.socket.addEventListener("close", () => {
state.closed = true
})
state.socket.addEventListener("error", () => {
clearTimeout(timeout)
reject(new Error("Persistent PTY WebSocket failed"))
})
})
return state
}
async function waitForForegroundProcess(
sockets: Array<{ foregroundProcess: string | null; closed: boolean }>,
expected: string,
) {
for (let attempt = 0; attempt < 100; attempt++) {
if (sockets.every((socket) => socket.foregroundProcess === expected)) return
if (sockets.some((socket) => socket.closed)) throw new Error("Persistent PTY observer disconnected")
await Bun.sleep(20)
}
throw new Error(
`Persistent PTY sockets did not both report ${expected}: ${JSON.stringify(sockets.map((socket) => socket.foregroundProcess))}`,
)
}
function inputFrame(cols: number, rows: number, input: string) {
const data = new TextEncoder().encode(input)
const frame = new Uint8Array(5 + data.byteLength)
const view = new DataView(frame.buffer)
frame[0] = 1
view.setUint16(1, cols)
view.setUint16(3, rows)
frame.set(data, 5)
return frame
}
function controlFrame(cols: number, rows: number) {
const frame = new Uint8Array(5)
const view = new DataView(frame.buffer)
view.setUint16(1, cols)
view.setUint16(3, rows)
return frame
}
async function waitForSocketOutput(
sockets: Array<{ output: string; closed: boolean }>,
expected: string,
) {
for (let attempt = 0; attempt < 100; attempt++) {
if (sockets.every((socket) => socket.output.includes(expected))) return
if (sockets.some((socket) => socket.closed)) throw new Error("Persistent PTY observer disconnected")
await Bun.sleep(20)
}
throw new Error(
`Persistent PTY sockets did not both receive ${expected}: ${JSON.stringify(sockets.map((socket) => socket.output))}`,
)
}
function waitForGroupItems(base: string, groupID: string, expected: unknown[]) {
return Effect.tryPromise({
try: async () => {
for (let attempt = 0; attempt < 40; attempt++) {
const response = await Effect.runPromise(request(base, "GET", `/api/pty-group/${groupID}`))
if (isRecord(response.data) && JSON.stringify(response.data.items) === JSON.stringify(expected)) return
await Bun.sleep(50)
}
throw new Error(`Persistent PTY group ${groupID} did not reconcile`)
},
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
})
}
function restore(key: string, value: string | undefined) {
if (value === undefined) delete process.env[key]
if (value !== undefined) process.env[key] = value
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
+64 -42
View File
@@ -31,7 +31,6 @@ import {
batch,
Show,
} from "solid-js"
import { createStore } from "solid-js/store"
import {
TuiLifecycleProvider,
TuiAppProvider,
@@ -77,7 +76,6 @@ import { clampSessionTabsWidth, sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH
import { ThemeErrorToast } from "./component/theme-error-toast"
import { createThemeSource, ThemeProvider, useTheme, useThemes } from "./context/theme"
import { Home } from "./routes/home"
import { Session } from "./routes/session"
import { PromptHistoryProvider } from "./prompt/history"
import { FrecencyProvider } from "./prompt/frecency"
import { PromptStashProvider } from "./prompt/stash"
@@ -100,6 +98,8 @@ import { destroyRenderer } from "./util/renderer"
import { cliErrorMessage, errorFormat } from "./util/error"
import { AttentionProvider } from "./context/attention"
import { StorageProvider, useStorage } from "./context/storage"
import { PaneLayoutProvider } from "./context/pane-layout"
import { PaneWorkspace } from "./component/pane-workspace"
import { createTuiClipboard } from "./clipboard"
registerOpencodeSpinner()
@@ -218,7 +218,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
reconnect: async (signal: AbortSignal) => {
const endpoint = await managed.reconnect(signal)
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
return { api: OpenCode.make(next) }
return { api: OpenCode.make(next), endpoint }
},
restart: managed.restart,
}
@@ -373,48 +373,50 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
: undefined
}
>
<ClientProvider api={api} service={service}>
<ClientProvider api={api} endpoint={input.server.endpoint} service={service}>
<PermissionProvider>
<DataProvider>
<LocationProvider>
<SessionTabsProvider>
<ThemeProvider
mode={mode}
source={createThemeSource(global.config)}
>
<ThemeErrorToast />
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<PluginProvider
packages={input.packages}
directories={pluginDirectories}
>
<App
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</AttentionProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
<PaneLayoutProvider>
<ThemeProvider
mode={mode}
source={createThemeSource(global.config)}
>
<ThemeErrorToast />
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<PluginProvider
packages={input.packages}
directories={pluginDirectories}
>
<App
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</AttentionProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</PaneLayoutProvider>
</SessionTabsProvider>
</LocationProvider>
</DataProvider>
@@ -618,6 +620,11 @@ function App(props: { pair?: DialogPairCredentials }) {
return
}
if (route.data.type === "workspace") {
renderer.setTerminalTitle("OC | Terminal")
return
}
if (route.data.type === "plugin") {
renderer.setTerminalTitle(`OC | ${route.data.name}`)
}
@@ -1316,7 +1323,22 @@ function App(props: { pair?: DialogPairCredentials }) {
</Match>
<Match when={route.data.type === "session"}>
<Show when={route.data.type === "session" ? route.data.sessionID : undefined} keyed>
{(_) => <Session verticalTabsWidth={verticalTabsVisible() ? verticalTabsWidth() : 0} />}
{(sessionID) => (
<PaneWorkspace
sessionID={sessionID}
verticalTabsWidth={verticalTabsVisible() ? verticalTabsWidth() : 0}
/>
)}
</Show>
</Match>
<Match when={route.data.type === "workspace"}>
<Show when={route.data.type === "workspace" ? route.data.groupID : undefined} keyed>
{(groupID) => (
<PaneWorkspace
groupID={groupID}
verticalTabsWidth={verticalTabsVisible() ? verticalTabsWidth() : 0}
/>
)}
</Show>
</Match>
<Match when={route.data.type === "plugin"}>
@@ -0,0 +1,189 @@
import { CliRenderEvents, RGBA, TextAttributes, type BoxRenderable, type Renderable } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import { createResource, createSignal, Match, onCleanup, Show, Switch, type JSX } from "solid-js"
import { usePaneLayout } from "../context/pane-layout"
import type { PaneLayoutNode } from "../context/pane-layout-model"
import { useData } from "../context/data"
import { usePromptRef } from "../context/prompt"
import { useTheme } from "../context/theme"
import { Session } from "../routes/session"
import { PersistentTerminalPane } from "./persistent-terminal-pane"
export function PaneWorkspace(props: { sessionID?: string; groupID?: string; verticalTabsWidth: number }) {
const panes = usePaneLayout()
createResource(
() => props.groupID ?? props.sessionID,
(key) => (props.groupID ? panes.loadGroup(key) : panes.load(key)).catch(() => undefined),
)
const workspace = () => (props.groupID ? panes.getGroup(props.groupID) : props.sessionID ? panes.get(props.sessionID) : undefined)
return (
<Show
when={workspace()}
fallback={props.sessionID ? <Session verticalTabsWidth={props.verticalTabsWidth} /> : null}
>
{(value) => (
<PaneNode
node={value().layout}
rootSessionID={props.sessionID}
verticalTabsWidth={props.verticalTabsWidth}
/>
)}
</Show>
)
}
function PaneNode(props: { node: PaneLayoutNode; rootSessionID?: string; verticalTabsWidth: number }) {
const panes = usePaneLayout()
const prompt = usePromptRef()
const theme = useTheme()
const data = useData()
return (
<Switch>
<Match when={props.node.type === "item" ? props.node.item : undefined}>
{(item) => {
let focusTerminal: (() => void) | undefined
const focus = () => {
if (item().type === "session" && item().id === props.rootSessionID) {
prompt.current?.focus()
return
}
focusTerminal?.()
}
return (
<PaneSurface
focus={focus}
title={
item().type === "terminal"
? "Terminal"
: `Session · ${data.session.get(item().id)?.title ?? "Untitled"}`
}
>
<Switch>
<Match when={item().type === "session" && item().id === props.rootSessionID}>
<Session verticalTabsWidth={props.verticalTabsWidth} />
</Match>
<Match when={item().type === "session"}>
<UnavailablePane label={`Session ${item().id}`} />
</Match>
<Match when={item().type === "terminal"}>
<PersistentTerminalPane
ptyID={item().id}
autoFocus={!props.rootSessionID || panes.shouldFocus(item().id)}
onAutoFocus={() => panes.clearFocus(item().id)}
onFocusRequest={(value) => (focusTerminal = value)}
/>
</Match>
</Switch>
</PaneSurface>
)
}}
</Match>
<Match when={props.node.type === "split" ? props.node : undefined}>
{(node) => (
<box
flexGrow={1}
minWidth={0}
minHeight={0}
flexDirection={node().direction === "horizontal" ? "row" : "column"}
>
<box flexGrow={node().ratio} flexBasis={0} minWidth={0} minHeight={0}>
<PaneNode
node={node().first}
rootSessionID={props.rootSessionID}
verticalTabsWidth={props.verticalTabsWidth}
/>
</box>
<box
flexGrow={1 - node().ratio}
flexBasis={0}
minWidth={0}
minHeight={0}
>
<PaneNode
node={node().second}
rootSessionID={props.rootSessionID}
verticalTabsWidth={props.verticalTabsWidth}
/>
</box>
</box>
)}
</Match>
</Switch>
)
}
function PaneSurface(props: { focus: () => void; title: string; children: JSX.Element }) {
const renderer = useRenderer()
const theme = useTheme()
const [focused, setFocused] = createSignal(false)
let pane: BoxRenderable | undefined
const contains = (current: Renderable | null) => {
while (current) {
if (current === pane) return true
current = current.parent
}
return false
}
const onFocused = (current: Renderable | null) => setFocused(contains(current))
renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
onCleanup(() => renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, onFocused))
return (
<box
ref={(value) => {
pane = value
setFocused(contains(renderer.currentFocusedRenderable))
}}
flexGrow={1}
minWidth={0}
minHeight={0}
position="relative"
flexDirection="column"
>
<box
height={1}
flexShrink={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={
focused()
? theme.raise(theme.raise(theme.background.surface.offset))
: theme.background.surface.offset
}
>
<text
fg={focused() ? theme.text.default : theme.text.subdued}
attributes={focused() ? TextAttributes.BOLD : undefined}
wrapMode="none"
truncate
>
{props.title}
</text>
</box>
<box flexGrow={1} minWidth={0} minHeight={0} position="relative">
{props.children}
</box>
<Show when={!focused()}>
<box
position="absolute"
left={0}
top={0}
width="100%"
height="100%"
zIndex={1}
backgroundColor={RGBA.fromInts(0, 0, 0)}
opacity={0.3}
onMouseDown={props.focus}
/>
</Show>
</box>
)
}
function UnavailablePane(props: { label: string }) {
const theme = useTheme()
return (
<box flexGrow={1} alignItems="center" justifyContent="center">
<text fg={theme.text.subdued}>{props.label} is unavailable in this prototype.</text>
</box>
)
}
@@ -0,0 +1,370 @@
import { EmbeddedTerminalRenderable, type RGBA } from "@opentui/core"
import type { ResolvedThemeTokens } from "@opencode-ai/theme/tui"
import { extend, useRenderer } from "@opentui/solid"
import { createEffect, createSignal, onCleanup, onMount, Show } from "solid-js"
import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
import { useTheme, useThemes } from "../context/theme"
import { errorMessage } from "../util/error"
declare module "@opentui/solid" {
interface OpenTUIComponents {
embeddedTerminal: typeof EmbeddedTerminalRenderable
}
}
extend({ embeddedTerminal: EmbeddedTerminalRenderable })
type TerminalSize = { cols: number; rows: number }
type StreamItem =
| { type: "output"; data: Uint8Array }
| { type: "resize"; size: TerminalSize; checkpoint?: Uint8Array }
| { type: "ready" }
export function PersistentTerminalPane(props: {
ptyID: string
autoFocus?: boolean
onAutoFocus?: () => void
onFocusRequest?: (focus: (() => void) | undefined) => void
}) {
const client = useClient()
const keymap = Keymap.use()
const theme = useTheme()
const themes = useThemes()
const renderer = useRenderer()
const [failure, setFailure] = createSignal<string>()
const attachmentID = crypto.randomUUID()
const stream: StreamItem[] = []
const pendingInput: Uint8Array[] = []
let terminal: EmbeddedTerminalRenderable | undefined
let socket: WebSocket | undefined
let attached = false
let controller = false
let restored = false
let wantsControl = false
let disposed = false
let size: TerminalSize | undefined
let canonicalSize: TerminalSize | undefined
let terminalSize: TerminalSize | undefined
let lastIntermediateRender = 0
let terminalTheme: Uint8Array | undefined
let waitingSize: { size: TerminalSize; resolve: () => void } | undefined
const setCanonicalSize = (value: TerminalSize) => {
canonicalSize = value
if (!terminal) return
terminal.width = value.cols
terminal.height = value.rows
}
const applyTerminalTheme = () => {
if (terminalTheme) terminal?.write(terminalTheme)
}
const send = (data: Uint8Array) => {
if (attached && socket?.readyState === WebSocket.OPEN) socket.send(data)
}
const interact = () => {
if (!restored) {
wantsControl = true
return
}
if (!size) return
send(interactionFrame(size))
}
const sendInput = (data: Uint8Array) => {
if (!restored) {
pendingInput.push(data)
return
}
if (size) send(interactionFrame(size, data))
}
const processStream = () => {
if (disposed || !terminal || !sameSize(canonicalSize, terminalSize)) return
while (stream.length > 0) {
const item = stream[0]!
if (item.type === "output") {
stream.shift()
const output = [item.data]
while (true) {
const next = stream[0]
if (!next || next.type !== "output") break
output.push(next.data)
stream.shift()
}
terminal.write(output.length === 1 ? output[0] : Buffer.concat(output))
continue
}
if (item.type === "resize") {
setCanonicalSize(item.size)
if (!sameSize(canonicalSize, terminalSize)) return
stream.shift()
if (item.checkpoint) {
terminal.write(Buffer.concat([Buffer.from("\x1bc"), Buffer.from(item.checkpoint)]))
applyTerminalTheme()
}
continue
}
stream.shift()
restored = true
const input = pendingInput.splice(0)
if (input.length > 0) input.forEach(sendInput)
if (input.length === 0 && (controller || wantsControl)) interact()
wantsControl = false
}
}
const enqueue = (item: StreamItem) => {
stream.push(item)
processStream()
}
const waitForTerminalSize = (value: TerminalSize) => {
if (sameSize(value, terminalSize)) return Promise.resolve()
return new Promise<void>((resolve) => {
waitingSize = { size: value, resolve }
})
}
const offKeys = keymap.intercept(
"key",
({ event }) => {
if (!terminal?.focused) return
event.preventDefault()
event.stopPropagation()
terminal.handleKeyPress(event)
},
{ priority: 100 },
)
createEffect(() => {
if (!props.autoFocus || !terminal) return
terminal.focus()
props.onAutoFocus?.()
})
createEffect(() => {
terminalTheme = terminalPalette(themes.currentTokens(), themes.mode())
applyTerminalTheme()
})
onMount(() => {
void connect().catch((error) => setFailure(errorMessage(error)))
})
onCleanup(() => {
disposed = true
waitingSize?.resolve()
socket?.close()
offKeys()
props.onFocusRequest?.(undefined)
})
async function connect() {
const endpoint = client.endpoint
if (!endpoint) throw new Error("Persistent terminal server endpoint is unavailable")
const snapshot = await client.api["server.persistentPty"].snapshot({ ptyID: props.ptyID })
if (disposed) return
setCanonicalSize(snapshot.info.size)
await waitForTerminalSize(snapshot.info.size)
if (disposed) return
terminal?.write(Buffer.from(snapshot.checkpoint, "base64"))
applyTerminalTheme()
const token = await client.api["server.persistentPty"].connectToken(
{ ptyID: props.ptyID },
{ headers: { "x-opencode-ticket": "1" } },
)
if (disposed) return
const url = new URL(`/api/persistent-pty/${encodeURIComponent(props.ptyID)}/connect`, endpoint.url)
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
url.searchParams.set("ticket", token.ticket)
url.searchParams.set("cursor", String(snapshot.info.output.tail))
url.searchParams.set("attachment_id", attachmentID)
url.searchParams.set("takeover", "true")
url.searchParams.set("input_protocol", "1")
const next = new WebSocket(url)
next.binaryType = "arraybuffer"
next.addEventListener("message", (event) => {
if (disposed) return
if (event.data instanceof ArrayBuffer) {
enqueue({ type: "output", data: new Uint8Array(event.data) })
const now = performance.now()
if (now - lastIntermediateRender >= 16) {
lastIntermediateRender = now
renderer.intermediateRender()
}
return
}
if (typeof event.data !== "string") return
const message: unknown = JSON.parse(event.data)
if (!message || typeof message !== "object" || !("type" in message)) return
if (
message.type === "resized" &&
"cols" in message &&
typeof message.cols === "number" &&
"rows" in message &&
typeof message.rows === "number" &&
"checkpoint" in message &&
typeof message.checkpoint === "string"
) {
enqueue({
type: "resize",
size: { cols: message.cols, rows: message.rows },
checkpoint: Buffer.from(message.checkpoint, "base64"),
})
return
}
if (message.type === "replay_complete") {
enqueue({ type: "ready" })
return
}
if (
message.type === "controller_changed" &&
"attachmentID" in message &&
(typeof message.attachmentID === "string" || message.attachmentID === undefined)
) {
const previous = controller
controller = message.attachmentID === attachmentID
if (controller && !previous && restored) interact()
return
}
if (message.type !== "attached") return
if (!("inputProtocol" in message) || message.inputProtocol !== 1) {
setFailure("Persistent terminal server is out of date; restart OpenCode")
next.close()
return
}
if (
"info" in message &&
message.info &&
typeof message.info === "object" &&
"size" in message.info &&
message.info.size &&
typeof message.info.size === "object" &&
"cols" in message.info.size &&
typeof message.info.size.cols === "number" &&
"rows" in message.info.size &&
typeof message.info.size.rows === "number"
)
enqueue({ type: "resize", size: { cols: message.info.size.cols, rows: message.info.size.rows } })
controller = "role" in message && message.role === "controller"
attached = true
})
next.addEventListener("error", () => {
if (!disposed) setFailure("Terminal connection failed")
})
next.addEventListener("close", () => {
if (!disposed) setFailure("Terminal disconnected")
})
socket = next
}
return (
<box
flexGrow={1}
minWidth={0}
minHeight={0}
overflow="hidden"
backgroundColor={theme.background.default}
onSizeChange={function () {
size = { cols: this.width, rows: this.height }
if (controller && restored) interact()
}}
// TODO: Revisit when embedded terminal mouse handlers can compose without replacing its internal focus handler.
onMouseDown={() => interact()}
>
<Show when={!failure()} fallback={<text fg={theme.text.feedback.error.default}>{failure()}</text>}>
<>
<embeddedTerminal
ref={(value) => {
terminal = value
props.onFocusRequest?.(() => {
value.focus()
interact()
})
terminalSize = { cols: 80, rows: 24 }
if (canonicalSize) {
value.width = canonicalSize.cols
value.height = canonicalSize.rows
}
applyTerminalTheme()
}}
position="absolute"
left={0}
top={0}
width={80}
height={24}
onData={(data, source) => {
if (source === "input") sendInput(data)
}}
onTerminalResize={(cols, rows) => {
terminalSize = { cols, rows }
if (waitingSize && sameSize(waitingSize.size, terminalSize)) {
waitingSize.resolve()
waitingSize = undefined
}
processStream()
}}
/>
</>
</Show>
</box>
)
}
function sameSize(first: TerminalSize | undefined, second: TerminalSize | undefined) {
return !!first && !!second && first.cols === second.cols && first.rows === second.rows
}
function terminalPalette(theme: ResolvedThemeTokens, mode: "dark" | "light") {
const base = mode === "dark" ? 500 : 700
const bright = mode === "dark" ? 300 : 500
const colors = [
theme.background.default,
theme.text.feedback.error.default,
theme.text.feedback.success.default,
theme.text.feedback.warning.default,
theme.hue.blue[base],
theme.hue.purple[base],
theme.text.feedback.info.default,
theme.text.default,
theme.text.subdued,
theme.text.feedback.error.subdued,
theme.text.feedback.success.subdued,
theme.text.feedback.warning.subdued,
theme.hue.blue[bright],
theme.hue.purple[bright],
theme.hue.cyan[bright],
theme.hue.neutral[mode === "dark" ? 100 : 900],
]
return Buffer.from(
colors
.map((color, index) => `\x1b]4;${index};${hex(color)}\x1b\\`)
.concat(
`\x1b]10;${hex(theme.text.default)}\x1b\\`,
`\x1b]11;${hex(theme.background.default)}\x1b\\`,
)
.join(""),
)
}
function hex(color: RGBA) {
return `#${color
.toInts()
.slice(0, 3)
.map((value) => value.toString(16).padStart(2, "0"))
.join("")}`
}
function interactionFrame(size: { cols: number; rows: number }, data?: Uint8Array) {
const frame = new Uint8Array(5 + (data?.byteLength ?? 0))
const view = new DataView(frame.buffer)
frame[0] = data ? 1 : 0
view.setUint16(1, size.cols)
view.setUint16(3, size.rows)
if (data) frame.set(data, 5)
return frame
}
+22 -20
View File
@@ -62,8 +62,7 @@ type MouseCloseHold = {
type TabContextMenuState = {
x: number
y: number
sessionID?: string
title?: string
tab?: SessionTab
}
type ContextController = ReturnType<typeof useSessionTabs>
@@ -248,16 +247,20 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
const theme = useTheme("elevated")
const dialog = useDialog()
const actions = createMemo(() => {
const sessionID = props.state.sessionID
const tab = props.state.tab
return [
...(props.tabs.add ? [{ title: "New tab", run: () => props.tabs.add?.() }] : []),
...(sessionID
...(tab
? [
{
title: "Rename",
run: () => DialogSessionRename.show(dialog, sessionID, props.state.title),
},
{ title: "Close", run: () => props.tabs.close(sessionID) },
...(!tab.groupID
? [
{
title: "Rename",
run: () => DialogSessionRename.show(dialog, tab.sessionID, tab.title),
},
]
: []),
{ title: "Close", run: () => props.tabs.close(tab.sessionID) },
]
: []),
]
@@ -480,7 +483,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const status = createMemo(() => itemStatus(tab))
const [sweepLevel, setSweepLevel] = createSignal(0)
const [closeHovered, setCloseHovered] = createSignal(false)
const session = createMemo(() => data.session.get(tab.sessionID))
const session = createMemo(() => (tab.groupID ? undefined : data.session.get(tab.sessionID)))
const project = createMemo(() => {
const value = session()
return value ? data.project.get(value.projectID) : undefined
@@ -489,7 +492,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const restingTitleWidth = () => Math.max(1, width() - numberWidth() - 2)
const hoveredTitleWidth = () => Math.max(1, restingTitleWidth() - 1)
const titleWidth = () => (hovered() === tab.sessionID ? hoveredTitleWidth() : restingTitleWidth())
const title = () => tab.title ?? "Untitled session"
const title = () => tab.title ?? (tab.groupID ? "Terminal" : "Untitled session")
const scrolling = () => marquee.active() === tab.sessionID
const visibleTitleParts = createMemo(() =>
scrolling()
@@ -508,6 +511,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const detail = createMemo(() => {
const fixture = tabs.detail?.(tab.sessionID)
if (fixture !== undefined) return fixture
if (tab.groupID) return tab.directory ?? ""
const value = session()
const currentProject = project()
const projectLabel = projectName(currentProject, value?.location.directory) ?? ""
@@ -641,10 +645,9 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
setDragging(undefined)
if (!rail) return
setContextMenu({
x: event.x,
y: event.y,
sessionID: tab.sessionID,
title: tab.title,
x: event.x - rail.screenX,
y: event.y - rail.screenY,
tab,
})
event.preventDefault()
event.stopPropagation()
@@ -1179,7 +1182,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
}
const glowColor = () => feedbackColor() ?? accent()
const glows = () => !selected() && (status().attention || (!status().busy && status().unread !== undefined))
const title = () => tab.title ?? "Untitled session"
const title = () => tab.title ?? (tab.groupID ? "Terminal" : "Untitled session")
const tabNumber = createMemo(() => items().findIndex((item) => item.sessionID === tab.sessionID) + 1)
const numberWidth = () => Math.max(2, String(items().length).length)
// Hovering reveals the close mark, so the title's right bound shifts left of it.
@@ -1263,10 +1266,9 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
didDrag = false
setDragging(undefined)
setContextMenu({
x: event.x,
y: event.y,
sessionID: tab === NEW_SESSION_TAB ? undefined : tab.sessionID,
title: tab === NEW_SESSION_TAB ? undefined : tab.title,
x: event.x - (strip?.screenX ?? 0),
y: event.y - (strip?.screenY ?? 0),
tab: tab === NEW_SESSION_TAB ? undefined : tab,
})
event.preventDefault()
event.stopPropagation()
+10 -3
View File
@@ -1,12 +1,13 @@
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
import { createClientConnection } from "@opencode-ai/client/solid"
import type { Endpoint } from "@opencode-ai/client/service"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js"
import { createSimpleContext } from "./helper"
import { useLog } from "./log"
type ManagedService = {
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient; endpoint?: Endpoint }>
restart: () => Promise<void>
}
@@ -14,16 +15,19 @@ type ClientEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent,
export const { use: useClient, provider: ClientProvider } = createSimpleContext({
name: "Client",
init: (props: { api: OpenCodeClient; service?: ManagedService }) => {
init: (props: { api: OpenCodeClient; endpoint?: Endpoint; service?: ManagedService }) => {
const log = useLog({ component: "client" })
const service = props.service
const events = createGlobalEmitter<ClientEventMap>()
let api = props.api
let endpoint = props.endpoint
const connection = createClientConnection(api, {
reconnect: service
? async (signal) => {
api = (await service.reconnect(signal)).api
const next = await service.reconnect(signal)
api = next.api
if (next.endpoint) endpoint = next.endpoint
return api
}
: undefined,
@@ -41,6 +45,9 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
get api() {
return api
},
get endpoint() {
return endpoint
},
event: {
on: events.on,
listen: events.listen,
@@ -0,0 +1,70 @@
import type { GroupItem } from "@opencode-ai/client"
export type PaneLayoutNode =
| { type: "item"; item: GroupItem }
| {
type: "split"
direction: "horizontal" | "vertical"
ratio: number
first: PaneLayoutNode
second: PaneLayoutNode
}
export function defaultPaneLayout(items: readonly GroupItem[]): PaneLayoutNode | undefined {
const master = items[0]
if (!master) return undefined
const stack = items.slice(1)
if (stack.length === 0) return { type: "item", item: master }
return {
type: "split",
direction: "horizontal",
ratio: 0.5,
first: { type: "item", item: master },
second: stackLayout(stack),
}
}
function stackLayout(items: readonly GroupItem[]): PaneLayoutNode {
const first = items[0]
if (items.length === 1) return { type: "item", item: first }
return {
type: "split",
direction: "vertical",
ratio: 1 / items.length,
first: { type: "item", item: first },
second: stackLayout(items.slice(1)),
}
}
export function paneLayoutItems(node: PaneLayoutNode): GroupItem[] {
if (node.type === "item") return [node.item]
return paneLayoutItems(node.first).concat(paneLayoutItems(node.second))
}
export function removePaneLayoutItem(node: PaneLayoutNode, item: GroupItem): PaneLayoutNode | undefined {
if (node.type === "item") return itemKey(node.item) === itemKey(item) ? undefined : node
const first = removePaneLayoutItem(node.first, item)
const second = removePaneLayoutItem(node.second, item)
if (!first) return second
if (!second) return first
if (first === node.first && second === node.second) return node
return { ...node, first, second }
}
export function reconcilePaneLayout(node: PaneLayoutNode | undefined, items: readonly GroupItem[]) {
if (!node) return defaultPaneLayout(items)
const wanted = new Map(items.map((item) => [itemKey(item), item]))
const kept = paneLayoutItems(node).filter((item) => wanted.has(itemKey(item)))
if (kept.length !== items.length || kept.some((item, index) => itemKey(item) !== itemKey(items[index])))
return defaultPaneLayout(items)
return replaceItems(node, wanted)
}
function replaceItems(node: PaneLayoutNode, items: ReadonlyMap<string, GroupItem>): PaneLayoutNode {
if (node.type === "item") return { type: "item", item: items.get(itemKey(node.item)) ?? node.item }
return { ...node, first: replaceItems(node.first, items), second: replaceItems(node.second, items) }
}
function itemKey(item: GroupItem) {
return `${item.type}:${item.id}`
}
+158
View File
@@ -0,0 +1,158 @@
import type { GroupInfo, GroupItem, LocationRef, PersistentPtyInfo } from "@opencode-ai/client"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
import { useData } from "./data"
import { useStorage } from "./storage"
import { reconcilePaneLayout, removePaneLayoutItem, type PaneLayoutNode } from "./pane-layout-model"
import { useEvent } from "./event"
import { createSignal, onCleanup } from "solid-js"
type PaneWorkspace = {
sessionID?: string
groupID: string
items: GroupItem[]
layout: PaneLayoutNode
}
type PaneLayoutState = {
workspaces: Record<string, PaneWorkspace>
}
export const { use: usePaneLayout, provider: PaneLayoutProvider } = createSimpleContext({
name: "PaneLayout",
init: () => {
const client = useClient()
const data = useData()
const event = useEvent()
const [focus, setFocus] = createSignal<string>()
const [store, update] = useStorage().store<PaneLayoutState>("pane-layout-v1", {
initial: { workspaces: {} },
})
const save = (key: string, group: GroupInfo, sessionID?: string) =>
update((draft) => {
const layout = reconcilePaneLayout(draft.workspaces[key]?.layout, group.items)
if (!layout) {
delete draft.workspaces[key]
return
}
draft.workspaces[key] = {
sessionID,
groupID: group.id,
items: group.items,
layout,
}
})
onCleanup(
event.on("group.item.added", (evt) => {
void update((draft) => {
Object.values(draft.workspaces).forEach((workspace) => {
if (workspace.groupID !== evt.data.groupID) return
if (workspace.items.some((item) => item.type === evt.data.item.type && item.id === evt.data.item.id)) return
workspace.items.push(evt.data.item)
workspace.layout = reconcilePaneLayout(workspace.layout, workspace.items) ?? workspace.layout
})
}).catch((error) => console.error("Failed to add pane layout item", error))
}),
)
onCleanup(
event.on("group.item.removed", (evt) => {
void update((draft) => {
Object.entries(draft.workspaces).forEach(([sessionID, workspace]) => {
if (workspace.groupID !== evt.data.groupID) return
const layout = removePaneLayoutItem(workspace.layout, evt.data.item)
if (!layout) {
delete draft.workspaces[sessionID]
return
}
workspace.items = workspace.items.filter(
(item) => item.type !== evt.data.item.type || item.id !== evt.data.item.id,
)
workspace.layout = layout
})
}).catch((error) => console.error("Failed to remove pane layout item", error))
}),
)
return {
get(sessionID: string) {
return store.workspaces[sessionID]
},
async load(sessionID: string) {
const current = store.workspaces[sessionID]
if (current) {
const group = await client.api["server.persistentPty"].group.get({ groupID: current.groupID })
await save(sessionID, group, sessionID)
return
}
const groups = await client.api["server.persistentPty"].group.list()
const group = groups.find((item) =>
item.items.some((entry) => entry.type === "session" && entry.id === sessionID),
)
if (group) await save(sessionID, group, sessionID)
},
getGroup(groupID: string) {
return store.workspaces[groupID]
},
async loadGroup(groupID: string) {
await save(groupID, await client.api["server.persistentPty"].group.get({ groupID }))
},
async refresh(sessionID: string) {
const current = store.workspaces[sessionID]
if (!current) return
const group = await client.api["server.persistentPty"].group.get({ groupID: current.groupID })
await save(sessionID, group, sessionID)
},
async newTerminal(sessionID: string, options?: { focus?: boolean }): Promise<PersistentPtyInfo> {
const api = client.api["server.persistentPty"]
const current = store.workspaces[sessionID]
const existing = current
? await api.group.get({ groupID: current.groupID })
: (await api.group.list()).find((group) =>
group.items.some((item) => item.type === "session" && item.id === sessionID),
)
const group = existing ?? (await api.group.create({ items: [{ type: "session", id: sessionID }] }))
const session = data.session.get(sessionID)
const terminal = await api.create({
groupID: group.id,
command: process.env.SHELL || "/bin/sh",
args: [],
cwd: session?.location.directory ?? process.cwd(),
title: "Terminal",
env: {},
})
const next = await api.group.get({ groupID: group.id })
if (options?.focus !== false) setFocus(terminal.id)
await save(sessionID, next, sessionID)
return terminal
},
async newTerminalWorkspace(location: LocationRef) {
const api = client.api["server.persistentPty"]
const group = await api.group.create({ items: [] })
const terminal = await api
.create({
groupID: group.id,
command: process.env.SHELL || "/bin/sh",
args: [],
cwd: location.directory,
title: "Terminal",
env: {},
})
.catch(async (error) => {
await api.group.remove({ groupID: group.id }).catch(() => undefined)
throw error
})
await save(group.id, await api.group.get({ groupID: group.id }))
return { group, terminal }
},
shouldFocus(ptyID: string) {
return focus() === ptyID
},
clearFocus(ptyID: string) {
setFocus((current) => (current === ptyID ? undefined : current))
},
}
},
})
+9 -1
View File
@@ -17,6 +17,11 @@ export type SessionRoute = {
prompt?: PromptInfo
}
export type WorkspaceRoute = {
type: "workspace"
groupID: string
}
export type PluginRoute = {
type: "plugin"
id: string
@@ -24,7 +29,7 @@ export type PluginRoute = {
data?: Record<string, unknown>
}
export type Route = HomeRoute | SessionRoute | PluginRoute
export type Route = HomeRoute | SessionRoute | WorkspaceRoute | PluginRoute
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
name: "Route",
@@ -51,6 +56,9 @@ function initialRoute(value: unknown): Route | undefined {
if (value.type === "session" && "sessionID" in value && typeof value.sessionID === "string") {
return { type: "session", sessionID: value.sessionID }
}
if (value.type === "workspace" && "groupID" in value && typeof value.groupID === "string") {
return { type: "workspace", groupID: value.groupID }
}
if (
value.type === "plugin" &&
"id" in value &&
@@ -1,6 +1,8 @@
export type SessionTab = {
sessionID: string
title?: string
groupID?: string
directory?: string
}
export type SessionTabUnread = "activity" | "error"
+108 -17
View File
@@ -75,6 +75,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const fallback = empty()
const [promptPulses, setPromptPulses] = createSignal<Record<string, number>>({})
let history: SessionTabHistory = { entries: [], index: -1 }
const closing = new Set<string>()
// User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned.
let closedTabs: ClosedSessionTab[] = []
// Storage mutations apply against the on-disk draft under a file lock, so
@@ -125,12 +126,17 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
}
const normalize = (value: TabsState) => ({
tabs: value.tabs.reduce<SessionTab[]>((tabs, tab) => {
if (tab.groupID) return openSessionTab(tabs, { ...tab, sessionID: tab.groupID })
const sessionID = root(tab.sessionID)
return openSessionTab(tabs, { sessionID, title: title(sessionID, tab.title) })
}, []),
unread: {},
})
const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
const current = () => {
if (route.data.type === "session") return root(route.data.sessionID)
if (route.data.type === "workspace") return route.data.groupID
return undefined
}
const newTab = createMemo((open = false) => {
if (route.data.type === "home") return true
if (!open) return false
@@ -138,6 +144,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
return sessionID !== undefined && !state().tabs.some((tab) => tab.sessionID === sessionID)
}, false)
const status = (sessionID: string) => {
if (state().tabs.some((tab) => tab.sessionID === sessionID && tab.groupID)) {
return { unread: undefined, promptPulse: 0, attention: false, busy: false }
}
const session = root(sessionID)
const members = family(session)
return {
@@ -160,11 +169,20 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
createEffect(
on(
[
() => (enabled() && route.data.type === "session" ? route.data.sessionID : undefined),
() => {
if (!enabled()) return undefined
if (route.data.type === "session") return route.data.sessionID
if (route.data.type === "workspace") return route.data.groupID
return undefined
},
() => config.tabs.scope,
],
([routed]) => {
if (!routed || routed === "dummy") return
if (route.data.type === "workspace") {
history = recordSessionTabHistory(history, route.data.groupID)
return
}
const sessionID = root(routed)
cancelledTabs.delete(sessionID)
history = recordSessionTabHistory(history, sessionID)
@@ -233,7 +251,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
// the first connection slots and switches still render from a warm cache.
const openTabSessions = createMemo(() =>
state()
.tabs.map((tab) => tab.sessionID)
.tabs.filter((tab) => !tab.groupID)
.map((tab) => tab.sessionID)
.sort()
.join("\n"),
)
@@ -261,7 +280,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})()
const timer = setTimeout(async () => {
const sessions = state()
.tabs.map((tab) => tab.sessionID)
.tabs.filter((tab) => !tab.groupID)
.map((tab) => tab.sessionID)
.filter((sessionID) => sessionID !== current())
for (const sessionID of sessions) {
if (stale) return
@@ -296,17 +316,49 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
onCleanup(
event.on("session.deleted", (evt) => {
const target = root(evt.data.sessionID)
closedTabs = closedTabs.filter((entry) => entry.tab.sessionID !== target)
closedTabs = closedTabs.filter((entry) => entry.tab.groupID || entry.tab.sessionID !== target)
remove(evt.data.sessionID, enabled())
}),
)
function remove(sessionID: string, navigate: boolean) {
const target = root(sessionID)
onCleanup(
event.on("group.item.removed", (evt) => {
if (closing.has(evt.data.groupID)) return
if (!state().tabs.some((tab) => tab.groupID === evt.data.groupID)) return
void client.api["server.persistentPty"].group
.get({ groupID: evt.data.groupID })
.then(async (group) => {
if (group.items.length > 0) return
await client.api["server.persistentPty"].group.remove({ groupID: group.id })
remove(group.id, enabled())
})
.catch(() => undefined)
}),
)
function tab(id: string) {
return state().tabs.find((item) => item.sessionID === id)
}
function navigate(id: string | undefined) {
if (!id) {
route.navigate({ type: "home" })
return
}
const target = tab(id)
if (target?.groupID) {
route.navigate({ type: "workspace", groupID: target.groupID })
return
}
route.navigate({ type: "session", sessionID: id })
}
function remove(sessionID: string, shouldNavigate: boolean) {
const target = tab(sessionID)?.groupID ? sessionID : root(sessionID)
cancelledTabs.add(target)
scrollAnchors.delete(target)
const closed = closeSessionTab(state().tabs, target)
const selected = navigate && current() === target
const selected = shouldNavigate && current() === target
if (closed.tabs === state().tabs && !selected) return
const previous = selected
? moveSessionTabHistory(recordSessionTabHistory(history, target), closed.tabs, target, -1)
@@ -322,7 +374,25 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
delete next[target]
return next
})
if (selected) route.navigate(next ? { type: "session", sessionID: next } : { type: "home" })
if (selected) navigate(next)
}
async function closeWorkspace(tab: SessionTab) {
if (!tab.groupID || closing.has(tab.groupID)) return
closing.add(tab.groupID)
try {
const api = client.api["server.persistentPty"]
const group = await api.group.get({ groupID: tab.groupID })
if (!group.items.some((item) => item.type === "session")) {
for (const terminal of await api.list({ groupID: group.id })) await api.remove({ ptyID: terminal.id })
await api.group.remove({ groupID: group.id })
}
remove(tab.sessionID, true)
} catch (error) {
console.error("Failed to close terminal workspace", error)
} finally {
closing.delete(tab.groupID)
}
}
return {
@@ -352,8 +422,25 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
},
select(sessionID: string) {
if (!enabled()) return
const target = tab(sessionID)
if (target?.groupID) {
route.navigate({ type: "workspace", groupID: target.groupID })
return
}
route.navigate({ type: "session", sessionID: root(sessionID) })
},
openWorkspace(groupID: string, directory: string) {
if (!enabled()) return
update((draft) => {
draft.tabs = openSessionTab(draft.tabs, {
sessionID: groupID,
groupID,
directory,
title: "Terminal",
})
})
route.navigate({ type: "workspace", groupID })
},
add() {
if (!enabled()) return
const sessionID = current()
@@ -370,17 +457,21 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
},
close(sessionID?: string) {
if (!enabled()) return
const target = sessionID ? root(sessionID) : current()
const target = sessionID ? (tab(sessionID)?.groupID ? sessionID : root(sessionID)) : current()
if (!target) {
const previous = moveSessionTabHistory(history, state().tabs, undefined, -1)
history = previous.history
const session = previous.sessionID ?? state().tabs.at(-1)?.sessionID
if (route.data.type === "home" && session) route.navigate({ type: "session", sessionID: session })
if (route.data.type === "home" && session) navigate(session)
return
}
const index = state().tabs.findIndex((tab) => tab.sessionID === target)
const tab = state().tabs[index]
if (tab) closedTabs = recordClosedSessionTab(closedTabs, tab, index)
const selected = state().tabs[index]
if (selected?.groupID) {
void closeWorkspace(selected)
return
}
if (selected) closedTabs = recordClosedSessionTab(closedTabs, selected, index)
remove(target, true)
},
reopen() {
@@ -397,7 +488,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
},
move(sessionID: string, index: number) {
if (!enabled()) return
const session = root(sessionID)
const session = tab(sessionID)?.groupID ? sessionID : root(sessionID)
if (moveSessionTab(state().tabs, session, index) === state().tabs) return
update((draft) => {
draft.tabs = moveSessionTab(draft.tabs, session, index)
@@ -406,19 +497,19 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
cycle(direction: 1 | -1) {
if (!enabled()) return
const tab = cycleSessionTab(state().tabs, current(), direction)
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
if (tab) navigate(tab.sessionID)
},
cycleUnread(direction: 1 | -1) {
if (!enabled()) return
const tab = cycleSessionTab(state().tabs, current(), direction, (tab) =>
Boolean(status(tab.sessionID).unread || status(tab.sessionID).attention),
)
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
if (tab) navigate(tab.sessionID)
},
selectIndex(index: number) {
if (!enabled()) return
const tab = state().tabs[index]
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
if (tab) navigate(tab.sessionID)
},
}
},
@@ -2,15 +2,24 @@ import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, createSignal, Match, Show, Switch } from "solid-js"
import { contextUsage, formatContextUsage } from "../../util/session"
import { useTerminalDimensions } from "@opentui/solid"
import { usePaneLayout } from "../../context/pane-layout"
import { useSessionTabs } from "../../context/session-tabs"
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
})
export function PromptFooter(props: { context: Plugin.Context; sessionID?: string; mode: "normal" | "shell" }) {
export function PromptFooter(props: {
context: Plugin.Context
sessionID?: string
mode: "normal" | "shell"
onNewTerminal?: () => Promise<void>
}) {
const dimensions = useTerminalDimensions()
const [liveHovered, setLiveHovered] = createSignal(false)
const [terminalHovered, setTerminalHovered] = createSignal(false)
const [terminalPending, setTerminalPending] = createSignal(false)
const subagents = createMemo(() => {
if (!props.sessionID) return 0
const count = props.context.data.session
@@ -42,51 +51,71 @@ export function PromptFooter(props: { context: Plugin.Context; sessionID?: strin
})
const live = createMemo(() => Boolean(subagents() || shells()))
const shortcut = (id: string) => props.context.keymap.shortcuts(id)[0]
const newTerminal = async () => {
if (terminalPending() || !props.onNewTerminal) return
setTerminalPending(true)
await props.onNewTerminal().finally(() => setTerminalPending(false))
}
return (
<Switch>
<Match when={props.mode === "normal"}>
<Switch>
<Match when={live() || status().length > 0}>
<box flexDirection="row" flexShrink={1} minWidth={0}>
<Show when={live()}>
<box
flexShrink={0}
onMouseOver={() => setLiveHovered(true)}
onMouseOut={() => setLiveHovered(false)}
onMouseUp={() => props.context.keymap.dispatch("session.child.first")}
>
<text
fg={liveHovered() ? props.context.theme.text.default : props.context.theme.text.subdued}
wrapMode="none"
>
<Show when={shortcut("session.child.first")}>
{(value) => <span style={{ fg: props.context.theme.text.default }}>{value()} </span>}
</Show>
<Show when={subagents()}>{(value) => <>{value()}</>}</Show>
<Show when={subagents() && shells()}> · </Show>
<Show when={shells()}>{(value) => <>{value()}</>}</Show>
</text>
</box>
</Show>
<Show when={status().length > 0}>
<text fg={props.context.theme.text.subdued} wrapMode="none" truncate flexShrink={1}>
<Show when={live()}> · </Show>
{status().join(" · ")}
</text>
</Show>
</box>
</Match>
<Match when={dimensions().width >= 44}>
<text fg={props.context.theme.text.default} flexShrink={0}>
{shortcut("agent.cycle")} <span style={{ fg: props.context.theme.text.subdued }}>agents</span>
<Show
when={props.sessionID}
fallback={
<text
fg={terminalHovered() ? props.context.theme.text.default : props.context.theme.text.subdued}
selectable={false}
onMouseOver={() => setTerminalHovered(true)}
onMouseOut={() => setTerminalHovered(false)}
onMouseUp={() => void newTerminal()}
>
{terminalPending() ? "starting terminal" : "new terminal"}
</text>
</Match>
</Switch>
<Show when={dimensions().width >= 44}>
<text fg={props.context.theme.text.default} flexShrink={0}>
{shortcut("command.palette.show")} <span style={{ fg: props.context.theme.text.subdued }}>commands</span>
</text>
}
>
<Switch>
<Match when={live() || status().length > 0}>
<box flexDirection="row" flexShrink={1} minWidth={0}>
<Show when={live()}>
<box
flexShrink={0}
onMouseOver={() => setLiveHovered(true)}
onMouseOut={() => setLiveHovered(false)}
onMouseUp={() => props.context.keymap.dispatch("session.child.first")}
>
<text
fg={liveHovered() ? props.context.theme.text.default : props.context.theme.text.subdued}
wrapMode="none"
>
<Show when={shortcut("session.child.first")}>
{(value) => <span style={{ fg: props.context.theme.text.default }}>{value()} </span>}
</Show>
<Show when={subagents()}>{(value) => <>{value()}</>}</Show>
<Show when={subagents() && shells()}> · </Show>
<Show when={shells()}>{(value) => <>{value()}</>}</Show>
</text>
</box>
</Show>
<Show when={status().length > 0}>
<text fg={props.context.theme.text.subdued} wrapMode="none" truncate flexShrink={1}>
<Show when={live()}> · </Show>
{status().join(" · ")}
</text>
</Show>
</box>
</Match>
<Match when={dimensions().width >= 44}>
<text fg={props.context.theme.text.default} flexShrink={0}>
{shortcut("agent.cycle")} <span style={{ fg: props.context.theme.text.subdued }}>agents</span>
</text>
</Match>
</Switch>
<Show when={dimensions().width >= 44}>
<text fg={props.context.theme.text.default} flexShrink={0}>
{shortcut("command.palette.show")} <span style={{ fg: props.context.theme.text.subdued }}>commands</span>
</text>
</Show>
</Show>
</Match>
<Match when={props.mode === "shell"}>
@@ -101,12 +130,31 @@ export function PromptFooter(props: { context: Plugin.Context; sessionID?: strin
)
}
function PromptFooterSlot(props: { context: Plugin.Context; sessionID?: string; mode: "normal" | "shell" }) {
const panes = usePaneLayout()
const tabs = useSessionTabs()
const newTerminal = async () => {
const location = props.context.location
if (!location || !tabs.enabled()) return
await panes
.newTerminalWorkspace(location)
.then(({ group }) => tabs.openWorkspace(group.id, location.directory))
.catch((error) => {
props.context.ui.toast.show({
variant: "error",
message: error instanceof Error ? error.message : "Failed to create terminal",
})
})
}
return <PromptFooter {...props} onNewTerminal={newTerminal} />
}
export default Plugin.define({
id: "opencode.prompt.footer",
setup(context) {
context.ui.slot({
append: "prompt.footer",
render: (props) => <PromptFooter context={context} sessionID={props.sessionID} mode={props.mode} />,
render: (props) => <PromptFooterSlot context={context} sessionID={props.sessionID} mode={props.mode} />,
})
},
})
+12 -7
View File
@@ -168,17 +168,22 @@ export function createPluginContext(input: {
host.route.navigate(destination)
},
current() {
if (host.route.data.type === "workspace") return { type: "home" }
return host.route.data
},
},
tabs: {
enabled: host.sessionTabs.enabled,
list: () =>
host.sessionTabs.tabs().map((tab) => ({
...tab,
active: host.sessionTabs.current() === tab.sessionID,
...host.sessionTabs.status(tab.sessionID),
})),
host.sessionTabs
.tabs()
.filter((tab) => !tab.groupID)
.map((tab) => ({
sessionID: tab.sessionID,
title: tab.title,
active: host.sessionTabs.current() === tab.sessionID,
...host.sessionTabs.status(tab.sessionID),
})),
open(sessionID) {
if (!host.sessionTabs.enabled()) return false
host.sessionTabs.select(sessionID)
@@ -186,14 +191,14 @@ export function createPluginContext(input: {
},
focus(sessionID) {
if (!host.sessionTabs.enabled()) return false
if (!host.sessionTabs.tabs().some((tab) => tab.sessionID === sessionID)) return false
if (!host.sessionTabs.tabs().some((tab) => !tab.groupID && tab.sessionID === sessionID)) return false
host.sessionTabs.select(sessionID)
return true
},
close(sessionID) {
if (!host.sessionTabs.enabled()) return false
const target = sessionID ?? host.sessionTabs.current()
if (!target || !host.sessionTabs.tabs().some((tab) => tab.sessionID === target)) return false
if (!target || !host.sessionTabs.tabs().some((tab) => !tab.groupID && tab.sessionID === target)) return false
host.sessionTabs.close(target)
return true
},
+12
View File
@@ -110,6 +110,7 @@ import { createDelayedPresence } from "../../util/delayed-presence"
import { SessionLocationMissing } from "./location-missing"
import { isRecord } from "../../util/record"
import { createHistoryPrepend } from "./history"
import { usePaneLayout } from "../../context/pane-layout"
addDefaultParsers(parsers.parsers)
@@ -280,6 +281,7 @@ export function Session(props: { verticalTabsWidth: number }) {
const [navigationSlack, setNavigationSlack] = createSignal(0)
const [synced, setSynced] = createSignal(false)
const sessionTabs = useSessionTabs()
const panes = usePaneLayout()
const [awayFromBottom, setAwayFromBottom] = createSignal(false)
const [latestHovered, setLatestHovered] = createSignal(false)
let ensureAllRowsPending: (() => void)[] | undefined
@@ -891,6 +893,16 @@ export function Session(props: { verticalTabsWidth: number }) {
dialog.clear()
},
},
{
title: "New terminal",
id: "session.terminal",
group: "Session",
slash: { name: "terminal" },
run: async () => {
dialog.clear()
await panes.newTerminal(route.sessionID, { focus: false }).catch(toast.error)
},
},
{
title: (() => {
const next = nextThinkingMode(thinkingMode())
@@ -0,0 +1,73 @@
import { describe, expect, test } from "bun:test"
import type { GroupItem } from "@opencode-ai/client"
import {
defaultPaneLayout,
paneLayoutItems,
reconcilePaneLayout,
removePaneLayoutItem,
} from "../../src/context/pane-layout-model"
const session = (id: string): GroupItem => ({ type: "session", id })
const terminal = (id: string): GroupItem => ({ type: "terminal", id })
describe("pane layout model", () => {
test("builds a master pane with an evenly divided right stack", () => {
const items = [session("ses_1"), terminal("pty_1"), terminal("pty_2"), terminal("pty_3")]
const layout = defaultPaneLayout(items)
expect(layout).toEqual({
type: "split",
direction: "horizontal",
ratio: 0.5,
first: { type: "item", item: items[0] },
second: {
type: "split",
direction: "vertical",
ratio: 1 / 3,
first: { type: "item", item: items[1] },
second: {
type: "split",
direction: "vertical",
ratio: 0.5,
first: { type: "item", item: items[2] },
second: { type: "item", item: items[3] },
},
},
})
expect(paneLayoutItems(layout!)).toEqual(items)
})
test("preserves stored split ratios when backend items still match", () => {
const items = [session("ses_1"), terminal("pty_1")]
const layout = defaultPaneLayout(items)!
if (layout.type !== "split") throw new Error("Expected a split")
layout.ratio = 0.65
expect(reconcilePaneLayout(layout, items)).toMatchObject({ ratio: 0.65 })
})
test("rebuilds the default layout when backend order changes", () => {
const items = [session("ses_1"), terminal("pty_1")]
const layout = defaultPaneLayout(items)!
if (layout.type !== "split") throw new Error("Expected a split")
layout.ratio = 0.65
expect(reconcilePaneLayout(layout, items.toReversed())).toMatchObject({ ratio: 0.5 })
})
test("removes a pane and preserves the remaining BSP layout", () => {
const items = [session("ses_1"), terminal("pty_1"), terminal("pty_2")]
const layout = defaultPaneLayout(items)!
if (layout.type !== "split" || layout.second.type !== "split") throw new Error("Expected nested splits")
layout.ratio = 0.65
layout.second.ratio = 0.3
expect(removePaneLayoutItem(layout, items[1])).toEqual({
type: "split",
direction: "horizontal",
ratio: 0.65,
first: { type: "item", item: items[0] },
second: { type: "item", item: items[2] },
})
})
})
@@ -1,6 +1,6 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client"
import type { GroupItem, OpenCodeEvent, PersistentPtyInfo } from "@opencode-ai/client"
import { testRender } from "@opentui/solid"
import { mkdirSync, watch } from "fs"
import path from "path"
@@ -41,6 +41,8 @@ async function renderSessionTabs(
newLocation?: "launch" | "inherit"
tabsEnabled?: boolean
viewFailures?: number
groups?: Record<string, GroupItem[]>
terminals?: Record<string, PersistentPtyInfo[]>
},
) {
const temporary = options?.state ? undefined : await tmpdir()
@@ -70,6 +72,8 @@ async function renderSessionTabs(
const sessionTimes = Object.fromEntries(
Object.entries(options?.sessionTimes ?? {}).map(([sessionID, time]) => [sessionID, { ...time }]),
)
const removedGroups: string[] = []
const removedTerminals: string[] = []
const calls = createFetch(async (url, request) => {
if (url.pathname === "/api/location") {
const requested = url.searchParams.get("location[directory]") ?? directory
@@ -106,6 +110,20 @@ async function renderSessionTabs(
time.viewed = Math.min(payload.idle, time.idle ?? payload.idle)
return new Response(null, { status: 204 })
}
const terminalGroupID = url.pathname.match(/^\/api\/pty-group\/([^/]+)\/terminal$/)?.[1]
if (terminalGroupID && request.method === "GET") return json({ data: options?.terminals?.[terminalGroupID] ?? [] })
const groupID = url.pathname.match(/^\/api\/pty-group\/([^/]+)$/)?.[1]
if (groupID && request.method === "GET")
return json({ data: { id: groupID, items: options?.groups?.[groupID] ?? [] } })
if (groupID && request.method === "DELETE") {
removedGroups.push(groupID)
return new Response(null, { status: 204 })
}
const ptyID = url.pathname.match(/^\/api\/persistent-pty\/([^/]+)$/)?.[1]
if (ptyID && request.method === "DELETE") {
removedTerminals.push(ptyID)
return new Response(null, { status: 204 })
}
const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1]
if (!sessionID) return undefined
sessions.push(sessionID)
@@ -180,6 +198,8 @@ async function renderSessionTabs(
viewWatermarks,
locations,
vcsLocations,
removedGroups,
removedTerminals,
state,
setSessionTime(sessionID: string, time: { idle?: number; viewed?: number }) {
sessionTimes[sessionID] = time
@@ -641,3 +661,58 @@ test("add inherits the current session location when configured", async () => {
await setup.destroy()
}
})
test("closing a terminal-only workspace tab terminates its terminals and removes its group", async () => {
const groupID = "grp_terminal"
const terminal = {
id: "pty_terminal",
title: "Terminal",
command: "/bin/sh",
args: [],
cwd: directory,
status: "running" as const,
pid: 123,
groupID,
foregroundProcess: null,
size: { cols: 80, rows: 24 },
output: { head: 0, tail: 0 },
}
const setup = await renderSessionTabs("first", {
home: true,
groups: { [groupID]: [{ type: "terminal", id: terminal.id }] },
terminals: { [groupID]: [terminal] },
})
try {
setup.tabs.openWorkspace(groupID, directory)
await wait(() => setup.tabs.current() === groupID && setup.tabs.tabs().some((tab) => tab.groupID === groupID))
setup.tabs.close(groupID)
await wait(() => setup.removedGroups.includes(groupID) && !setup.tabs.tabs().some((tab) => tab.groupID === groupID))
expect(setup.removedTerminals).toEqual([terminal.id])
expect(setup.route.data).toEqual({ type: "home" })
} finally {
await setup.destroy()
}
})
test("closing a workspace tab with a session only detaches it", async () => {
const groupID = "grp_session"
const setup = await renderSessionTabs("first", {
home: true,
groups: { [groupID]: [{ type: "session", id: "ses_one" }] },
})
try {
setup.tabs.openWorkspace(groupID, directory)
await wait(() => setup.tabs.current() === groupID && setup.tabs.tabs().some((tab) => tab.groupID === groupID))
setup.tabs.close(groupID)
await wait(() => !setup.tabs.tabs().some((tab) => tab.groupID === groupID))
expect(setup.removedGroups).toEqual([])
expect(setup.removedTerminals).toEqual([])
expect(setup.route.data).toEqual({ type: "home" })
} finally {
await setup.destroy()
}
})
@@ -59,3 +59,28 @@ test("prompt footer separates simultaneous subagent, shell, and usage status", a
app.renderer.destroy()
}
})
test("home prompt footer offers a new terminal instead of agent and command hints", async () => {
const color = RGBA.fromInts(200, 200, 200)
const context = {
theme: { text: { default: color, subdued: color } },
keymap: { shortcuts: () => [] },
data: {
session: { family: () => [], status: () => "idle" },
shell: { list: () => [] },
},
} as unknown as Context
const app = await testRender(() => <PromptFooter context={context} mode="normal" onNewTerminal={async () => {}} />, {
width: 80,
height: 2,
})
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain("new terminal")
expect(app.captureCharFrame()).not.toContain("agents")
expect(app.captureCharFrame()).not.toContain("commands")
} finally {
app.renderer.destroy()
}
})
+1
View File
@@ -161,6 +161,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
if (url.pathname === "/session") return json([])
if (url.pathname === "/vcs") return json({ branch: "main" })
if (url.pathname === "/api/experimental/migration/v1") return json({ status: "completed" })
if (url.pathname === "/api/pty-group") return json([])
throw new Error(`unexpected request: ${url.pathname}`)
}
fetch.preconnect = () => {}