Compare commits

..

7 Commits

Author SHA1 Message Date
Kit Langton 97dc7a8a70 refactor(core): simplify patch target resolution 2026-08-14 20:32:48 -04:00
Kit Langton c0ba36a50a fix(core): unify patch path resolution 2026-08-14 20:24:53 -04:00
opencode-agent[bot] 08a6d7b619 docs: fix package manager code blocks (#42313)
Co-authored-by: Kit Langton <7587245+kitlangton@users.noreply.github.com>
2026-08-14 20:03:48 -04:00
opencode-agent[bot] 1580e7cc3a chore: generate 2026-08-14 23:41:12 +00:00
Aiden Cline 8640ea3374 minimize system prompt (#42638) 2026-08-14 18:39:27 -05:00
opencode-agent[bot] e6a3b951b5 chore: generate 2026-08-14 22:19:55 +00:00
James Long 62b67f2761 refactor(protocol): move worktree routes out of experimental namespace (#42656) 2026-08-14 18:18:40 -04:00
62 changed files with 522 additions and 4131 deletions
+2 -2
View File
@@ -278,7 +278,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
}
if (path === "/api/project/current")
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
const worktree = path.match(/^\/api\/experimental\/project\/([^/]+)\/worktree$/)?.[1]
const worktree = path.match(/^\/api\/worktree\/([^/]+)$/)?.[1]
if (worktree && route.request().method() === "GET")
return json(route, [
{ directory: config.directory },
@@ -294,7 +294,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
}
if (worktree && route.request().method() === "DELETE")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (/^\/api\/experimental\/project\/[^/]+\/worktree\/refresh$/.test(path))
if (/^\/api\/worktree\/[^/]+\/refresh$/.test(path))
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (path === "/api/permission/request")
return json(route, {
@@ -4,13 +4,11 @@ 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,13 +3,10 @@ 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* () {
const options = yield* ServiceConfig.options()
yield* ServerConnection.shutdownPersistentPty(options).pipe(Effect.ignore)
yield* Service.stop(options)
yield* Service.stop(yield* ServiceConfig.options())
}),
)
@@ -56,22 +56,12 @@ 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 })
+58 -265
View File
@@ -32,7 +32,6 @@ import type { FileSystem } from "@opencode-ai/schema/filesystem"
import type { Command } from "@opencode-ai/schema/command"
import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
import type { Pty } from "@opencode-ai/schema/pty"
import type { PtyTicket } from "@opencode-ai/schema/pty-ticket"
import type { Reference } from "@opencode-ai/schema/reference"
import type { Worktree } from "@opencode-ai/schema/worktree"
import type { Vcs } from "@opencode-ai/schema/vcs"
@@ -1436,249 +1435,44 @@ export interface PtyApi<E = never> {
readonly remove: PtyRemoveOperation<E>
}
export type Endpoint21_0Output = 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<Endpoint21_0Output, E>
export type Endpoint21_1Input = {
readonly items?:
| ReadonlyArray<
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
>
| undefined
}
export type Endpoint21_1Output = {
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?: Endpoint21_1Input,
) => Effect.Effect<Endpoint21_1Output, E>
export type Endpoint21_2Input = { readonly groupID: string & Brand.Brand<"GroupID"> }
export type Endpoint21_2Output = {
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: Endpoint21_2Input,
) => Effect.Effect<Endpoint21_2Output, E>
export type Endpoint21_3Input = {
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 Endpoint21_3Output = {
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: Endpoint21_3Input,
) => Effect.Effect<Endpoint21_3Output, E>
export type Endpoint21_4Input = { readonly groupID: string & Brand.Brand<"GroupID"> }
export type Endpoint21_4Output = void
export type ServerPersistentPtyGroupRemoveOperation<E = never> = (
input: Endpoint21_4Input,
) => Effect.Effect<Endpoint21_4Output, E>
export type Endpoint21_5Input = { readonly groupID: string & Brand.Brand<"GroupID"> }
export type Endpoint21_5Output = 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 size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}>
export type ServerPersistentPtyListOperation<E = never> = (
input: Endpoint21_5Input,
) => Effect.Effect<Endpoint21_5Output, E>
export type Endpoint21_6Input = {
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 Endpoint21_6Output = {
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 size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
export type ServerPersistentPtyCreateOperation<E = never> = (
input: Endpoint21_6Input,
) => Effect.Effect<Endpoint21_6Output, E>
export type Endpoint21_7Output = void
export type ServerPersistentPtyShutdownOperation<E = never> = () => Effect.Effect<Endpoint21_7Output, E>
export type Endpoint21_8Input = { readonly ptyID: Pty.ID }
export type Endpoint21_8Output = {
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 size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
export type ServerPersistentPtyGetOperation<E = never> = (
input: Endpoint21_8Input,
) => Effect.Effect<Endpoint21_8Output, E>
export type Endpoint21_9Input = {
readonly ptyID: Pty.ID
readonly attachmentID?: string | undefined
readonly size: { readonly cols: number; readonly rows: number }
}
export type Endpoint21_9Output = {
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 size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
export type ServerPersistentPtyUpdateOperation<E = never> = (
input: Endpoint21_9Input,
) => Effect.Effect<Endpoint21_9Output, E>
export type Endpoint21_10Input = { readonly ptyID: Pty.ID }
export type Endpoint21_10Output = {
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 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: Endpoint21_10Input,
) => Effect.Effect<Endpoint21_10Output, E>
export type Endpoint21_11Input = { readonly ptyID: Pty.ID }
export type Endpoint21_11Output = void
export type ServerPersistentPtyRemoveOperation<E = never> = (
input: Endpoint21_11Input,
) => Effect.Effect<Endpoint21_11Output, E>
export type Endpoint21_12Input = { readonly ptyID: Pty.ID }
export type Endpoint21_12Output = PtyTicket.ConnectToken
export type ServerPersistentPtyConnectTokenOperation<E = never> = (
input: Endpoint21_12Input,
) => Effect.Effect<Endpoint21_12Output, E>
export type Endpoint21_13Input = { readonly ptyID: Pty.ID }
export type Endpoint21_13Output = boolean
export type ServerPersistentPtyConnectOperation<E = never> = (
input: Endpoint21_13Input,
) => Effect.Effect<Endpoint21_13Output, 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 Endpoint22_0Input = {
export type Endpoint21_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint22_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Shell.Info> }
export type ShellListOperation<E = never> = (input?: Endpoint22_0Input) => Effect.Effect<Endpoint22_0Output, E>
export type Endpoint21_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Shell.Info> }
export type ShellListOperation<E = never> = (input?: Endpoint21_0Input) => Effect.Effect<Endpoint21_0Output, E>
export type Endpoint22_1Input = {
export type Endpoint21_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly command: string
readonly cwd?: string | undefined
readonly timeout: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
}
export type Endpoint22_1Output = { readonly location: Location.Info; readonly data: Shell.Info }
export type ShellCreateOperation<E = never> = (input: Endpoint22_1Input) => Effect.Effect<Endpoint22_1Output, E>
export type Endpoint21_1Output = { readonly location: Location.Info; readonly data: Shell.Info }
export type ShellCreateOperation<E = never> = (input: Endpoint21_1Input) => Effect.Effect<Endpoint21_1Output, E>
export type Endpoint22_2Input = {
export type Endpoint21_2Input = {
readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint22_2Output = { readonly location: Location.Info; readonly data: Shell.Info }
export type ShellGetOperation<E = never> = (input: Endpoint22_2Input) => Effect.Effect<Endpoint22_2Output, E>
export type Endpoint21_2Output = { readonly location: Location.Info; readonly data: Shell.Info }
export type ShellGetOperation<E = never> = (input: Endpoint21_2Input) => Effect.Effect<Endpoint21_2Output, E>
export type Endpoint22_3Input = {
export type Endpoint21_3Input = {
readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly timeout: number
}
export type Endpoint22_3Output = { readonly location: Location.Info; readonly data: Shell.Info }
export type ShellTimeoutOperation<E = never> = (input: Endpoint22_3Input) => Effect.Effect<Endpoint22_3Output, E>
export type Endpoint21_3Output = { readonly location: Location.Info; readonly data: Shell.Info }
export type ShellTimeoutOperation<E = never> = (input: Endpoint21_3Input) => Effect.Effect<Endpoint21_3Output, E>
export type Endpoint22_4Input = {
export type Endpoint21_4Input = {
readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly cursor?: number | undefined
readonly limit?: number | undefined
}
export type Endpoint22_4Output = {
export type Endpoint21_4Output = {
readonly location: Location.Info
readonly data: {
readonly output: string
@@ -1687,14 +1481,14 @@ export type Endpoint22_4Output = {
readonly truncated: boolean
}
}
export type ShellOutputOperation<E = never> = (input: Endpoint22_4Input) => Effect.Effect<Endpoint22_4Output, E>
export type ShellOutputOperation<E = never> = (input: Endpoint21_4Input) => Effect.Effect<Endpoint21_4Output, E>
export type Endpoint22_5Input = {
export type Endpoint21_5Input = {
readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint22_5Output = void
export type ShellRemoveOperation<E = never> = (input: Endpoint22_5Input) => Effect.Effect<Endpoint22_5Output, E>
export type Endpoint21_5Output = void
export type ShellRemoveOperation<E = never> = (input: Endpoint21_5Input) => Effect.Effect<Endpoint21_5Output, E>
export interface ShellApi<E = never> {
readonly list: ShellListOperation<E>
@@ -1705,41 +1499,41 @@ export interface ShellApi<E = never> {
readonly remove: ShellRemoveOperation<E>
}
export type Endpoint23_0Input = {
export type Endpoint22_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint23_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Reference.Info> }
export type ReferenceListOperation<E = never> = (input?: Endpoint23_0Input) => Effect.Effect<Endpoint23_0Output, E>
export type Endpoint22_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Reference.Info> }
export type ReferenceListOperation<E = never> = (input?: Endpoint22_0Input) => Effect.Effect<Endpoint22_0Output, E>
export interface ReferenceApi<E = never> {
readonly list: ReferenceListOperation<E>
}
export type Endpoint24_0Input = { readonly projectID: Project.ID }
export type Endpoint24_0Output = Worktree.List
export type WorktreeListOperation<E = never> = (input: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
export type Endpoint23_0Input = { readonly projectID: Project.ID }
export type Endpoint23_0Output = Worktree.List
export type WorktreeListOperation<E = never> = (input: Endpoint23_0Input) => Effect.Effect<Endpoint23_0Output, E>
export type Endpoint24_1Input = {
export type Endpoint23_1Input = {
readonly projectID: Project.ID
readonly strategy: Worktree.StrategyID
readonly from?: AbsolutePath | undefined
readonly directory: AbsolutePath
readonly name?: string | undefined
}
export type Endpoint24_1Output = Worktree.Info
export type WorktreeCreateOperation<E = never> = (input: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
export type Endpoint23_1Output = Worktree.Info
export type WorktreeCreateOperation<E = never> = (input: Endpoint23_1Input) => Effect.Effect<Endpoint23_1Output, E>
export type Endpoint24_2Input = {
export type Endpoint23_2Input = {
readonly projectID: Project.ID
readonly directory: AbsolutePath
readonly force: boolean
}
export type Endpoint24_2Output = void
export type WorktreeRemoveOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
export type Endpoint23_2Output = void
export type WorktreeRemoveOperation<E = never> = (input: Endpoint23_2Input) => Effect.Effect<Endpoint23_2Output, E>
export type Endpoint24_3Input = { readonly projectID: Project.ID }
export type Endpoint24_3Output = void
export type WorktreeRefreshOperation<E = never> = (input: Endpoint24_3Input) => Effect.Effect<Endpoint24_3Output, E>
export type Endpoint23_3Input = { readonly projectID: Project.ID }
export type Endpoint23_3Output = void
export type WorktreeRefreshOperation<E = never> = (input: Endpoint23_3Input) => Effect.Effect<Endpoint23_3Output, E>
export interface WorktreeApi<E = never> {
readonly list: WorktreeListOperation<E>
@@ -1748,25 +1542,25 @@ export interface WorktreeApi<E = never> {
readonly refresh: WorktreeRefreshOperation<E>
}
export type Endpoint25_0Input = {
export type Endpoint24_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
export type VcsGetOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
export type Endpoint24_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
export type VcsGetOperation<E = never> = (input?: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
export type Endpoint25_1Input = {
export type Endpoint24_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
export type VcsStatusOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
export type Endpoint24_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
export type VcsStatusOperation<E = never> = (input?: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
export type Endpoint25_2Input = {
export type Endpoint24_2Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: Vcs.Mode
readonly context?: number | undefined
}
export type Endpoint25_2Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
export type VcsDiffOperation<E = never> = (input: Endpoint25_2Input) => Effect.Effect<Endpoint25_2Output, E>
export type Endpoint24_2Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
export type VcsDiffOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
export interface VcsApi<E = never> {
readonly get: VcsGetOperation<E>
@@ -1774,20 +1568,20 @@ export interface VcsApi<E = never> {
readonly diff: VcsDiffOperation<E>
}
export type Endpoint26_0Output = ReadonlyArray<Location.Ref>
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
export type Endpoint25_0Output = ReadonlyArray<Location.Ref>
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint25_0Output, E>
export type Endpoint26_1Input = {
export type Endpoint25_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint26_1Output = void
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint26_1Input) => Effect.Effect<Endpoint26_1Output, E>
export type Endpoint25_1Output = void
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
export interface DebugApi<E = never> {
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
}
export type Endpoint27_0Output =
export type Endpoint26_0Output =
| { readonly status: "required" | "completed" }
| {
readonly status: "running"
@@ -1798,36 +1592,36 @@ export type Endpoint27_0Output =
}
}
| { readonly status: "error"; readonly error: string }
export type MigrationV1StatusOperation<E = never> = () => Effect.Effect<Endpoint27_0Output, E>
export type MigrationV1StatusOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
export interface MigrationApi<E = never> {
readonly v1: { readonly status: MigrationV1StatusOperation<E> }
}
export type Endpoint28_0Input = {
export type Endpoint27_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint28_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E>
export type Endpoint27_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint27_0Input) => Effect.Effect<Endpoint27_0Output, E>
export type Endpoint28_1Input = {
export type Endpoint27_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string
readonly providerID?: WebSearch.ID | undefined
}
export type Endpoint28_1Output = { readonly location: Location.Info; readonly data: WebSearch.Response }
export type WebsearchQueryOperation<E = never> = (input: Endpoint28_1Input) => Effect.Effect<Endpoint28_1Output, E>
export type Endpoint27_1Output = { readonly location: Location.Info; readonly data: WebSearch.Response }
export type WebsearchQueryOperation<E = never> = (input: Endpoint27_1Input) => Effect.Effect<Endpoint27_1Output, E>
export interface WebsearchApi<E = never> {
readonly providers: WebsearchProvidersOperation<E>
readonly query: WebsearchQueryOperation<E>
}
export type Endpoint29_0Input = {
export type Endpoint28_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint29_0Output = ReadonlyArray<Config.Entry>
export type ConfigGetOperation<E = never> = (input?: Endpoint29_0Input) => Effect.Effect<Endpoint29_0Output, E>
export type Endpoint28_0Output = ReadonlyArray<Config.Entry>
export type ConfigGetOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E>
export interface ConfigApi<E = never> {
readonly get: ConfigGetOperation<E>
@@ -1855,7 +1649,6 @@ 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>
+81 -238
View File
@@ -186,6 +186,7 @@ import type {
Endpoint20_3Output,
Endpoint20_4Input,
Endpoint20_4Output,
Endpoint21_0Input,
Endpoint21_0Output,
Endpoint21_1Input,
Endpoint21_1Output,
@@ -197,59 +198,32 @@ import type {
Endpoint21_4Output,
Endpoint21_5Input,
Endpoint21_5Output,
Endpoint21_6Input,
Endpoint21_6Output,
Endpoint21_7Output,
Endpoint21_8Input,
Endpoint21_8Output,
Endpoint21_9Input,
Endpoint21_9Output,
Endpoint21_10Input,
Endpoint21_10Output,
Endpoint21_11Input,
Endpoint21_11Output,
Endpoint21_12Input,
Endpoint21_12Output,
Endpoint21_13Input,
Endpoint21_13Output,
Endpoint22_0Input,
Endpoint22_0Output,
Endpoint22_1Input,
Endpoint22_1Output,
Endpoint22_2Input,
Endpoint22_2Output,
Endpoint22_3Input,
Endpoint22_3Output,
Endpoint22_4Input,
Endpoint22_4Output,
Endpoint22_5Input,
Endpoint22_5Output,
Endpoint23_0Input,
Endpoint23_0Output,
Endpoint23_1Input,
Endpoint23_1Output,
Endpoint23_2Input,
Endpoint23_2Output,
Endpoint23_3Input,
Endpoint23_3Output,
Endpoint24_0Input,
Endpoint24_0Output,
Endpoint24_1Input,
Endpoint24_1Output,
Endpoint24_2Input,
Endpoint24_2Output,
Endpoint24_3Input,
Endpoint24_3Output,
Endpoint25_0Input,
Endpoint25_0Output,
Endpoint25_1Input,
Endpoint25_1Output,
Endpoint25_2Input,
Endpoint25_2Output,
Endpoint26_0Output,
Endpoint26_1Input,
Endpoint26_1Output,
Endpoint27_0Input,
Endpoint27_0Output,
Endpoint27_1Input,
Endpoint27_1Output,
Endpoint28_0Input,
Endpoint28_0Output,
Endpoint28_1Input,
Endpoint28_1Output,
Endpoint29_0Input,
Endpoint29_0Output,
} from "../api/api.js"
import { ClientError } from "./client-error.js"
@@ -1123,158 +1097,28 @@ const adaptGroup20 = (raw: RawClient["server.pty"]) => ({
remove: Endpoint20_4(raw),
})
const Endpoint21_0 = (raw: RawClient["server.persistentPty"]) => () =>
const Endpoint21_0 = (raw: RawClient["server.shell"]) => (input?: Endpoint21_0Input) =>
preserveEffect<Endpoint21_0Output>()(
raw["persistentPty.group.list"]({}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint21_1 = (raw: RawClient["server.persistentPty"]) => (input?: Endpoint21_1Input) =>
preserveEffect<Endpoint21_1Output>()(
raw["persistentPty.group.create"]({ payload: { items: input?.["items"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint21_2 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_2Input) =>
preserveEffect<Endpoint21_2Output>()(
raw["persistentPty.group.get"]({ params: { groupID: input["groupID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint21_3 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_3Input) =>
preserveEffect<Endpoint21_3Output>()(
raw["persistentPty.group.set"]({ params: { groupID: input["groupID"] }, payload: { items: input["items"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint21_4 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_4Input) =>
preserveEffect<Endpoint21_4Output>()(
raw["persistentPty.group.remove"]({ params: { groupID: input["groupID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint21_5 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_5Input) =>
preserveEffect<Endpoint21_5Output>()(
raw["persistentPty.list"]({ params: { groupID: input["groupID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint21_6 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_6Input) =>
preserveEffect<Endpoint21_6Output>()(
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 Endpoint21_7 = (raw: RawClient["server.persistentPty"]) => () =>
preserveEffect<Endpoint21_7Output>()(raw["persistentPty.shutdown"]({}).pipe(Effect.mapError(mapClientError)))
const Endpoint21_8 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_8Input) =>
preserveEffect<Endpoint21_8Output>()(
raw["persistentPty.get"]({ params: { ptyID: input["ptyID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint21_9 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_9Input) =>
preserveEffect<Endpoint21_9Output>()(
raw["persistentPty.update"]({
params: { ptyID: input["ptyID"] },
payload: { attachmentID: input["attachmentID"], size: input["size"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint21_10 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_10Input) =>
preserveEffect<Endpoint21_10Output>()(
raw["persistentPty.snapshot"]({ params: { ptyID: input["ptyID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint21_11 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_11Input) =>
preserveEffect<Endpoint21_11Output>()(
raw["persistentPty.remove"]({ params: { ptyID: input["ptyID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint21_12 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_12Input) =>
preserveEffect<Endpoint21_12Output>()(
raw["persistentPty.connectToken"]({ params: { ptyID: input["ptyID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint21_13 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_13Input) =>
preserveEffect<Endpoint21_13Output>()(
raw["persistentPty.connect"]({ params: { ptyID: input["ptyID"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup21 = (raw: RawClient["server.persistentPty"]) => ({
group: {
list: Endpoint21_0(raw),
create: Endpoint21_1(raw),
get: Endpoint21_2(raw),
set: Endpoint21_3(raw),
remove: Endpoint21_4(raw),
},
list: Endpoint21_5(raw),
create: Endpoint21_6(raw),
shutdown: Endpoint21_7(raw),
get: Endpoint21_8(raw),
update: Endpoint21_9(raw),
snapshot: Endpoint21_10(raw),
remove: Endpoint21_11(raw),
connectToken: Endpoint21_12(raw),
connect: Endpoint21_13(raw),
})
const Endpoint22_0 = (raw: RawClient["server.shell"]) => (input?: Endpoint22_0Input) =>
preserveEffect<Endpoint22_0Output>()(
raw["shell.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint22_1 = (raw: RawClient["server.shell"]) => (input: Endpoint22_1Input) =>
preserveEffect<Endpoint22_1Output>()(
const Endpoint21_1 = (raw: RawClient["server.shell"]) => (input: Endpoint21_1Input) =>
preserveEffect<Endpoint21_1Output>()(
raw["shell.create"]({
query: { location: input["location"] },
payload: { command: input["command"], cwd: input["cwd"], timeout: input["timeout"], metadata: input["metadata"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint22_2 = (raw: RawClient["server.shell"]) => (input: Endpoint22_2Input) =>
preserveEffect<Endpoint22_2Output>()(
const Endpoint21_2 = (raw: RawClient["server.shell"]) => (input: Endpoint21_2Input) =>
preserveEffect<Endpoint21_2Output>()(
raw["shell.get"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint22_3 = (raw: RawClient["server.shell"]) => (input: Endpoint22_3Input) =>
preserveEffect<Endpoint22_3Output>()(
const Endpoint21_3 = (raw: RawClient["server.shell"]) => (input: Endpoint21_3Input) =>
preserveEffect<Endpoint21_3Output>()(
raw["shell.timeout"]({
params: { id: input["id"] },
query: { location: input["location"] },
@@ -1282,134 +1126,134 @@ const Endpoint22_3 = (raw: RawClient["server.shell"]) => (input: Endpoint22_3Inp
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint22_4 = (raw: RawClient["server.shell"]) => (input: Endpoint22_4Input) =>
preserveEffect<Endpoint22_4Output>()(
const Endpoint21_4 = (raw: RawClient["server.shell"]) => (input: Endpoint21_4Input) =>
preserveEffect<Endpoint21_4Output>()(
raw["shell.output"]({
params: { id: input["id"] },
query: { location: input["location"], cursor: input["cursor"], limit: input["limit"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint22_5 = (raw: RawClient["server.shell"]) => (input: Endpoint22_5Input) =>
preserveEffect<Endpoint22_5Output>()(
const Endpoint21_5 = (raw: RawClient["server.shell"]) => (input: Endpoint21_5Input) =>
preserveEffect<Endpoint21_5Output>()(
raw["shell.remove"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroup22 = (raw: RawClient["server.shell"]) => ({
list: Endpoint22_0(raw),
create: Endpoint22_1(raw),
get: Endpoint22_2(raw),
timeout: Endpoint22_3(raw),
output: Endpoint22_4(raw),
remove: Endpoint22_5(raw),
const adaptGroup21 = (raw: RawClient["server.shell"]) => ({
list: Endpoint21_0(raw),
create: Endpoint21_1(raw),
get: Endpoint21_2(raw),
timeout: Endpoint21_3(raw),
output: Endpoint21_4(raw),
remove: Endpoint21_5(raw),
})
const Endpoint23_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint23_0Input) =>
preserveEffect<Endpoint23_0Output>()(
const Endpoint22_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint22_0Input) =>
preserveEffect<Endpoint22_0Output>()(
raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup23 = (raw: RawClient["server.reference"]) => ({ list: Endpoint23_0(raw) })
const adaptGroup22 = (raw: RawClient["server.reference"]) => ({ list: Endpoint22_0(raw) })
const Endpoint24_0 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_0Input) =>
preserveEffect<Endpoint24_0Output>()(
const Endpoint23_0 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_0Input) =>
preserveEffect<Endpoint23_0Output>()(
raw["worktree.list"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint24_1 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_1Input) =>
preserveEffect<Endpoint24_1Output>()(
const Endpoint23_1 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_1Input) =>
preserveEffect<Endpoint23_1Output>()(
raw["worktree.create"]({
params: { projectID: input["projectID"] },
payload: { strategy: input["strategy"], from: input["from"], directory: input["directory"], name: input["name"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint24_2 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_2Input) =>
preserveEffect<Endpoint24_2Output>()(
const Endpoint23_2 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_2Input) =>
preserveEffect<Endpoint23_2Output>()(
raw["worktree.remove"]({
params: { projectID: input["projectID"] },
payload: { directory: input["directory"], force: input["force"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint24_3 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_3Input) =>
preserveEffect<Endpoint24_3Output>()(
const Endpoint23_3 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_3Input) =>
preserveEffect<Endpoint23_3Output>()(
raw["worktree.refresh"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup24 = (raw: RawClient["server.worktree"]) => ({
list: Endpoint24_0(raw),
create: Endpoint24_1(raw),
remove: Endpoint24_2(raw),
refresh: Endpoint24_3(raw),
const adaptGroup23 = (raw: RawClient["server.worktree"]) => ({
list: Endpoint23_0(raw),
create: Endpoint23_1(raw),
remove: Endpoint23_2(raw),
refresh: Endpoint23_3(raw),
})
const Endpoint25_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_0Input) =>
preserveEffect<Endpoint25_0Output>()(
const Endpoint24_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint24_0Input) =>
preserveEffect<Endpoint24_0Output>()(
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_1Input) =>
preserveEffect<Endpoint25_1Output>()(
const Endpoint24_1 = (raw: RawClient["server.vcs"]) => (input?: Endpoint24_1Input) =>
preserveEffect<Endpoint24_1Output>()(
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint25_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_2Input) =>
preserveEffect<Endpoint25_2Output>()(
const Endpoint24_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint24_2Input) =>
preserveEffect<Endpoint24_2Output>()(
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({
get: Endpoint25_0(raw),
status: Endpoint25_1(raw),
diff: Endpoint25_2(raw),
const adaptGroup24 = (raw: RawClient["server.vcs"]) => ({
get: Endpoint24_0(raw),
status: Endpoint24_1(raw),
diff: Endpoint24_2(raw),
})
const Endpoint26_0 = (raw: RawClient["server.debug"]) => () =>
preserveEffect<Endpoint26_0Output>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)))
const Endpoint25_0 = (raw: RawClient["server.debug"]) => () =>
preserveEffect<Endpoint25_0Output>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)))
const Endpoint26_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint26_1Input) =>
preserveEffect<Endpoint26_1Output>()(
const Endpoint25_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint25_1Input) =>
preserveEffect<Endpoint25_1Output>()(
raw["debug.location.evict"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup26 = (raw: RawClient["server.debug"]) => ({
location: { list: Endpoint26_0(raw), evict: Endpoint26_1(raw) },
const adaptGroup25 = (raw: RawClient["server.debug"]) => ({
location: { list: Endpoint25_0(raw), evict: Endpoint25_1(raw) },
})
const Endpoint27_0 = (raw: RawClient["server.migration"]) => () =>
preserveEffect<Endpoint27_0Output>()(raw["migration.v1.status"]({}).pipe(Effect.mapError(mapClientError)))
const Endpoint26_0 = (raw: RawClient["server.migration"]) => () =>
preserveEffect<Endpoint26_0Output>()(raw["migration.v1.status"]({}).pipe(Effect.mapError(mapClientError)))
const adaptGroup27 = (raw: RawClient["server.migration"]) => ({ v1: { status: Endpoint27_0(raw) } })
const adaptGroup26 = (raw: RawClient["server.migration"]) => ({ v1: { status: Endpoint26_0(raw) } })
const Endpoint28_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint28_0Input) =>
preserveEffect<Endpoint28_0Output>()(
const Endpoint27_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint27_0Input) =>
preserveEffect<Endpoint27_0Output>()(
raw["websearch.providers"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint28_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint28_1Input) =>
preserveEffect<Endpoint28_1Output>()(
const Endpoint27_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_1Input) =>
preserveEffect<Endpoint27_1Output>()(
raw["websearch.query"]({
query: { location: input["location"] },
payload: { query: input["query"], providerID: input["providerID"] },
}).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup28 = (raw: RawClient["server.websearch"]) => ({
providers: Endpoint28_0(raw),
query: Endpoint28_1(raw),
const adaptGroup27 = (raw: RawClient["server.websearch"]) => ({
providers: Endpoint27_0(raw),
query: Endpoint27_1(raw),
})
const Endpoint29_0 = (raw: RawClient["server.config"]) => (input?: Endpoint29_0Input) =>
preserveEffect<Endpoint29_0Output>()(
const Endpoint28_0 = (raw: RawClient["server.config"]) => (input?: Endpoint28_0Input) =>
preserveEffect<Endpoint28_0Output>()(
raw["config.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup29 = (raw: RawClient["server.config"]) => ({ get: Endpoint29_0(raw) })
const adaptGroup28 = (raw: RawClient["server.config"]) => ({ get: Endpoint28_0(raw) })
const adaptClient = (raw: RawClient) => ({
health: adaptGroup0(raw["server.health"]),
@@ -1433,15 +1277,14 @@ const adaptClient = (raw: RawClient) => ({
skill: adaptGroup18(raw["server.skill"]),
event: adaptGroup19(raw["server.event"]),
pty: adaptGroup20(raw["server.pty"]),
"server.persistentPty": adaptGroup21(raw["server.persistentPty"]),
shell: adaptGroup22(raw["server.shell"]),
reference: adaptGroup23(raw["server.reference"]),
worktree: adaptGroup24(raw["server.worktree"]),
vcs: adaptGroup25(raw["server.vcs"]),
debug: adaptGroup26(raw["server.debug"]),
migration: adaptGroup27(raw["server.migration"]),
websearch: adaptGroup28(raw["server.websearch"]),
config: adaptGroup29(raw["server.config"]),
shell: adaptGroup21(raw["server.shell"]),
reference: adaptGroup22(raw["server.reference"]),
worktree: adaptGroup23(raw["server.worktree"]),
vcs: adaptGroup24(raw["server.vcs"]),
debug: adaptGroup25(raw["server.debug"]),
migration: adaptGroup26(raw["server.migration"]),
websearch: adaptGroup27(raw["server.websearch"]),
config: adaptGroup28(raw["server.config"]),
})
export const make = (options?: { readonly baseUrl?: URL | string }) =>
+4 -199
View File
@@ -182,32 +182,6 @@ import type {
PtyUpdateOutput,
PtyRemoveInput,
PtyRemoveOutput,
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,
@@ -1597,175 +1571,6 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
"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>(
@@ -1866,7 +1671,7 @@ export function make(options: ClientOptions) {
request<WorktreeListOutput>(
{
method: "GET",
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
path: `/api/worktree/${encodeURIComponent(input.projectID)}`,
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
@@ -1877,7 +1682,7 @@ export function make(options: ClientOptions) {
request<WorktreeCreateOutput>(
{
method: "POST",
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
path: `/api/worktree/${encodeURIComponent(input.projectID)}`,
body: {
strategy: input["strategy"],
from: input["from"],
@@ -1894,7 +1699,7 @@ export function make(options: ClientOptions) {
request<WorktreeRemoveOutput>(
{
method: "DELETE",
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
path: `/api/worktree/${encodeURIComponent(input.projectID)}`,
body: { directory: input["directory"], force: input["force"] },
successStatus: 204,
declaredStatuses: [400, 401],
@@ -1906,7 +1711,7 @@ export function make(options: ClientOptions) {
request<WorktreeRefreshOutput>(
{
method: "POST",
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree/refresh`,
path: `/api/worktree/${encodeURIComponent(input.projectID)}/refresh`,
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
@@ -328,8 +328,6 @@ export type FormMetadata1 = { [x: string]: any }
export type FormWhen1 = { key: string; op: "eq" | "neq"; value: string | number | boolean }
export type GroupItem = { type: "session"; id: string } | { type: "terminal"; id: string }
export type SessionStatus =
| { type: "idle" }
| {
@@ -341,22 +339,6 @@ export type SessionStatus =
}
| { type: "busy" }
export type PersistentPtyInfo = {
id: string
title: string
command: string
args: Array<string>
cwd: string
status: "running" | "exited"
pid: number
exitCode?: number
groupID: string
size: { cols: number; rows: number }
output: { head: number; tail: number }
}
export type PtyTicketConnectToken = { ticket: string; expires_in: number }
export type ShellInfo1 = {
id: string
status: "running" | "exited" | "timeout" | "killed"
@@ -1493,26 +1475,6 @@ export type FormMultiselectField1 = {
default?: Array<string>
}
export type GroupItemAdded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "group.item.added"
location?: LocationRef
data: { groupID: string; item: GroupItem }
}
export type GroupItemRemoved = {
id: string
created: number
metadata?: { [x: string]: any }
type: "group.item.removed"
location?: LocationRef
data: { groupID: string; item: GroupItem }
}
export type GroupInfo = { id: string; items: Array<GroupItem> }
export type SessionStatus2 = {
id: string
created: number
@@ -1522,13 +1484,6 @@ export type SessionStatus2 = {
data: { sessionID: string; status: SessionStatus }
}
export type PersistentPtySnapshot = {
info: PersistentPtyInfo
text: string
checkpoint: string
cursor: { x: number; y: number }
}
export type ReferenceSource = ReferenceLocalSource | ReferenceGitSource
export type WorktreeList = Array<WorktreeDirectory>
@@ -2120,8 +2075,6 @@ export type V2Event =
| FormCreated
| FormReplied
| FormCancelled
| GroupItemAdded
| GroupItemRemoved
| WebsearchUpdated
| SessionStatus2
| SessionIdle
@@ -2294,10 +2247,6 @@ export type PtyNotFoundError = { readonly _tag: "PtyNotFoundError"; readonly pty
export const isPtyNotFoundError = (value: unknown): value is PtyNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PtyNotFoundError"
export type ForbiddenError = { readonly _tag: "ForbiddenError"; readonly message: string }
export const isForbiddenError = (value: unknown): value is ForbiddenError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ForbiddenError"
export type ShellNotFoundError = { readonly _tag: "ShellNotFoundError"; readonly id: string; readonly message: string }
export const isShellNotFoundError = (value: unknown): value is ShellNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ShellNotFoundError"
@@ -5486,133 +5435,6 @@ export type PtyRemoveInput = {
export type PtyRemoveOutput = void
export type ServerPersistentPtyGroupListOutput = { data: Array<GroupInfo> }["data"]
export type ServerPersistentPtyGroupCreateInput = {
readonly items?: {
readonly items?:
| ReadonlyArray<
{ readonly type: "session"; readonly id: string } | { readonly type: "terminal"; readonly id: string }
>
| undefined
}["items"]
}
export type ServerPersistentPtyGroupCreateOutput = { data: GroupInfo }["data"]
export type ServerPersistentPtyGroupGetInput = { readonly groupID: { readonly groupID: string }["groupID"] }
export type ServerPersistentPtyGroupGetOutput = { data: GroupInfo }["data"]
export type ServerPersistentPtyGroupSetInput = {
readonly groupID: { readonly groupID: string }["groupID"]
readonly items: {
readonly items: ReadonlyArray<
{ readonly type: "session"; readonly id: string } | { readonly type: "terminal"; readonly id: string }
>
}["items"]
}
export type ServerPersistentPtyGroupSetOutput = { data: GroupInfo }["data"]
export type ServerPersistentPtyGroupRemoveInput = { readonly groupID: { readonly groupID: string }["groupID"] }
export type ServerPersistentPtyGroupRemoveOutput = void
export type ServerPersistentPtyListInput = { readonly groupID: { readonly groupID: string }["groupID"] }
export type ServerPersistentPtyListOutput = { data: Array<PersistentPtyInfo> }["data"]
export type ServerPersistentPtyCreateInput = {
readonly groupID: { readonly groupID: string }["groupID"]
readonly command: {
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 }
}["command"]
readonly args: {
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 }
}["args"]
readonly cwd: {
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 }
}["cwd"]
readonly title: {
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 }
}["title"]
readonly env: {
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 }
}["env"]
readonly size?: {
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 }
}["size"]
}
export type ServerPersistentPtyCreateOutput = { data: PersistentPtyInfo }["data"]
export type ServerPersistentPtyShutdownOutput = void
export type ServerPersistentPtyGetInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
export type ServerPersistentPtyGetOutput = { data: PersistentPtyInfo }["data"]
export type ServerPersistentPtyUpdateInput = {
readonly ptyID: { readonly ptyID: string }["ptyID"]
readonly attachmentID?: {
readonly attachmentID?: string
readonly size: { readonly cols: number; readonly rows: number }
}["attachmentID"]
readonly size: {
readonly attachmentID?: string
readonly size: { readonly cols: number; readonly rows: number }
}["size"]
}
export type ServerPersistentPtyUpdateOutput = { data: PersistentPtyInfo }["data"]
export type ServerPersistentPtySnapshotInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
export type ServerPersistentPtySnapshotOutput = { data: PersistentPtySnapshot }["data"]
export type ServerPersistentPtyRemoveInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
export type ServerPersistentPtyRemoveOutput = void
export type ServerPersistentPtyConnectTokenInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
export type ServerPersistentPtyConnectTokenOutput = { data: PtyTicketConnectToken }["data"]
export type ServerPersistentPtyConnectInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
export type ServerPersistentPtyConnectOutput = boolean
export type ShellListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+4 -4
View File
@@ -283,10 +283,10 @@ test("worktree methods use the global project contract", async () => {
await client.worktree.refresh({ projectID: "proj_test" })
expect(requests.map((request) => [request.method, request.url])).toEqual([
["GET", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
["POST", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
["DELETE", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
["POST", "http://localhost:3000/api/experimental/project/proj_test/worktree/refresh"],
["GET", "http://localhost:3000/api/worktree/proj_test"],
["POST", "http://localhost:3000/api/worktree/proj_test"],
["DELETE", "http://localhost:3000/api/worktree/proj_test"],
["POST", "http://localhost:3000/api/worktree/proj_test/refresh"],
])
expect(await requests[1]?.json()).toEqual({
strategy: "git",
+5 -3
View File
@@ -76,9 +76,11 @@ const layer = Layer.effect(
const type =
input.kind === "directory"
? "Directory"
: (yield* fs
.stat(absolute)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))))?.type
: input.kind === "file"
? "File"
: (yield* fs
.stat(absolute)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))))?.type
const externalDirectory = type === "Directory" ? absolute : path.dirname(absolute)
const externalResource = slash(path.join(externalDirectory, "*"))
return {
-2
View File
@@ -1,2 +0,0 @@
export { PersistentPty } from "./persistent-pty/index.js"
export { Group } from "./persistent-pty/group.js"
-103
View File
@@ -1,103 +0,0 @@
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] })
-736
View File
@@ -1,736 +0,0 @@
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"
const ProtocolVersion = 4
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,
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("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 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 }
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.TaggedErrorClass<UnavailableError>()("PersistentPty.UnavailableError", {
message: Schema.String,
}) {}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("PersistentPty.NotFoundError", {
ptyID: Pty.ID,
}) {}
export class GroupNotFoundError extends Schema.TaggedErrorClass<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 context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const client = new Client(runtimeDirectory(databasePath(database.db)))
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] })
class Client {
private registration?: Promise<Registration>
constructor(private readonly directory: 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 === "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) : 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) {
const found = await discover(directory).catch(() => undefined)
if (found) return found
await new Promise<void>((resolve, reject) => {
const child = spawn(process.env.OPENCODE_PTY_BIN || "opencode-pty", ["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),
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
}
+7 -2
View File
@@ -13,7 +13,7 @@ import { SessionHistory } from "./history.js"
import { SessionModelHeaders } from "./model-headers.js"
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
import { SessionRunnerModel } from "./runner/model.js"
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
import { SessionSystemPrompt } from "./system-prompt.js"
import { toLLMMessages } from "./runner/to-llm-message.js"
export const layer = Layer.effect(
@@ -39,7 +39,12 @@ export const layer = Layer.effect(
sessionID: selection.session.id,
agent: selection.agent.id,
model: model.ref,
system: [selection.agent.info.system ? selection.agent.info.system : PROMPT_DEFAULT, history.initial]
system: [
selection.agent.info.system
? selection.agent.info.system
: SessionSystemPrompt.make(toolDefinitions.map((tool) => tool.name)),
history.initial,
]
.filter((part) => part.length > 0)
.map(SystemPart.make),
messages: [
+5 -2
View File
@@ -19,7 +19,7 @@ import { SessionModelTransport } from "./model-transport.js"
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
import { PromptCacheDiagnostics } from "./prompt-cache-diagnostics.js"
import { MAX_STEPS_PROMPT } from "./runner/max-steps.js"
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
import { SessionSystemPrompt } from "./system-prompt.js"
import { toLLMMessages } from "./runner/to-llm-message.js"
const IMAGE_BYTES_TRIGGER = 25 * 1024 * 1024 // 25 MiB
@@ -190,7 +190,10 @@ export const layer = Layer.effect(
// The final Step keeps definitions available to protocols with native "none",
// preserving their prompt cache prefix. Calls are still rejected at execution.
const tools = input.context.tools
const system = [agent.info.system ? agent.info.system : PROMPT_DEFAULT, input.context.initial]
const system = [
agent.info.system ? agent.info.system : SessionSystemPrompt.make(tools.definitions.map((tool) => tool.name)),
input.context.initial,
]
.filter((part) => part.length > 0)
.map(SystemPart.make)
const history = toLLMMessages(input.context.messages, resolved.ref, providerMetadataKey)
@@ -1,95 +0,0 @@
You are opencode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
If the user asks for help or wants to give feedback inform them of the following:
- /help: Get help with using opencode
- To give feedback, users should report the issue at https://github.com/anomalyco/opencode/issues
When the user directly asks about opencode (eg 'can opencode do...', 'does opencode have...') or asks in second person (eg 'are you able...', 'can you do...'), first use the webfetch tool to gather information to answer the question from opencode docs at https://opencode.ai/v2/docs/
# Tone and style
You should be concise, direct, and to the point. When you run a non-trivial shell command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
Remember that your output will be displayed on a command line interface. Your responses can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like the shell tool or code comments as means to communicate with the user during the session.
If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.
IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
IMPORTANT: Keep your responses short, since they will be displayed on a command line interface. You MUST answer concisely with fewer than 4 lines (not including tool use or code generation), unless user asks for detail. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...". Here are some examples to demonstrate appropriate verbosity:
<example>
user: what is 2+2?
assistant: 4
</example>
<example>
user: is 11 a prime number?
assistant: Yes
</example>
<example>
user: what command should I run to list files in the current directory?
assistant: ls
</example>
<example>
user: what command should I run to watch files in the current directory?
assistant: [use the read tool to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files]
npm run dev
</example>
<example>
user: what files are in the directory src/?
assistant: [uses read and sees foo.c, bar.c, baz.c]
user: which file contains the implementation of foo?
assistant: src/foo.c
</example>
<example>
user: write tests for new feature
assistant: [uses grep and glob search tools to find where similar tests are defined, uses concurrent read file tool use blocks in one tool call to read relevant files at the same time, uses edit file tool to write new tests]
</example>
# Proactiveness
You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:
1. Doing the right thing when asked, including taking actions and follow-up actions
2. Not surprising the user with actions you take without asking
For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.
3. Do not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did.
# Following conventions
When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.
- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).
- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions.
- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.
- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
# Code style
- IMPORTANT: DO NOT ADD ***ANY*** COMMENTS unless asked
# Doing tasks
The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
- Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially.
- Implement the solution using all tools available to you
- Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (e.g. npm run lint, npm run typecheck, ruff, etc.) with the shell tool if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time.
NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
# Tool usage policy
- When doing file search, prefer to use the subagent tool in order to reduce context usage.
- You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. When making multiple shell tool calls, you MUST send a single message with multiple tools calls to run the calls in parallel. For example, if you need to run "git status" and "git diff", send a single message with two tool calls to run the calls in parallel.
You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.
IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
# Code References
When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location.
<example>
user: Where are errors from the client handled?
assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712.
</example>
@@ -0,0 +1,14 @@
You are an AI agent powered by OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.
# Harness
- Responses are rendered as GitHub-flavored Markdown.
- `<system-reminder>` blocks are harness instructions, not user-authored content. Read and follow them.
${OPENCODE_TOOL_GUIDANCE}
# Communication
- Use clear file paths when referring to files.
- Keep responses clear and concise, and avoid unnecessary technical jargon.
# Working in codebases
- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.
- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.
@@ -0,0 +1,24 @@
export * as SessionSystemPrompt from "./system-prompt.js"
import PROMPT from "./runner/prompt/system.txt"
export function make(tools: string[]) {
const instructions: string[] = []
if (tools.includes("write")) {
instructions.push(
"- Use the write tool to create files or completely replace their content. Prefer using the edit tool for targeted changes.",
)
}
if (tools.includes("edit")) {
instructions.push(
"- Use the edit tool for targeted changes to existing text files. It replaces the exact text in `oldString` with `newString`, and the values must differ. By default, `oldString` must occur exactly once. If it occurs multiple times, include more surrounding context to make it unique or set `replaceAll` to true to replace every occurrence.",
)
}
// if (tools.includes("patch")) {
// // instructions.push(...)
// }
if (tools.includes("read")) {
instructions.push("- Prefer using the read tool rather than shell commands like `cat`.")
}
return PROMPT.replace("${OPENCODE_TOOL_GUIDANCE}", instructions.join("\n"))
}
+29 -70
View File
@@ -6,11 +6,11 @@ import { FileDiff } from "@opencode-ai/schema/file-diff"
import { Effect, Result, Schema } from "effect"
import path from "path"
import { Bom } from "@opencode-ai/util/bom"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Environment } from "../../environment/index.js"
import { Formatter } from "../../formatter.js"
import { FileMutation } from "../../file-mutation.js"
import { Location } from "../../location.js"
import { LocationMutation } from "../../location-mutation.js"
import { Patch } from "@opencode-ai/util/patch"
import { Permission } from "../../permission.js"
import DESCRIPTION from "../patch.txt"
@@ -46,38 +46,30 @@ export const toModelOutput = (output: Output) =>
type Prepared =
| (Extract<Patch.Hunk, { readonly type: "add" }> & {
readonly target: Target
readonly target: LocationMutation.Target
readonly content: string
readonly before: string
readonly after: string
})
| (Extract<Patch.Hunk, { readonly type: "delete" }> & {
readonly target: Target
readonly target: LocationMutation.Target
readonly before: string
readonly after: string
})
| (Extract<Patch.Hunk, { readonly type: "update" }> & {
readonly target: Target
readonly target: LocationMutation.Target
readonly content: string
readonly before: string
readonly after: string
readonly moveTarget?: Target
readonly moveTarget?: LocationMutation.Target
})
interface Target {
readonly absolute: string
readonly resource: string
readonly externalDirectory?: {
readonly directory: string
readonly resource: string
}
}
export const Plugin = {
id: "opencode.tool.patch",
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
const environment = yield* Environment.Service
const mutation = yield* FileMutation.Service
const mutation = yield* LocationMutation.Service
const fileMutation = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const location = yield* Location.Service
const permission = yield* Permission.Service
@@ -119,26 +111,25 @@ export const Plugin = {
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
}
const prepared: Prepared[] = []
const targets: Target[] = []
const updates = new Map<string, string>()
const resolveTarget = Effect.fnUntraced(function* (value: string) {
const target = yield* mutation.resolve({ path: value, kind: "file" })
if (!target.externalDirectory) return target
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
metadata: {
filepath: target.absolute,
parentDir: target.externalDirectory.directory,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
return target
})
for (const hunk of hunks) {
yield* Effect.gen(function* () {
const target = resolveTarget(location, hunk.path)
targets.push(target)
if (target.externalDirectory) {
yield* permission.assert({
action: "external_directory",
resources: [target.externalDirectory.resource],
save: [target.externalDirectory.resource],
metadata: {
filepath: target.absolute,
parentDir: target.externalDirectory.directory,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
}
const target = yield* resolveTarget(hunk.path)
if (hunk.type === "add") {
const content =
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
@@ -182,22 +173,7 @@ export const Plugin = {
try: () => Patch.derive(hunk.path, hunk.chunks, original),
catch: (error) => new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
})
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
if (moveTarget) targets.push(moveTarget)
if (moveTarget?.externalDirectory) {
yield* permission.assert({
action: "external_directory",
resources: [moveTarget.externalDirectory.resource],
save: [moveTarget.externalDirectory.resource],
metadata: {
filepath: moveTarget.absolute,
parentDir: moveTarget.externalDirectory.directory,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
}
const moveTarget = hunk.movePath ? yield* resolveTarget(hunk.movePath) : undefined
prepared.push({
...hunk,
target,
@@ -217,6 +193,10 @@ export const Plugin = {
}
const patchFiles = prepared.map((change) => patchFile(change))
const targets = prepared.flatMap((change) => [
change.target,
...(change.type === "update" && change.moveTarget ? [change.moveTarget] : []),
])
yield* permission.assert({
action: "edit",
resources: [...new Set(targets.map((target) => target.resource))],
@@ -313,7 +293,7 @@ export const Plugin = {
})
return { applied, files }
}).pipe(
mutation.withLock(lockTargets),
fileMutation.withLock(lockTargets),
Effect.map((output) => ({
output,
content: toModelOutput(output),
@@ -394,24 +374,3 @@ function trimDiff(diff: string) {
})
.join("\n")
}
function resolveTarget(location: Location.Interface, value: string): Target {
const absolute =
process.platform === "win32"
? FSUtil.normalizePath(path.resolve(location.directory, value))
: path.resolve(location.directory, value)
const projectRoot = path.parse(location.project.directory).root
const external =
!FSUtil.contains(location.directory, absolute) &&
(location.project.directory === projectRoot || !FSUtil.contains(location.project.directory, absolute))
const directory = path.dirname(absolute)
const resource =
process.platform === "win32"
? FSUtil.normalizePathPattern(path.join(directory, "*"))
: path.join(directory, "*").replaceAll("\\", "/")
return {
absolute,
resource: path.relative(location.project.directory, absolute).replaceAll("\\", "/") || ".",
externalDirectory: external ? { directory, resource } : undefined,
}
}
-91
View File
@@ -1,91 +0,0 @@
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 })
}),
)
})
@@ -160,6 +160,20 @@ describe("LocationMutation", () => {
),
)
it.live("uses an explicit file kind without treating an existing directory as the target boundary", () =>
withTmp((directory) =>
withTmp((outside) =>
Effect.gen(function* () {
const target = yield* (yield* LocationMutation.Service).resolve({ path: outside, kind: "file" })
expect(target.externalDirectory).toMatchObject({
directory: path.dirname(outside),
resource: path.join(path.dirname(outside), "*").replaceAll("\\", "/"),
})
}).pipe(provide(directory)),
),
),
)
it.live("authorizes prospective external descendants at their lexical parent", () =>
withTmp((directory) =>
withTmp((outside) =>
@@ -7,6 +7,7 @@ import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt"
import { Session } from "@opencode-ai/core/session"
import { SessionSystemPrompt } from "@opencode-ai/core/session/system-prompt"
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
@@ -14,10 +15,9 @@ import { Effect } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
import PROMPT_META from "../../src/plugin/system-prompt/meta.txt"
import PROMPT_DEFAULT from "../../src/session/runner/prompt/base.txt"
const it = testEffect(PluginTestLayer)
const fallback = PROMPT_DEFAULT
const fallback = SessionSystemPrompt.make([])
const makeHost = Effect.gen(function* () {
const agents = yield* Agent.Service
const plugins = yield* Plugin.Service
@@ -74,7 +74,7 @@ describe("SystemPromptPlugin", () => {
["kimi-k2", "# Prompt and Tool Use"],
["trinity", "what command should I run to list files"],
["meta/muse-spark-1.1", "powered by Muse Spark"],
["llama-3.3", "You are opencode, an interactive CLI tool"],
["llama-3.3", fallback],
] as const
yield* Effect.forEach(
+2 -2
View File
@@ -71,6 +71,7 @@ import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { SkillInstructions } from "@opencode-ai/core/skill/instructions"
import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions"
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
import { SessionSystemPrompt } from "@opencode-ai/core/session/system-prompt"
import { ID } from "@opencode-ai/core/model"
import { Location } from "@opencode-ai/core/location"
import { Provider } from "@opencode-ai/core/provider"
@@ -81,7 +82,6 @@ import { asc, desc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
import { permissionLayer } from "./lib/permission"
import { agentHost, catalogHost, host } from "./plugin/host"
import PROMPT_DEFAULT from "../src/session/runner/prompt/base.txt"
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
let requests: LLMRequest[] = []
@@ -147,7 +147,7 @@ const modelTransport = Layer.succeed(
}),
)
const model = LanguageModel.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route })
const defaultSystem = PROMPT_DEFAULT
const defaultSystem = SessionSystemPrompt.make([])
const replacementModel = LanguageModel.make({ id: "replacement", provider: "fake", route: OpenAIChat.route })
const compactModel = LanguageModel.make({
id: "compact",
@@ -0,0 +1,8 @@
import { expect, test } from "bun:test"
import { SessionSystemPrompt } from "@opencode-ai/core/session/system-prompt"
test("renders the default system prompt instructions", () => {
const prompt = SessionSystemPrompt.make(["edit", "read", "shell"])
expect(prompt).not.toContain("${OPENCODE_TOOL_GUIDANCE}")
expect(prompt).toContain("Use the edit tool for targeted changes to existing text files")
})
+116 -6
View File
@@ -9,6 +9,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Formatter } from "@opencode-ai/core/formatter"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
@@ -25,7 +26,15 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const patchToolNode = makeLocationNode({
name: "test/patch-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
deps: [Tool.node, FileMutation.node, Environment.node, Formatter.node, Location.node, Permission.node],
deps: [
Tool.node,
LocationMutation.node,
FileMutation.node,
Environment.node,
Formatter.node,
Location.node,
Permission.node,
],
})
const sessionID = Session.ID.make("ses_patch_tool_test")
@@ -91,7 +100,7 @@ const withTool = <A, E, R>(
return yield* body(yield* Tool.Service)
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Tool.node, FileMutation.node, patchToolNode]), [
AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, patchToolNode]), [
[
Environment.node,
transformEnvironmentFiles(activeLocation, (files) => ({
@@ -485,6 +494,42 @@ describe("PatchTool", () => {
),
)
it.live("uses Location-relative resources for move targets in a nested Location", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const active = path.join(tmp.path, "nested", "location")
const source = path.join(active, "old.txt")
return Effect.promise(() =>
fs.mkdir(active, { recursive: true }).then(() => fs.writeFile(source, "before\n")),
).pipe(
Effect.andThen(
withTool(
active,
(registry) =>
Effect.gen(function* () {
const settled = yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch",
),
)
expect(settled).toMatchObject({
status: "completed",
output: { applied: [{ resource: "moved.txt" }] },
})
expect(assertions).toMatchObject([{ action: "edit", resources: ["old.txt", "moved.txt"] }])
}),
tmp.path,
),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("inserts lines with an insert-only hunk", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
@@ -781,8 +826,15 @@ describe("PatchTool", () => {
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
const target = path.join(outside.path, "external.txt")
return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
const repository = path.join(outside.path, "repository")
const directory = path.join(repository, "nested")
const target = path.join(directory, "external.txt")
return Effect.promise(() =>
Promise.all([
fs.mkdir(path.join(repository, ".git"), { recursive: true }),
fs.mkdir(directory, { recursive: true }).then(() => fs.writeFile(target, "before\n")),
]),
).pipe(
Effect.andThen(
withTool(active.path, (registry) =>
Effect.gen(function* () {
@@ -793,6 +845,15 @@ describe("PatchTool", () => {
),
).toMatchObject({ status: "completed" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(assertions[0]).toMatchObject({
resources: [path.join(directory, "*").replaceAll("\\", "/")],
save: [path.join(repository, "*").replaceAll("\\", "/")],
metadata: {
filepath: target,
parentDir: directory,
},
})
expect(assertions[1]?.resources).toEqual([target.replaceAll("\\", "/")])
expect(readsBeforeEditApproval).toBe(1)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
}),
@@ -860,7 +921,7 @@ describe("PatchTool", () => {
),
)
it.live("treats a sibling path inside the project worktree as internal", () =>
it.live("treats a sibling path inside the project worktree as external to the Location", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
@@ -879,7 +940,9 @@ describe("PatchTool", () => {
call("*** Begin Patch\n*** Update File: ../sibling.txt\n@@\n-before\n+after\n*** End Patch"),
),
).toMatchObject({ status: "completed" })
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(assertions[0]?.resources).toEqual([path.join(tmp.path, "*").replaceAll("\\", "/")])
expect(assertions[1]?.resources).toEqual([target.replaceAll("\\", "/")])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
}),
tmp.path,
@@ -956,6 +1019,53 @@ describe("PatchTool", () => {
),
)
it.live("uses canonical external permissions and resources for a move destination", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
const source = path.join(active.path, "source.txt")
const destination = path.join(outside.path, "moved.txt")
return Effect.promise(() => fs.writeFile(source, "before\n")).pipe(
Effect.andThen(
withTool(active.path, (registry) =>
Effect.gen(function* () {
const settled = yield* executeTool(
registry,
call(
`*** Begin Patch\n*** Update File: source.txt\n*** Move to: ${destination}\n@@\n-before\n+after\n*** End Patch`,
),
)
expect(settled).toMatchObject({
status: "completed",
output: { applied: [{ resource: destination.replaceAll("\\", "/") }] },
})
expect(assertions).toMatchObject([
{
action: "external_directory",
resources: [path.join(outside.path, "*").replaceAll("\\", "/")],
save: [path.join(outside.path, "*").replaceAll("\\", "/")],
metadata: { filepath: destination, parentDir: outside.path },
},
{
action: "edit",
resources: ["source.txt", destination.replaceAll("\\", "/")],
},
])
expect(yield* exists(source)).toBe(false)
expect(yield* Effect.promise(() => fs.readFile(destination, "utf8"))).toBe("after\n")
}),
),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("approves each external file under the same parent", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
+2 -2
View File
@@ -10534,7 +10534,7 @@
"summary": "List references"
}
},
"/api/experimental/project/{projectID}/worktree": {
"/api/worktree/{projectID}": {
"get": {
"tags": ["worktree"],
"operationId": "v2.worktree.list",
@@ -10736,7 +10736,7 @@
}
}
},
"/api/experimental/project/{projectID}/worktree/refresh": {
"/api/worktree/{projectID}/refresh": {
"post": {
"tags": ["worktree"],
"operationId": "v2.worktree.refresh",
-3
View File
@@ -19,7 +19,6 @@ 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"
@@ -87,7 +86,6 @@ type ApiGroups<
| typeof DebugGroup
| typeof MigrationGroup
| typeof WorktreeGroup
| typeof PersistentPtyGroup
| LocationGroups<LocationId>
| FormGroups<LocationId, LocationService, FormLocationId, FormLocationService>
| SessionGroups<SessionLocationId, SessionLocationService>
@@ -168,7 +166,6 @@ const makeApiFromGroup = <
.add(SkillGroup.middleware(locationMiddleware))
.add(eventGroup)
.add(PtyGroup.middleware(locationMiddleware))
.add(PersistentPtyGroup)
.add(ShellGroup.middleware(locationMiddleware))
.add(ReferenceGroup.middleware(locationMiddleware))
.add(WorktreeGroup)
@@ -1,137 +0,0 @@
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." }))
+1 -1
View File
@@ -3,7 +3,7 @@ import { Worktree } from "@opencode-ai/schema/worktree"
import { Schema, Struct } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
const root = "/api/experimental/project/:projectID/worktree"
const root = "/api/worktree/:projectID"
export class WorktreeError extends Schema.ErrorClass<WorktreeError>("WorktreeError")(
{
-2
View File
@@ -10,7 +10,6 @@ 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"
@@ -57,7 +56,6 @@ const featureDefinitions = Event.inventory(
...Pty.Event.Definitions,
...Shell.Event.Definitions,
...Form.Event.Definitions,
...Group.Event.Definitions,
...WebSearch.Event.Definitions,
)
-43
View File
@@ -1,43 +0,0 @@
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,7 +6,6 @@ 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 { AI } from "./ai.js"
@@ -32,7 +31,6 @@ 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"
-38
View File
@@ -1,38 +0,0 @@
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,
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,7 +4,6 @@ import {
Config,
FileSystem,
Form,
Group,
Integration,
Permission,
Project,
@@ -65,7 +64,6 @@ 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
@@ -1,31 +0,0 @@
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,7 +16,6 @@ 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,7 +55,6 @@ export const handlers = Layer.mergeAll(
SkillHandler,
EventHandler.pipe(Layer.provide(EventFeed.layer)),
PtyHandler,
PersistentPtyHandler,
ShellHandler,
ReferenceHandler,
WorktreeHandler,
@@ -1,290 +0,0 @@
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 } 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 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) => {
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,7 +3,6 @@ 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"
@@ -50,8 +49,7 @@ 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.
const url = new URL(request.url, "http://localhost")
if (hasPtyConnectTicketURL(url) || hasPersistentPtyConnectTicketURL(url)) return yield* effect
if (hasPtyConnectTicketURL(new URL(request.url, "http://localhost"))) 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)),
+1 -6
View File
@@ -4,7 +4,6 @@ import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
import { hasPersistentPtyConnectTicketURL } from "@opencode-ai/protocol/groups/persistent-pty"
import { Cause, Context, Deferred, Effect, Exit, Layer, Option, Ref, Schema, Scope } from "effect"
import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { randomUUID } from "node:crypto"
@@ -184,11 +183,7 @@ 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) && !hasPersistentPtyConnectTicketURL(url))) &&
!(yield* authorizedRequest(request, auth))
)
return unauthorized()
if ((!ready || !hasPtyConnectTicketURL(url)) && !(yield* authorizedRequest(request, auth))) return unauthorized()
if (ready) return yield* app.value
return unavailable(state)
})
-3
View File
@@ -13,7 +13,6 @@ import { Command } from "@opencode-ai/core/command"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Pty } from "@opencode-ai/core/pty"
import { 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"
@@ -61,8 +60,6 @@ const applicationServiceNodes = [
SdkPlugins.node,
PermissionSaved.node,
PtyTicket.node,
Group.node,
PersistentPty.node,
Credential.node,
WellKnown.node,
PtyEnvironment.node,
-397
View File
@@ -1,397 +0,0 @@
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")
second.socket.send(inputFrame(70, 20, "x".repeat(1024 * 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 }
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 === "attached") {
if (message.inputProtocol === 1) {
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
}
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)
}
+1 -1
View File
@@ -36,7 +36,7 @@ it.live("lists, creates, and removes worktrees by project ID", () =>
const resolved = yield* Effect.promise(() => fetch(location, { headers }).then((response) => response.json()))
if (!isRecord(resolved) || !isRecord(resolved.project) || typeof resolved.project.id !== "string")
throw new Error("Expected resolved project")
const url = new URL(`/api/experimental/project/${resolved.project.id}/worktree`, base)
const url = new URL(`/api/worktree/${resolved.project.id}`, base)
const initial = yield* Effect.promise(() => fetch(url, { headers }).then((response) => response.json()))
expect(initial).toEqual([{ directory: project }])
+42 -64
View File
@@ -31,6 +31,7 @@ import {
batch,
Show,
} from "solid-js"
import { createStore } from "solid-js/store"
import {
TuiLifecycleProvider,
TuiAppProvider,
@@ -75,6 +76,7 @@ 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"
@@ -98,8 +100,6 @@ 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()
@@ -217,7 +217,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), endpoint }
return { api: OpenCode.make(next) }
},
restart: managed.restart,
}
@@ -372,50 +372,48 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
: undefined
}
>
<ClientProvider api={api} endpoint={input.server.endpoint} service={service}>
<ClientProvider api={api} service={service}>
<PermissionProvider>
<DataProvider>
<LocationProvider>
<SessionTabsProvider>
<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>
<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>
</SessionTabsProvider>
</LocationProvider>
</DataProvider>
@@ -608,11 +606,6 @@ 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}`)
}
@@ -1309,22 +1302,7 @@ function App(props: { pair?: DialogPairCredentials }) {
</Match>
<Match when={route.data.type === "session"}>
<Show when={route.data.type === "session" ? route.data.sessionID : undefined} keyed>
{(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}
/>
)}
{(_) => <Session verticalTabsWidth={verticalTabsVisible() ? verticalTabsWidth() : 0} />}
</Show>
</Match>
<Match when={route.data.type === "plugin"}>
+1 -42
View File
@@ -17,13 +17,12 @@ import { useDialog } from "../ui/dialog"
import { DialogExperiments } from "./dialog-experiments"
import { usePlugin } from "../plugin/context"
import { errorMessage } from "../util/error"
import { usePaneLayout } from "../context/pane-layout"
const graphWidth = 23
const sampleIntervalMilliseconds = 2_000
const sampleRetentionMilliseconds = 30_000
const statusWindowMilliseconds = 6_000
type Panel = "server" | "theme" | "tools" | "ui" | "layout"
type Panel = "server" | "theme" | "tools" | "ui"
type ProcessSample = Readonly<{ cpu: number; memory: number; delay: number; time: number }>
export type RuntimeStatus = "normal" | "medium" | "high"
@@ -39,7 +38,6 @@ export function DevToolsBar() {
const keymap = Keymap.use()
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const panes = usePaneLayout()
const { current: theme, mode, supports, setMode } = themes
const elevatedTheme = useTheme("elevated")
const [panel, setPanel] = createSignal<Panel>()
@@ -48,8 +46,6 @@ export function DevToolsBar() {
const [dumpError, setDumpError] = createSignal<string>()
const [frontendSamples, setFrontendSamples] = createSignal<readonly ProcessSample[]>([])
const [debugOverlay, setDebugOverlay] = createSignal(renderer.debugOverlay.enabled)
const [creatingTerminal, setCreatingTerminal] = createSignal(false)
const [terminalError, setTerminalError] = createSignal<string>()
let focus: Renderable | null
const connected = createMemo(() => client.connection.status() === "connected")
const serverIndicator = createMemo(() => connectionIndicator(client.connection.status(), client.connection.attempt()))
@@ -224,18 +220,6 @@ export function DevToolsBar() {
setDumping(false)
}
async function newTerminal() {
const routeData = route.data
if (routeData.type !== "session") return
setCreatingTerminal(true)
setTerminalError()
await panes.newTerminal(routeData.sessionID).then(
() => close(),
(error) => setTerminalError(errorMessage(error)),
)
setCreatingTerminal(false)
}
return (
<box height={1} flexShrink={0} flexDirection="row" backgroundColor={theme.raise(theme.background.default)}>
<Show when={panel()}>
@@ -434,31 +418,6 @@ export function DevToolsBar() {
</PanelBox>
</Show>
</BarItem>
<BarItem active={panel() === "layout"} onClick={() => toggle("layout")}>
<text fg={panel() === "layout" ? theme.text.action.primary.focused : theme.text.subdued}>Layout</text>
<Show when={panel() === "layout"}>
<PanelBox>
<PanelTitle>Layout</PanelTitle>
<Action
onClick={() => void newTerminal()}
disabled={route.data.type !== "session" || creatingTerminal()}
hoverBackground
>
{creatingTerminal() ? "Creating terminal..." : "New terminal"}
</Action>
<Show when={route.data.type !== "session"}>
<text fg={elevatedTheme.text.subdued}>Open a session first.</text>
</Show>
<Show when={terminalError()}>
{(error) => (
<text fg={elevatedTheme.text.feedback.error.default} wrapMode="word">
{error()}
</text>
)}
</Show>
</PanelBox>
</Show>
</BarItem>
<BarItem
active={false}
onClick={() => {
@@ -1,98 +0,0 @@
import { createResource, Match, Show, Switch } from "solid-js"
import { usePaneLayout } from "../context/pane-layout"
import type { PaneLayoutNode } from "../context/pane-layout-model"
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 theme = useTheme()
return (
<Switch>
<Match when={props.node.type === "item" ? props.node.item : undefined}>
{(item) => (
<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)}
/>
</Match>
</Switch>
)}
</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}
border={node().direction === "horizontal" ? ["left"] : ["top"]}
borderColor={theme.border.default}
>
<PaneNode
node={node().second}
rootSessionID={props.rootSessionID}
verticalTabsWidth={props.verticalTabsWidth}
/>
</box>
</box>
)}
</Match>
</Switch>
)
}
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>
)
}
@@ -1,302 +0,0 @@
import { EmbeddedTerminalRenderable } from "@opentui/core"
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 } 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 }) {
const client = useClient()
const keymap = Keymap.use()
const theme = useTheme()
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 waitingSize: { size: TerminalSize; resolve: () => void } | undefined
const setCanonicalSize = (value: TerminalSize) => {
canonicalSize = value
if (!terminal) return
terminal.width = value.cols
terminal.height = value.rows
}
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)]))
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?.()
})
onMount(() => {
void connect().catch((error) => setFailure(errorMessage(error)))
})
onCleanup(() => {
disposed = true
waitingSize?.resolve()
socket?.close()
offKeys()
})
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"))
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"
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
terminalSize = { cols: 80, rows: 24 }
if (canonicalSize) {
value.width = canonicalSize.cols
value.height = canonicalSize.rows
}
}}
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 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
}
+20 -22
View File
@@ -51,7 +51,8 @@ const RIGHT_MOUSE_BUTTON = 2
type TabContextMenuState = {
x: number
y: number
tab?: SessionTab
sessionID?: string
title?: string
}
type ContextController = ReturnType<typeof useSessionTabs>
@@ -188,20 +189,16 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
const theme = useTheme("elevated")
const dialog = useDialog()
const actions = createMemo(() => {
const tab = props.state.tab
const sessionID = props.state.sessionID
return [
...(props.tabs.add ? [{ title: "New tab", run: () => props.tabs.add?.() }] : []),
...(tab
...(sessionID
? [
...(!tab.groupID
? [
{
title: "Rename",
run: () => DialogSessionRename.show(dialog, tab.sessionID, tab.title),
},
]
: []),
{ title: "Close", run: () => props.tabs.close(tab.sessionID) },
{
title: "Rename",
run: () => DialogSessionRename.show(dialog, sessionID, props.state.title),
},
{ title: "Close", run: () => props.tabs.close(sessionID) },
]
: []),
]
@@ -423,7 +420,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(() => (tab.groupID ? undefined : data.session.get(tab.sessionID)))
const session = createMemo(() => data.session.get(tab.sessionID))
const project = createMemo(() => {
const value = session()
return value ? data.project.get(value.projectID) : undefined
@@ -432,7 +429,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 ?? (tab.groupID ? "Terminal" : "Untitled session")
const title = () => tab.title ?? "Untitled session"
const scrolling = () => marquee.active() === tab.sessionID
const visibleTitleParts = createMemo(() =>
scrolling()
@@ -451,7 +448,6 @@ 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) ?? ""
@@ -577,9 +573,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
setDragging(undefined)
if (!rail) return
setContextMenu({
x: event.x - rail.screenX,
y: event.y - rail.screenY,
tab,
x: event.x,
y: event.y,
sessionID: tab.sessionID,
title: tab.title,
})
event.preventDefault()
event.stopPropagation()
@@ -1035,7 +1032,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 ?? (tab.groupID ? "Terminal" : "Untitled session")
const title = () => tab.title ?? "Untitled session"
const tabNumber = createMemo(() => items().findIndex((item) => item.sessionID === tab.sessionID) + 1)
// Shortcut labels stay one cell wide: 1-9, 0 for ten, then a neutral dot.
const numberWidth = () => 2
@@ -1110,9 +1107,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
didDrag = false
setDragging(undefined)
setContextMenu({
x: event.x - (strip?.screenX ?? 0),
y: event.y - (strip?.screenY ?? 0),
tab: tab === NEW_SESSION_TAB ? undefined : tab,
x: event.x,
y: event.y,
sessionID: tab === NEW_SESSION_TAB ? undefined : tab.sessionID,
title: tab === NEW_SESSION_TAB ? undefined : tab.title,
})
event.preventDefault()
event.stopPropagation()
+2 -8
View File
@@ -1,5 +1,4 @@
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
import type { Endpoint } from "@opencode-ai/client/service"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { batch, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
@@ -19,7 +18,7 @@ export type ClientConnectionEvent = {
}
type ManagedService = {
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient; endpoint?: Endpoint }>
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
restart: () => Promise<void>
}
@@ -30,12 +29,11 @@ const eventFlushInterval = 10
export const { use: useClient, provider: ClientProvider } = createSimpleContext({
name: "Client",
init: (props: { api: OpenCodeClient; endpoint?: Endpoint; service?: ManagedService }) => {
init: (props: { api: OpenCodeClient; service?: ManagedService }) => {
const log = useLog({ component: "client" })
const abort = new AbortController()
const history: ClientConnectionEvent[] = []
let api = props.api
let endpoint = props.endpoint
const events = createGlobalEmitter<ClientEventMap>()
let pending: OpenCodeEvent[] = []
let flushTimer: ReturnType<typeof setTimeout> | undefined
@@ -160,7 +158,6 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
if (abort.signal.aborted || controller.signal.aborted) return
if (next) {
api = next.api
if (next.endpoint) endpoint = next.endpoint
if (attempt === 1) continue
}
}
@@ -182,9 +179,6 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
get api() {
return api
},
get endpoint() {
return endpoint
},
event: {
on: events.on,
listen: events.listen,
@@ -1,70 +0,0 @@
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
@@ -1,158 +0,0 @@
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): 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 })
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))
},
}
},
})
+1 -9
View File
@@ -17,11 +17,6 @@ export type SessionRoute = {
prompt?: PromptInfo
}
export type WorkspaceRoute = {
type: "workspace"
groupID: string
}
export type PluginRoute = {
type: "plugin"
id: string
@@ -29,7 +24,7 @@ export type PluginRoute = {
data?: Record<string, unknown>
}
export type Route = HomeRoute | SessionRoute | WorkspaceRoute | PluginRoute
export type Route = HomeRoute | SessionRoute | PluginRoute
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
name: "Route",
@@ -56,9 +51,6 @@ 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,8 +1,6 @@
export type SessionTab = {
sessionID: string
title?: string
groupID?: string
directory?: string
}
export type SessionTabUnread = "activity" | "error"
+16 -102
View File
@@ -74,7 +74,6 @@ 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[] = []
const scrollAnchors = new Map<string, ScrollAnchor>()
@@ -113,7 +112,6 @@ 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) })
}, []),
@@ -123,11 +121,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
return result
}, {}),
})
const current = () => {
if (route.data.type === "session") return root(route.data.sessionID)
if (route.data.type === "workspace") return route.data.groupID
return undefined
}
const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
const newTab = createMemo((open = false) => {
if (route.data.type === "home") return true
if (!open) return false
@@ -135,9 +129,6 @@ 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 = data.session.family(session)
const family = members.length > 0 ? members : [session]
@@ -164,10 +155,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
createEffect(() => {
if (!enabled()) return
if (route.data.type === "workspace") {
history = recordSessionTabHistory(history, route.data.groupID)
return
}
if (route.data.type !== "session" || route.data.sessionID === "dummy") return
const sessionID = root(route.data.sessionID)
history = recordSessionTabHistory(history, sessionID)
@@ -211,8 +198,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
// the first connection slots and switches still render from a warm cache.
const openTabSessions = createMemo(() =>
state()
.tabs.filter((tab) => !tab.groupID)
.map((tab) => tab.sessionID)
.tabs.map((tab) => tab.sessionID)
.sort()
.join("\n"),
)
@@ -240,8 +226,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})()
const timer = setTimeout(async () => {
const sessions = state()
.tabs.filter((tab) => !tab.groupID)
.map((tab) => tab.sessionID)
.tabs.map((tab) => tab.sessionID)
.filter((sessionID) => sessionID !== current())
for (const sessionID of sessions) {
if (stale) return
@@ -273,48 +258,16 @@ 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.groupID || entry.tab.sessionID !== target)
closedTabs = closedTabs.filter((entry) => entry.tab.sessionID !== target)
remove(evt.data.sessionID, enabled())
}),
)
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)
function remove(sessionID: string, navigate: boolean) {
const target = root(sessionID)
scrollAnchors.delete(target)
const closed = closeSessionTab(state().tabs, target)
const selected = shouldNavigate && current() === target
const selected = navigate && current() === target
if (closed.tabs === state().tabs && !selected) return
const previous = selected
? moveSessionTabHistory(recordSessionTabHistory(history, target), closed.tabs, target, -1)
@@ -331,25 +284,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
delete next[target]
return next
})
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)
}
if (selected) route.navigate(next ? { type: "session", sessionID: next } : { type: "home" })
}
return {
@@ -379,25 +314,8 @@ 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()
@@ -414,21 +332,17 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
},
close(sessionID?: string) {
if (!enabled()) return
const target = sessionID ? (tab(sessionID)?.groupID ? sessionID : root(sessionID)) : current()
const target = 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) navigate(session)
if (route.data.type === "home" && session) route.navigate({ type: "session", sessionID: session })
return
}
const index = state().tabs.findIndex((tab) => tab.sessionID === target)
const selected = state().tabs[index]
if (selected?.groupID) {
void closeWorkspace(selected)
return
}
if (selected) closedTabs = recordClosedSessionTab(closedTabs, selected, index)
const tab = state().tabs[index]
if (tab) closedTabs = recordClosedSessionTab(closedTabs, tab, index)
remove(target, true)
},
reopen() {
@@ -444,7 +358,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
},
move(sessionID: string, index: number) {
if (!enabled()) return
const session = tab(sessionID)?.groupID ? sessionID : root(sessionID)
const session = root(sessionID)
if (moveSessionTab(state().tabs, session, index) === state().tabs) return
update((draft) => {
draft.tabs = moveSessionTab(draft.tabs, session, index)
@@ -453,19 +367,19 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
cycle(direction: 1 | -1) {
if (!enabled()) return
const tab = cycleSessionTab(state().tabs, current(), direction)
if (tab) navigate(tab.sessionID)
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
},
cycleUnread(direction: 1 | -1) {
if (!enabled()) return
const tab = cycleSessionTab(state().tabs, current(), direction, (tab) =>
Boolean(state().unread[tab.sessionID] || status(tab.sessionID).attention),
)
if (tab) navigate(tab.sessionID)
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
},
selectIndex(index: number) {
if (!enabled()) return
const tab = state().tabs[index]
if (tab) navigate(tab.sessionID)
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
},
}
},
@@ -1,24 +1,15 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, createSignal, Match, Show, Switch } from "solid-js"
import { createMemo, 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"
onNewTerminal?: () => Promise<void>
}) {
export function PromptFooter(props: { context: Plugin.Context; sessionID?: string; mode: "normal" | "shell" }) {
const dimensions = useTerminalDimensions()
const [terminalHovered, setTerminalHovered] = createSignal(false)
const [terminalPending, setTerminalPending] = createSignal(false)
const subagents = createMemo(() => {
if (!props.sessionID) return 0
const count = props.context.data.session
@@ -50,53 +41,33 @@ export function PromptFooter(props: {
})
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"}>
<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"}
<Switch>
<Match when={live() || status().length > 0}>
<text fg={props.context.theme.text.subdued} wrapMode="none" truncate flexShrink={1}>
<Show when={live() && shortcut("session.child.first")}>
{(value) => <span style={{ fg: props.context.theme.text.default }}>{value()} </span>}
</Show>
<Show when={subagents()}>{(value) => <span>{value()}</span>}</Show>
<Show when={subagents() && shells()}> · </Show>
<Show when={shells()}>{(value) => <span>{value()}</span>}</Show>
<Show when={live() && status().length > 0}> · </Show>
<Show when={status().length > 0}>{status().join(" · ")}</Show>
</text>
}
>
<Switch>
<Match when={live() || status().length > 0}>
<text fg={props.context.theme.text.subdued} wrapMode="none" truncate flexShrink={1}>
<Show when={live() && shortcut("session.child.first")}>
{(value) => <span style={{ fg: props.context.theme.text.default }}>{value()} </span>}
</Show>
<Show when={subagents()}>{(value) => <span>{value()}</span>}</Show>
<Show when={subagents() && shells()}> · </Show>
<Show when={shells()}>{(value) => <span>{value()}</span>}</Show>
<Show when={live() && status().length > 0}> · </Show>
<Show when={status().length > 0}>{status().join(" · ")}</Show>
</text>
</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}>
</Match>
<Match 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>
{shortcut("agent.cycle")} <span style={{ fg: props.context.theme.text.subdued }}>agents</span>
</text>
</Show>
</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>
</Match>
<Match when={props.mode === "shell"}>
@@ -111,31 +82,12 @@ export function PromptFooter(props: {
)
}
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) => <PromptFooterSlot context={context} sessionID={props.sessionID} mode={props.mode} />,
render: (props) => <PromptFooter context={context} sessionID={props.sessionID} mode={props.mode} />,
})
},
})
+7 -12
View File
@@ -168,22 +168,17 @@ 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()
.filter((tab) => !tab.groupID)
.map((tab) => ({
sessionID: tab.sessionID,
title: tab.title,
active: host.sessionTabs.current() === tab.sessionID,
...host.sessionTabs.status(tab.sessionID),
})),
host.sessionTabs.tabs().map((tab) => ({
...tab,
active: host.sessionTabs.current() === tab.sessionID,
...host.sessionTabs.status(tab.sessionID),
})),
open(sessionID) {
if (!host.sessionTabs.enabled()) return false
host.sessionTabs.select(sessionID)
@@ -191,14 +186,14 @@ export function createPluginContext(input: {
},
focus(sessionID) {
if (!host.sessionTabs.enabled()) return false
if (!host.sessionTabs.tabs().some((tab) => !tab.groupID && tab.sessionID === sessionID)) return false
if (!host.sessionTabs.tabs().some((tab) => 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.groupID && tab.sessionID === target)) return false
if (!target || !host.sessionTabs.tabs().some((tab) => tab.sessionID === target)) return false
host.sessionTabs.close(target)
return true
},
@@ -1,73 +0,0 @@
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 { GroupItem, OpenCodeEvent, PersistentPtyInfo } from "@opencode-ai/client"
import type { OpenCodeEvent } from "@opencode-ai/client"
import { testRender } from "@opentui/solid"
import { mkdirSync, watch } from "fs"
import path from "path"
@@ -36,8 +36,6 @@ async function renderSessionTabs(
sessionGate?: Promise<void>
sessionDirectories?: Record<string, string>
newLocation?: "launch" | "inherit"
groups?: Record<string, GroupItem[]>
terminals?: Record<string, PersistentPtyInfo[]>
},
) {
const temporary = options?.state ? undefined : await tmpdir()
@@ -57,9 +55,7 @@ async function renderSessionTabs(
const sessions: string[] = []
const locations: string[] = []
const vcsLocations: string[] = []
const removedGroups: string[] = []
const removedTerminals: string[] = []
const calls = createFetch(async (url, request) => {
const calls = createFetch(async (url) => {
if (url.pathname === "/api/location") {
const requested = url.searchParams.get("location[directory]") ?? directory
locations.push(requested)
@@ -76,19 +72,6 @@ async function renderSessionTabs(
data: { branch: { current: "main", default: "main" } },
})
}
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)
@@ -157,8 +140,6 @@ async function renderSessionTabs(
sessions,
locations,
vcsLocations,
removedGroups,
removedTerminals,
state,
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
focus: () => app.renderer.emit("focus"),
@@ -428,57 +409,3 @@ 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,
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()
}
})
@@ -43,28 +43,3 @@ 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()
}
})
+2 -4
View File
@@ -107,13 +107,12 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
})
if (url.pathname === "/api/project/current") return json({ id: "proj_test", directory: worktree })
if (url.pathname === "/api/project") return json([])
if (url.pathname === "/api/experimental/project/proj_test/worktree") {
if (url.pathname === "/api/worktree/proj_test") {
if (request.method === "GET") return json([{ directory: worktree }])
if (request.method === "POST") return json({ directory: `${worktree}/created` })
return new Response(null, { status: 204 })
}
if (url.pathname === "/api/experimental/project/proj_test/worktree/refresh")
return new Response(null, { status: 204 })
if (url.pathname === "/api/worktree/proj_test/refresh") return new Response(null, { status: 204 })
if (url.pathname === "/api/shell")
return json({
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
@@ -157,7 +156,6 @@ 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 = () => {}
+19 -10
View File
@@ -15,20 +15,29 @@ description: "Get started with OpenCode."
## Install
### Install script
<CodeGroup>
```bash
```bash npm
npm install -g @opencode-ai/cli@next
```
```bash bun
bun install -g --trust @opencode-ai/cli@next
```
```bash pnpm
pnpm add -g --allow-build=@opencode-ai/cli @opencode-ai/cli@next
```
```bash yarn
yarn global add @opencode-ai/cli@next
```
```bash curl
curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash
```
You can also install it with the following package managers.
<Tabs>
<Tab title="npm">```bash npm install -g @opencode-ai/cli@next ```</Tab>
<Tab title="bun">```bash bun install -g --trust @opencode-ai/cli@next ```</Tab>
<Tab title="pnpm">```bash pnpm add -g --allow-build=@opencode-ai/cli @opencode-ai/cli@next ```</Tab>
<Tab title="Yarn">```bash yarn global add @opencode-ai/cli@next ```</Tab>
</Tabs>
</CodeGroup>
The package uses a trusted postinstall script to select the native `opencode2` binary for your platform. The Bun and pnpm
commands above explicitly allow that script to run.
+2 -2
View File
@@ -10534,7 +10534,7 @@
"summary": "List references"
}
},
"/api/experimental/project/{projectID}/worktree": {
"/api/worktree/{projectID}": {
"get": {
"tags": ["worktree"],
"operationId": "v2.worktree.list",
@@ -10736,7 +10736,7 @@
}
}
},
"/api/experimental/project/{projectID}/worktree/refresh": {
"/api/worktree/{projectID}/refresh": {
"post": {
"tags": ["worktree"],
"operationId": "v2.worktree.refresh",
+2 -2
View File
@@ -10534,7 +10534,7 @@
"summary": "List references"
}
},
"/api/experimental/project/{projectID}/worktree": {
"/api/worktree/{projectID}": {
"get": {
"tags": ["worktree"],
"operationId": "v2.worktree.list",
@@ -10736,7 +10736,7 @@
}
}
},
"/api/experimental/project/{projectID}/worktree/refresh": {
"/api/worktree/{projectID}/refresh": {
"post": {
"tags": ["worktree"],
"operationId": "v2.worktree.refresh",