mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-14 21:56:54 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f078e708d0 | ||
|
|
4ece831269 |
@@ -175,14 +175,14 @@ const table = sqliteTable("session", {
|
||||
## V2 Session Core
|
||||
|
||||
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
|
||||
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_pending` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries, consuming the pending row in the same event transaction; `session_pending` stores only unconsumed work.
|
||||
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Retry of an already-promoted input reconciles against the projected message and the durable admitted event rather than a retained row.
|
||||
- Keep durable prompt admission separate from model execution. `Session.prompt(...)` publishes `session.inbox.enqueued`, whose projection inserts one durable `session_inbox` row, before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. Delivery publishes `session.inbox.delivered`; its projection consumes the inbox row and inserts the visible message in the same transaction. `session_inbox` stores only unconsumed work.
|
||||
- Reusing a Session ID adopts the existing Session. While a user or synthetic inbox item is pending, reusing its ID reconciles only when Session, type, complete payload, metadata, and delivery match; conflicting reuse fails. Once delivered, retry reconciliation for those message-producing items uses the projected message and does not require retained enqueue history or the original delivery mode. Control items keep their operation-specific conflict behavior.
|
||||
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. Most Steps have one Physical Attempt; overflow-triggered compaction recovery may rebuild one Step for a second attempt. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe step boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default. Steers deliver in enqueue order at safe step boundaries, stopping before compaction or move control items. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
|
||||
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
|
||||
- Keep event replay ownership separate from clustered Session execution ownership.
|
||||
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
|
||||
- `session.instructions.updated` stores only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement retains it so destination instruction changes are chronological, while committed revert clears it. Unavailable sources retain the last value and block only the initial complete delta.
|
||||
- `session.instructions.updated` stores changed source keys and content hashes and may freeze rendered chronological update text. Blob values live once in `instruction_blob`; the projected `instruction_state` row is the normal boundary-processing source of current and initial values. Request assembly renders the epoch baseline from stored values, while later frozen updates enter history as durable System messages. Completed compaction moves the instruction epoch; Session movement retains it so destination instruction changes are chronological, while committed revert clears it. Forks adopt the parent's newest instruction values even when copied message history ends at an earlier boundary. Unavailable sources retain the last value and block only the initial complete delta.
|
||||
|
||||
@@ -27,8 +27,9 @@ exits before expensive server boot. The design does not require clients to
|
||||
agree on a single initiator.
|
||||
|
||||
This proposal does not introduce a supervisor process, warm candidate server,
|
||||
protocol negotiation, idle background restart, or general execution-recovery
|
||||
framework.
|
||||
protocol negotiation, idle background restart, or clustered or exactly-once
|
||||
execution recovery. Session execution separately provides bounded local recovery
|
||||
through durable write-ahead claims.
|
||||
|
||||
## Architecture at a Glance
|
||||
|
||||
@@ -176,9 +177,9 @@ This design gives each concept one authority.
|
||||
- Adding a permanent steward, proxy, or supervisor process.
|
||||
- Zero-downtime worker handoff or automatic rollback.
|
||||
- Application protocol negotiation or automatic TUI self-restart.
|
||||
- General hard-crash recovery for active Sessions.
|
||||
- Defining recovery semantics for provider attempts, tools, shells, sub-agents,
|
||||
permissions, questions, or background jobs.
|
||||
- Exactly-once recovery for provider attempts, tools, shells, sub-agents,
|
||||
permissions, questions, or background jobs. Top-level Session continuation
|
||||
after process death is handled separately through durable execution claims.
|
||||
- Automatically killing a frozen owner.
|
||||
- Bounding concurrent location cold boots after clients reconnect.
|
||||
- Multi-machine or clustered service placement.
|
||||
@@ -202,9 +203,10 @@ This design gives each concept one authority.
|
||||
diagnosed, non-retryable cause.
|
||||
8. **Clients do not kill an unresponsive owner automatically.** Destructive
|
||||
recovery requires the explicit `service restart` command.
|
||||
9. **Lifecycle does not promise execution semantics.** Graceful replacement
|
||||
invokes Session suspension and resumption hooks, but tool-level continuity
|
||||
belongs to a separate design.
|
||||
9. **Lifecycle does not promise exactly-once execution.** A successor invokes
|
||||
the Session execution-claim sweep, which resumes from durable history.
|
||||
Provider-attempt identity and tool-side-effect fencing belong to separate
|
||||
designs.
|
||||
|
||||
## System Model
|
||||
|
||||
@@ -485,23 +487,20 @@ The UI derives text from status:
|
||||
| `failed` | Actionable failure message |
|
||||
| `ready` | Normal TUI |
|
||||
|
||||
## Graceful Session Continuity
|
||||
## Session Continuity
|
||||
|
||||
Version-mismatch replacement uses the existing graceful Session suspension and
|
||||
resumption hooks:
|
||||
Every process-local Session busy period writes a durable execution claim before
|
||||
its runner starts. Success, failure, and user interruption release the claim;
|
||||
shutdown interruption and process death leave it intact. The
|
||||
successor sweeps claimed top-level Sessions, durably counts a recovery attempt,
|
||||
appends a continuation instruction, and resumes from projected history. The same
|
||||
mechanism covers graceful replacement, crash, SIGKILL, and runtime eviction.
|
||||
|
||||
1. The old server snapshots active Session IDs during graceful teardown.
|
||||
2. The successor schedules those Sessions for continuation.
|
||||
3. The runner reloads durable Session history before continuing.
|
||||
|
||||
This lifecycle design does not define what an interrupted physical provider
|
||||
attempt or tool invocation means. It does not promise that external side effects
|
||||
did not occur, replay the exact interrupted tool, preserve an in-memory form, or
|
||||
recover process-local background work.
|
||||
|
||||
Those concerns require a separate execution-continuity design covering tools,
|
||||
shells, sub-agents, permissions, questions, provider attempts, and hard-crash
|
||||
recovery.
|
||||
Recovery fails stale running tool projections before further model work, but it
|
||||
does not prove whether an interrupted provider request or external operation
|
||||
already took effect. It does not replay the exact interrupted tool, preserve an
|
||||
in-memory form, recover process-local background work, or guarantee exactly-once
|
||||
provider or tool behavior.
|
||||
|
||||
## Unresponsive Owner
|
||||
|
||||
@@ -531,13 +530,15 @@ Automatic frozen-owner recovery is deferred.
|
||||
|
||||
1. The old service installs vNext but keeps running.
|
||||
2. A fresh vNext TUI finds the healthy vOld service and requests graceful stop.
|
||||
3. The old service reports `stopping`, suspends active Sessions, and exits.
|
||||
3. The old service reports `stopping` and exits. Shutdown interruption preserves
|
||||
the execution claims already written by active Sessions.
|
||||
4. Open TUIs enter their indefinite status loops.
|
||||
5. One or more clients spawn contenders.
|
||||
6. One contender acquires the service lock. Losers exit before heavy boot.
|
||||
7. The winner binds and registers the lifecycle shell as `starting`.
|
||||
8. Clients stop spawning and wait on the observable winner.
|
||||
9. The winner initializes the application and reports `ready`.
|
||||
9. The winner initializes the application, sweeps orphaned execution claims,
|
||||
and reports `ready`.
|
||||
10. TUIs rebuild clients, reconcile state, and resume.
|
||||
|
||||
### Server crashes while ready
|
||||
@@ -546,7 +547,9 @@ Automatic frozen-owner recovery is deferred.
|
||||
2. Clients call `ensureRunning`.
|
||||
3. Process death has released the service lock.
|
||||
4. One contender wins, replaces registration, and starts normally.
|
||||
5. Detailed active-execution recovery is outside this design.
|
||||
5. Application startup sweeps orphaned top-level execution claims and resumes
|
||||
them with bounded attempt accounting. External side effects remain
|
||||
potentially ambiguous.
|
||||
|
||||
### Winner crashes during startup
|
||||
|
||||
@@ -650,8 +653,9 @@ was the observed incident cost.
|
||||
`packages/client` with `bun run generate`.
|
||||
6. **Codify launch versus reconnect.** Fresh launch enforces installed version;
|
||||
reconnect never activates replacement.
|
||||
7. **Integrate graceful replacement.** Preserve current background-install and
|
||||
fresh-launch activation behavior while invoking Session continuity hooks.
|
||||
7. **Integrate Session continuity.** Preserve current background-install and
|
||||
fresh-launch activation behavior while invoking startup execution-claim
|
||||
recovery.
|
||||
8. **Harden explicit recovery.** Verify exact process identity during explicit
|
||||
`service restart`; never automatically kill an unresponsive owner.
|
||||
9. **Run the full multi-process suite.** Include repeated restart cycles and
|
||||
@@ -677,7 +681,8 @@ was the observed incident cost.
|
||||
|
||||
- Idle background update activation with an admission fence.
|
||||
- Application protocol compatibility and automatic local TUI re-exec.
|
||||
- Durable execution recovery for provider attempts and tools.
|
||||
- Stronger execution recovery with provider-attempt identity, tool-side-effect
|
||||
idempotency or fencing, and clustered ownership.
|
||||
- Shell, sub-agent, permission, question, and background-job continuity.
|
||||
- Automatic recovery for a positively identified frozen owner.
|
||||
- Cold-boot concurrency limits and interaction-prioritized location loading.
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { PersistentPty } from "./persistent-pty/index.js"
|
||||
export { Group } from "./persistent-pty/group.js"
|
||||
@@ -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] })
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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." }))
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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) }
|
||||
@@ -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"
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+42
-64
@@ -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"}>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
@@ -1004,6 +1001,24 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
}}
|
||||
onMouseDrag={drag}
|
||||
onMouseDragEnd={release}
|
||||
renderAfter={function (buffer) {
|
||||
const x = Math.max(0, this.screenX)
|
||||
const y = this.screenY + this.height
|
||||
const width = Math.min(this.width, buffer.width - x)
|
||||
if (y < 0 || y >= buffer.height || width <= 0) return
|
||||
buffer.fillRect(
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
1,
|
||||
RGBA.fromValues(
|
||||
theme.background.default.r,
|
||||
theme.background.default.g,
|
||||
theme.background.default.b,
|
||||
mode() === "light" ? 0.14 : 0.28,
|
||||
),
|
||||
)
|
||||
}}
|
||||
>
|
||||
<Show when={layout().before > 0}>
|
||||
<text width={sessionTabOverflowWidth(layout().before)} fg={theme.text.subdued} selectable={false}>
|
||||
@@ -1035,7 +1050,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 +1125,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()
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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))
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -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} />,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -157,7 +157,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 = () => {}
|
||||
|
||||
@@ -119,17 +119,18 @@ not included in model context.
|
||||
## Compaction advances the instruction epoch
|
||||
|
||||
Conversation compaction and instruction synchronization are separate. Before
|
||||
promoting pending input, V2 compares live instruction sources with the latest
|
||||
admitted values. Ordinary changes become durable value deltas; their
|
||||
model-facing System messages are derived during request assembly rather than
|
||||
persisted.
|
||||
each physical model attempt, V2 compares live instruction sources with the
|
||||
latest admitted values, before delivering pending input for that attempt.
|
||||
Ordinary changes become durable value deltas. Later changes freeze their
|
||||
model-facing text when admitted and project it as chronological System messages;
|
||||
request assembly renders only the epoch baseline from stored values.
|
||||
|
||||
Completed compaction advances the instruction epoch at the exact ended-event
|
||||
sequence and makes the currently admitted values initial. It does not reread
|
||||
sources or publish an instruction event. Session movement and committed revert
|
||||
clear the instruction fold so the next safe boundary requires one complete
|
||||
source read. See [Instructions](/instructions) for source ordering and update
|
||||
behavior.
|
||||
sources or publish an instruction event. Session movement retains instruction
|
||||
state so destination changes become chronological updates. Committed revert
|
||||
clears instruction state so the next model attempt requires one complete source
|
||||
read. See [Instructions](/instructions) for source ordering and update behavior.
|
||||
|
||||
## Current limitations
|
||||
|
||||
|
||||
@@ -102,8 +102,9 @@ than part of the initial instructions.
|
||||
|
||||
## Changes
|
||||
|
||||
Before promoting pending input, V2 compares live instruction sources with the
|
||||
latest admitted source values:
|
||||
Before each physical model attempt, V2 compares live instruction sources with
|
||||
the latest admitted source values. This comparison happens before pending input
|
||||
is delivered for that attempt:
|
||||
|
||||
- A new or changed ambient `AGENTS.md` aggregate is announced as a system update
|
||||
that replaces the previous ambient aggregate.
|
||||
@@ -115,10 +116,13 @@ latest admitted source values:
|
||||
- Completed conversation compaction advances the instruction epoch, making the
|
||||
currently admitted values initial without rereading sources or authoring an
|
||||
instruction event.
|
||||
- Moving a session or committing a revert clears the instruction fold. The next
|
||||
safe boundary requires one complete source read before promoting input.
|
||||
- Moving a session retains instruction state, so destination changes become
|
||||
chronological updates. Committing a revert clears instruction state; the next
|
||||
model attempt requires one complete source read before delivering input.
|
||||
|
||||
The durable event stores changed source keys and value hashes, not rendered
|
||||
prose. During request assembly, OpenCode renders the epoch's initial values and
|
||||
interleaves later changes as chronological System messages. Clients see changed
|
||||
keys but never the privileged value bodies.
|
||||
The durable event stores changed source keys and value hashes. Initial baseline
|
||||
events contain no rendered prose. Later changes render once when admitted and
|
||||
freeze that optional text in the event, which projects it as a chronological
|
||||
System message. During request assembly, OpenCode renders the epoch's initial
|
||||
values and reuses projected update messages verbatim. Clients see changed keys
|
||||
but never the privileged value bodies.
|
||||
|
||||
+5
-6
@@ -27,12 +27,11 @@ Generated clients follow the assembled public `HttpApi`. GitHub issues own activ
|
||||
|
||||
## Decisions And Proposals
|
||||
|
||||
| Document | Status | Job |
|
||||
| ----------------------------------------------------------------- | -------------------------- | ---------------------------------------------------------------------------- |
|
||||
| [Event stream](./event-stream-architecture.md) | Accepted and implemented | Record why public events use one encoded feed with independent queues. |
|
||||
| [Managed restart continuation](./session-restart-continuation.md) | Accepted and implemented | Record why graceful managed-service restart uses private Session suspension. |
|
||||
| [Instruction sync](./instruction-sync-proposal.md) | Accepted and implemented | Record why instruction state is value deltas plus derived rendering. |
|
||||
| [Provider policy](./provider-policy.md) | Proposed and unimplemented | Explore provider authorization independently from provider configuration. |
|
||||
| Document | Status | Job |
|
||||
| ----------------------------------------------------------------- | -------------------------- | --------------------------------------------------------------------------- |
|
||||
| [Event stream](./event-stream-architecture.md) | Accepted and implemented | Record why public events use one encoded feed with independent queues. |
|
||||
| [Managed restart continuation](./session-restart-continuation.md) | Superseded decision record | Preserve the graceful-only design replaced by write-ahead execution claims. |
|
||||
| [Provider policy](./provider-policy.md) | Proposed and unimplemented | Explore provider authorization independently from provider configuration. |
|
||||
|
||||
## Historical Context
|
||||
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
# Instruction Sync: V2 Architecture
|
||||
|
||||
Status: implemented on `instruction-sync-v2` (2026-07-10).
|
||||
|
||||
## Principle
|
||||
|
||||
The model is a replica that OpenCode can write but cannot read or edit. The transcript is the one-way channel. Instruction sync keeps mutable privileged context (`AGENTS.md`, guidance, API entries, date, and environment) current over that channel without rewriting text that was already sent.
|
||||
|
||||
**The durable log stores only irreducible facts: which source values changed, and when. Everything else is a function of the log and current renderer code.**
|
||||
|
||||
## Durable Fact
|
||||
|
||||
```typescript
|
||||
"session.instructions.updated.2" {
|
||||
sessionID: Session.ID
|
||||
delta: Record<Instructions.Key, Instructions.Hash | "removed">
|
||||
}
|
||||
```
|
||||
|
||||
A hash overwrites one source value. The literal `"removed"` removes it (chosen over JSON `null` because record-value nullability does not survive every client generator; it cannot collide with a 64-hex hash). The event stores no rendered text, mode, baseline, or snapshot.
|
||||
|
||||
Each hash body is canonical JSON stored once in the machine-local `instruction_blob` table. Hashes are local pointers, not cross-machine promises.
|
||||
|
||||
## Epochs And Folds
|
||||
|
||||
An instruction epoch is the span between completed compactions. `epochStart` is the sequence of the last `session.compaction.ended`, or the initial complete v2 delta when no epoch exists.
|
||||
|
||||
Folding deltas in durable sequence order derives:
|
||||
|
||||
```text
|
||||
values through epochStart -> renderInitial -> initial instructions
|
||||
each delta after epochStart -> renderUpdate -> chronological System message
|
||||
final values -> next boundary comparison state
|
||||
```
|
||||
|
||||
Completed compaction moves the epoch by copying current hashes to initial hashes at the exact ended-event sequence. It does not read sources or publish an instruction event.
|
||||
|
||||
Session movement and committed revert clear the fold. The next boundary must establish one complete delta before input promotion.
|
||||
|
||||
## Projection Cache
|
||||
|
||||
```text
|
||||
instruction_state
|
||||
session_id
|
||||
epoch_start
|
||||
through_seq
|
||||
initial_values
|
||||
current_values
|
||||
```
|
||||
|
||||
This row is derived state. The boundary compares `through_seq` with the latest relevant durable sequence. A missing or stale row folds the log and rewrites the cache without publishing an event.
|
||||
|
||||
The relevant reducer inputs are:
|
||||
|
||||
- `session.instructions.updated.2`: apply the delta; the first one establishes an epoch, including an empty complete delta.
|
||||
- `session.compaction.ended.1`: make current values initial and move `epochStart`.
|
||||
- `session.moved.1`: clear values.
|
||||
- `session.revert.committed.1`: clear values.
|
||||
- `session.forked.2`: derive from parent ancestry through its frozen `parentSeq`.
|
||||
|
||||
## Sources
|
||||
|
||||
```typescript
|
||||
interface Source {
|
||||
readonly key: Key
|
||||
readonly read: Effect<Json | Unavailable | Removed>
|
||||
readonly initial: (value: Json) => string | undefined
|
||||
readonly changed: (previous: Json, current: Json) => string | undefined
|
||||
readonly removed: (previous: Json) => string | undefined
|
||||
}
|
||||
|
||||
namespace Source {
|
||||
interface Definition<A> {
|
||||
readonly key: Key
|
||||
readonly codec: Schema.Codec<A, Json>
|
||||
readonly read: Effect<A | Unavailable | Removed>
|
||||
readonly render: {
|
||||
readonly initial: (value: A) => string
|
||||
readonly changed: (previous: A, current: A) => string
|
||||
readonly removed?: (previous: A) => string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Instructions = ReadonlyArray<Source>
|
||||
|
||||
declare function make<A>(definition: Source.Definition<A>): Instructions
|
||||
```
|
||||
|
||||
Producers author a typed `Source.Definition<A>`. `make` captures its codec and renderers in one JSON-level `Source`, the representation used for heterogeneous composition, durable values, and historical rendering. `Instructions` is an ordered collection of those sources; combining collections preserves order and rejects duplicate keys.
|
||||
|
||||
`read` runs once per source at the safe boundary, never at layer construction or request assembly. Codecs must be canonical: object keys are canonicalized by the hash function, while source-owned collections must have deterministic order and values must not contain observation timestamps.
|
||||
|
||||
`Unavailable` means the read failed temporarily. The initial complete delta blocks while any source is unavailable; later boundaries retain its prior hash silently.
|
||||
|
||||
`Removed` is an observed absence. If the key currently has a value, the next delta stores `"removed"` and assembly calls the source's removal renderer. A source that disappears from a software upgrade does not imply removal; its retained value becomes invisible while its renderer is absent.
|
||||
|
||||
## Safe Boundary
|
||||
|
||||
Once per physical attempt, before input promotion:
|
||||
|
||||
1. Load the selected agent and compose built-ins, discovery, skill guidance, reference guidance, MCP guidance, and API entries in fixed order.
|
||||
2. Read every source concurrently exactly once.
|
||||
3. Encode and hash values; compare with `instruction_state.current_values`.
|
||||
4. At the initial v2 boundary, require a complete read and admit one complete delta, including `{}` for a truly empty set.
|
||||
5. For later boundaries, insert new blobs and admit one delta only when a hash or explicit removal changed.
|
||||
6. Promote pending input.
|
||||
7. Read projected messages, epoch values, blobs, and post-epoch deltas in one database transaction.
|
||||
8. Render initial instructions and interleave derived update messages by durable sequence.
|
||||
|
||||
`MoveSession` interrupts any active drain and awaits idle before publishing `session.moved`, matching the best-effort ordering used by Session removal.
|
||||
|
||||
The blob inserts, durable event, and fold-cache advance share the event transaction.
|
||||
|
||||
## Forks
|
||||
|
||||
`session.forked.2` carries `parentSeq`, the authoritative parent event cutoff. For a fork before message N, the cutoff is `message.seq - 1`, so instruction changes admitted immediately before that message are inherited while later parent state is not.
|
||||
|
||||
The child stores the cutoff as `session.fork_seq`. Its virtual instruction log is the parent's ancestry through that cutoff followed by child events. Child event sequence reservation begins after the cutoff, preserving chronological interleaving with copied message rows. Replay accepts the intentional fork gap because the fork projector reserves the inherited prefix before later child events replay.
|
||||
|
||||
## API Entries
|
||||
|
||||
Each visible entry is one `api/<key>` source. DELETE marks the row as a hidden tombstone rather than physically removing it, preserving the renderer needed to admit and narrate the removal; list responses hide tombstones. The nullable value column preserves JSON `null`, while the separate tombstone flag distinguishes removal. A later PUT revives the same source.
|
||||
|
||||
PUT measures encoded JSON in UTF-8 and rejects values larger than 8KB with `InstructionEntryValueTooLargeError` (HTTP 413). Values are never truncated.
|
||||
|
||||
## Content-Addressed Storage
|
||||
|
||||
The blob store grows by one row per distinct encoded value. No GC ships initially. This is an at-rest deduplication policy; deleting a Session does not remove values only that Session referenced, so clients must not put secrets in API entries.
|
||||
|
||||
If retention becomes necessary, add mark-and-sweep: walk live v2 deltas for referenced hashes and delete the rest. No schema change or eager reference counter is required.
|
||||
|
||||
The blob store is machine/tenant scoped and must never deduplicate across tenants.
|
||||
|
||||
**Storage format is not wire format.** Any future V2 sync, export, share, or workspace-transfer boundary must hydrate referenced values, verify each body against its hash on ingestion, and insert blobs before replaying the event. Current V2 has no cross-machine durable replay surface; hashes are sufficient for local event logs and key-only clients.
|
||||
|
||||
## Client Projection
|
||||
|
||||
Instruction deltas do not project `session_message` rows. The TUI derives a non-model-facing notice from event keys, for example `Instructions updated: core/date, api/plan`. Model-facing update prose exists only during runner assembly and is excluded from compaction summaries.
|
||||
|
||||
## Migration
|
||||
|
||||
Migration deletes pre-beta `session.instructions.updated.1` events and their event-derived System rows, then drops `instruction_checkpoint`. It leaves unrelated events and System messages intact. The next safe boundary establishes one complete v2 delta.
|
||||
|
||||
Existing `session.forked.1` rows migrate to v2 with the event prefix reserved by their original projection as `parentSeq`.
|
||||
|
||||
## Accepted Costs
|
||||
|
||||
- Renderer changes can change request bytes for identical stored values, causing one provider-cache miss. They do not create an instruction delta.
|
||||
- Rendered text is not retained verbatim.
|
||||
- Source additions or software removals are silent unless a source explicitly reads `Removed`.
|
||||
- Clients display changed keys, not privileged prose.
|
||||
- Blob GC is deferred.
|
||||
- Pre-beta instruction events are deleted during migration; logs with resulting sequence gaps are not guaranteed to replay into a blank database.
|
||||
@@ -2,12 +2,25 @@
|
||||
|
||||
| Field | Value |
|
||||
| -------------- | ------------------------------------------------------------ |
|
||||
| Status | Accepted and implemented |
|
||||
| Status | Superseded by write-ahead execution claims |
|
||||
| Author | Kit Langton |
|
||||
| Date | 2026-07-08 |
|
||||
| Superseded | 2026-08-14 |
|
||||
| Tracking issue | [#35646](https://github.com/anomalyco/opencode/issues/35646) |
|
||||
|
||||
## Summary
|
||||
## Current Decision
|
||||
|
||||
Session execution now writes a durable claim when a process-local busy period starts. Success, failure, and user interruption release the claim. Shutdown interruption and process death preserve it, so graceful restart, crash, SIGKILL, and runtime eviction have the same durable recovery signature.
|
||||
|
||||
On startup, managed Node and fetch runtimes sweep claimed top-level Sessions. Recovery increments a durable attempt counter, appends a continuation instruction, and resumes from projected history. The claim remains until a terminal event releases it, so another process death remains recoverable. After ten automatic recovery attempts by default, the next sweep records terminal failure instead of creating a restart loop.
|
||||
|
||||
The historical `time_suspended` column now stores this execution claim, and `resume_attempts` counts automatic recovery attempts against the runtime's configured budget. A claim is a recovery marker, not live status, a lock, clustered ownership, or an exactly-once guarantee. Recovery fails stale running tool projections before further model work, but it cannot prove whether an interrupted provider request or external side effect already took effect.
|
||||
|
||||
See the current [Session contract](./session.md) and the implementation in `packages/core/src/session/execution.ts` and `packages/core/src/session/execution/restart.ts`.
|
||||
|
||||
## Original Summary
|
||||
|
||||
The remainder of this document records the graceful-only suspension design that first implemented issue #35646. It is retained as design history and does not describe the current recovery mechanism.
|
||||
|
||||
When the managed OpenCode server shuts down gracefully, active Sessions continue automatically the next time the managed server starts.
|
||||
|
||||
|
||||
+18
-18
@@ -1,12 +1,12 @@
|
||||
# V2 Session Contract
|
||||
|
||||
Status: **Current semantic overview.** Protocol owns public operations, Schema owns public shapes and durable events, and Core owns execution and persistence behavior. [CONTEXT.md](../../CONTEXT.md) defines the canonical terms used here.
|
||||
Status: **Current semantic overview.** Protocol owns public operations, Schema owns public shapes and durable events, and Core owns execution and persistence behavior.
|
||||
|
||||
## Prompt Admission Precedes Execution
|
||||
|
||||
`SessionV2.prompt(...)` records one durable `session.input.admitted` fact and one `session_pending` row before advisory execution begins. Pending input remains outside model-visible Session History until promotion. The promotion transaction publishes `session.input.promoted`, projects the visible message, and consumes the pending row atomically.
|
||||
`Session.prompt(...)` publishes one durable `session.inbox.enqueued` fact whose projection inserts one `session_inbox` row before advisory execution begins. An inbox item remains outside model-visible Session History until delivery. The `session.inbox.delivered` projection consumes the row and inserts a visible user or synthetic message atomically; compaction and move control items are consumed without becoming transcript messages.
|
||||
|
||||
Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. A retry of an already-promoted input reconciles against projected history and its durable admission event.
|
||||
Reusing a Session ID adopts the existing Session. While a user or synthetic item remains pending, reusing its ID reconciles only when Session, item type, complete payload, metadata, and delivery match; conflicting reuse fails. After delivery, retry reconciliation for those message-producing items uses the projected message and does not require enqueue history or the original delivery mode. Compaction and move controls retain operation-specific conflict behavior.
|
||||
|
||||
`resume` controls scheduling, not durability:
|
||||
|
||||
@@ -15,12 +15,12 @@ Reusing a Session ID adopts the existing Session. Reusing a prompt message ID re
|
||||
|
||||
Delivery is explicit:
|
||||
|
||||
- `steer` is the default. Steers promote together at the next Safe Step Boundary while the current Session Drain still requires continuation.
|
||||
- `queue` remains pending while the Session can continue. When the Session would otherwise become idle, one queued input promotes; the runner then reevaluates continuation before promoting another.
|
||||
- `steer` is the default. Steers deliver in enqueue order at the next Safe Step Boundary. Delivery stops before a compaction or move control item.
|
||||
- `queue` remains pending while the Session can continue. At an idle boundary, steers still take priority; otherwise one queued item delivers, followed by any steers that arrived during delivery. The runner then reevaluates continuation before another queued item.
|
||||
|
||||
Promoting new user input resets the selected agent's step allowance. A batch of steers resets it once.
|
||||
|
||||
Manual compaction uses the same pending store as one coalesced barrier. The barrier blocks later input promotion until compaction ends or fails, then is consumed.
|
||||
Manual compaction and Session movement use the same inbox as control items. Each request has its own inbox identity and delivery mode. A control item forms a delivery boundary so later steers do not cross it.
|
||||
|
||||
## Execution Is Process-Local
|
||||
|
||||
@@ -35,37 +35,37 @@ Manual compaction uses the same pending store as one coalesced barrier. The barr
|
||||
|
||||
The public interrupt operation verifies that the durable Session exists. An unknown Session fails with `SessionNotFoundError`; a known Session that is idle, settled, or not locally owned is a no-op.
|
||||
|
||||
`sessions.active()` snapshots foreground drains currently owned by this process. Durable execution events are historical observations, not liveness or ownership records.
|
||||
`sessions.active()` snapshots busy periods currently owned by this process. Durable execution events and claims are historical and recovery records, not proof that this process is still live.
|
||||
|
||||
The managed server provides graceful restart continuity through private Session suspension. Shutdown marks active Sessions before interrupting them; the next managed server atomically consumes each suspension and schedules at most one resume. Hard-crash recovery and exactly-once provider or tool execution remain out of scope. See [Managed restart continuation](./session-restart-continuation.md).
|
||||
Execution commits a write-ahead claim when a process-local busy period starts. Success, failure, and user interruption release the claim; shutdown interruption and unclean process death preserve it. On startup, managed Node and fetch runtimes resume claimed top-level Sessions, append a durable continuation instruction, and count recovery attempts. Recovery is bounded per claimed execution but does not guarantee exactly-once provider requests or tool effects. See [Session restart recovery](./session-restart-continuation.md).
|
||||
|
||||
## One Step Owns One Logical LLM Call
|
||||
## One Step May Have Several Physical Attempts
|
||||
|
||||
Before each Step, the runner reloads Session History, resolves the selected agent and model, prepares instructions, and materializes tools. Most Steps make one Physical Attempt; overflow-triggered compaction recovery may rebuild the same Step for one additional provider request.
|
||||
Before each Step, the runner reloads Session History, resolves the selected agent and model, prepares instructions, and materializes tools. Most Steps make one Physical Attempt. Generic retry, continuation-state rejection, incomplete-stream continuation, or overflow-triggered compaction may make another attempt without promoting input again.
|
||||
|
||||
Each complete local tool call is durable before side effects begin. Local calls start eagerly and may run concurrently, but terminal outcome publication remains serialized. Every local and hosted call reaches durable success or failure before the Step publishes its single terminal ended or failed event.
|
||||
|
||||
Tool calls belong to their assistant message. `callID` is unique only within that Step, so durable tool events also carry `assistantMessageID`.
|
||||
Tool calls belong to their assistant message. A tool-call `id` is unique only within that Step, so durable tool events also carry `assistantMessageID`.
|
||||
|
||||
Before `runStep` assembles its provider request, orphan reconciliation fails tool calls still projected as streaming or running from an earlier process. It preserves the original assistant attribution and never replays ambiguous side effects.
|
||||
At drain start, orphan reconciliation fails tool calls still projected as streaming or running from an earlier process before further model work. It preserves the original assistant attribution and never directly replays ambiguous side effects.
|
||||
|
||||
After a local outcome, continuation reloads projected history and begins a new Step. The runner never delegates orchestration to an in-memory tool loop.
|
||||
|
||||
## Retry Is Narrow And Observable
|
||||
|
||||
Core retries typed rate-limit, provider-internal, and transport failures only before durable assistant content, tool-call, tool-output, or tool-execution evidence exists. The initial request plus at most four retries use exponential backoff, increased when the provider supplies a longer retry delay.
|
||||
Generic scheduled retry covers rate-limit and provider-internal failures, transport failures that are unsent or have unknown delivery, and provider output classified as an incomplete stream. The initial request plus at most four retries use jittered exponential backoff, increased when the provider supplies a longer retry delay.
|
||||
|
||||
Each retry attempt is a distinct Step, consumes the selected agent's allowance, and reuses the assistant message ID while no durable output exists. `session.retry.scheduled` records the next attempt and absolute retry time. A later Step start or terminal failure clears projected retry state. Surviving retry history never triggers post-crash recovery by itself.
|
||||
Before durable output, generic retries retain the logical step number and assistant message ID and do not consume another agent-step allowance. An incomplete stream after durable output instead preserves the failed partial assistant, adds a synthetic continuation instruction, and continues with a new assistant message ID under the same retry budget. Provider continuation rejection permits one immediate full-context rebuild without a scheduled-retry event. `session.retry.scheduled` records generic backoff; later activity or a terminal execution event clears projected retry state.
|
||||
|
||||
A normalized content-filter finish fails the Step. Any partial streamed content remains visible.
|
||||
|
||||
## Instructions Are Value Deltas
|
||||
|
||||
Instruction sync persists values, never rendered privileged prose. The only durable fact is `session.instructions.updated { delta }`, mapping each changed source key to a SHA-256 content hash, with the literal `"removed"` for observed absence. Canonical JSON bodies live once in the machine-local `instruction_blob` store; `instruction_state` is a rebuildable fold cache, never primary state. The runner explicitly combines built-ins, ambient discovery, selected-agent skill guidance, references, MCP guidance, and API-managed instruction entries. There is no instruction registry.
|
||||
Instruction sync persists content-addressed values and may freeze rendered chronological prose. `session.instructions.updated { delta, text? }` maps each changed source key to a SHA-256 content hash, with the literal `"removed"` for observed absence. Canonical JSON bodies live once in the machine-local `instruction_blob` store. The projected `instruction_state` row supplies current and epoch-initial values during normal boundary processing. The runner explicitly combines built-ins, ambient discovery, selected-agent skill guidance, references, MCP guidance, and API-managed instruction entries. There is no instruction registry.
|
||||
|
||||
At each Safe Step Boundary the runner reads every source concurrently exactly once, hashes encoded values, and admits one delta atomically with its new blobs before input promotion. The initial delta must be complete; an unavailable source blocks only that initial delta and otherwise silently retains the stored value. Initial instructions and chronological update messages are rendered from stored values during request assembly and are never persisted; clients display changed keys.
|
||||
Before each Physical Attempt that reaches model execution, the runner reads every source concurrently exactly once, hashes encoded values, and admits one delta atomically with its new blobs before input delivery. The initial delta must be complete; it carries no update text. An unavailable source blocks only that initial delta and otherwise silently retains the stored value. Request assembly renders the epoch baseline from stored values. Later changes render once at admission, freeze optional `text` in the durable event, and project that text as a chronological System message; clients display changed keys rather than privileged prose.
|
||||
|
||||
An instruction epoch spans completed compactions. `session.compaction.ended` moves the epoch start to its exact sequence, making current values initial, without reading sources or authoring an instruction event. Session movement and committed revert clear the fold. Forks record an authoritative parent sequence and derive values from the parent's ancestry through that cutoff. Model selection affects request assembly but is not itself an instruction source. See the [instruction sync design](./instruction-sync-proposal.md).
|
||||
An instruction epoch spans completed compactions. `session.compaction.ended` moves the epoch start to its exact sequence, making current values initial, without reading sources or authoring an instruction event. Session movement retains state so destination changes become chronological updates; committed revert clears state so the next boundary establishes a fresh baseline. A fork copies messages only through its selected boundary but adopts the parent's newest instruction values as its baseline. Model selection affects request assembly but is not itself an instruction source.
|
||||
|
||||
## Compaction Rebuilds Active History
|
||||
|
||||
@@ -85,6 +85,6 @@ There is no separate finite Session-history endpoint. Request/response consumers
|
||||
|
||||
## Recovery Boundaries Stay Explicit
|
||||
|
||||
An advisory wake does not infer that ambiguous provider work is safe to retry after input promotion. Explicit resume may continue from durable projected history, but automatic hard-crash continuation requires a separate design covering provider-dispatch ambiguity, tool idempotency, retry budgets, and future clustered ownership.
|
||||
An advisory wake is not itself crash recovery. Crash recovery is driven by a write-ahead execution claim that survives without a releasing terminal. Startup recovery resumes claimed top-level Sessions from durable projected history with bounded attempt accounting. It fails stale running tool projections before continuing, but it cannot prove whether an interrupted external operation already took effect and does not guarantee exactly-once provider or tool behavior.
|
||||
|
||||
Event replay ownership is separate from Session execution ownership. Local execution remains process-owned until clustering introduces an explicit placement and fencing protocol.
|
||||
|
||||
Reference in New Issue
Block a user