Skip to content

Commit 65c0d83

Browse files
authored
feat(core): rename persistent terminals (#60329)
1 parent 8f0fc21 commit 65c0d83

17 files changed

Lines changed: 469 additions & 70 deletions

File tree

‎packages/client/src/effect/api/api.ts‎

Lines changed: 8 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/client/src/effect/generated/client.ts‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/client/src/promise/generated/client.ts‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/client/src/promise/generated/types.ts‎

Lines changed: 22 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/core/src/persistent-pty/daemon.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export const WireTerminal = Schema.Struct({
1919
id: Schema.Number,
2020
pid: Schema.NullOr(Schema.Number),
2121
title: Schema.String,
22+
user_title: Schema.optional(Schema.NullOr(Schema.String)),
2223
foreground_process: Schema.NullOr(Schema.String),
2324
group_id: Session.ID,
2425
command: Schema.Array(Schema.String),
@@ -37,6 +38,7 @@ const Registration = Schema.Struct({
3738
protocol: Schema.Number,
3839
socket: Schema.String,
3940
token: Schema.String,
41+
features: Schema.optional(Schema.Array(Schema.String)),
4042
})
4143
type Registration = typeof Registration.Type
4244

@@ -46,6 +48,7 @@ export const WireResponse = Schema.Union([
4648
instance_id: Schema.String,
4749
pid: Schema.Number,
4850
protocol: Schema.Number,
51+
features: Schema.optional(Schema.Array(Schema.String)),
4952
}),
5053
Schema.Struct({ type: Schema.Literal("created"), terminal: WireTerminal }),
5154
Schema.Struct({ type: Schema.Literal("terminals"), terminals: Schema.Array(WireTerminal) }),
@@ -141,6 +144,7 @@ export class DaemonError extends Data.TaggedError("PersistentPty.DaemonError")<{
141144
export interface DaemonTransport {
142145
readonly request: (value: object, start?: boolean) => Effect.Effect<WireResponse, DaemonError>
143146
readonly requestIfRunning: (value: object) => Effect.Effect<WireResponse | undefined, DaemonError>
147+
readonly supports: (feature: string) => Effect.Effect<boolean, DaemonError>
144148
readonly shutdown: Effect.Effect<WireResponse | undefined, DaemonError>
145149
readonly handoff: Effect.Effect<Handoff | null, DaemonError>
146150
readonly subscribe: (
@@ -223,7 +227,8 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport"
223227
response.protocol !== ProtocolVersion
224228
)
225229
return yield* Effect.fail(new DaemonError({ kind: "protocol", message: "opencode-pty registration mismatch" }))
226-
return decoded
230+
// Daemons older than the features list omit it; trust the live daemon's answer over the file.
231+
return { ...decoded, features: response.features ?? [] }
227232
})
228233

229234
const spawnDaemon = Effect.fn("PersistentPty.daemon.spawn")(function* () {
@@ -338,6 +343,11 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport"
338343
Effect.catch((error) => (error.kind === "connect" ? Effect.succeed(undefined) : Effect.fail(error))),
339344
)
340345

346+
const supports = Effect.fn("PersistentPty.daemon.supports")(function* (feature: string) {
347+
const current = yield* connect(false)
348+
return current.features?.includes(feature) ?? false
349+
})
350+
341351
const shutdown = Effect.gen(function* () {
342352
const response = yield* requestIfRunning({ op: "shutdown" })
343353
registration = undefined
@@ -428,7 +438,7 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport"
428438
)
429439
}
430440

431-
return { request, requestIfRunning, shutdown, handoff, subscribe } satisfies DaemonTransport
441+
return { request, requestIfRunning, supports, shutdown, handoff, subscribe } satisfies DaemonTransport
432442
})
433443

434444
// Older releases kept registrations in purgeable temp directories. Renaming keeps the lock file's

‎packages/core/src/persistent-pty/index.ts‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,15 @@ import { stat } from "node:fs/promises"
44
import path from "node:path"
55
import { Context, Effect, Layer, Schema, Stream } from "effect"
66
import { makeGlobalNode } from "@opencode/util/effect/app-node"
7-
import { Added, Handoff, PersistentPty, ReadLines, Removed, type ReadResult } from "@opencode/schema/persistent-pty"
7+
import {
8+
Added,
9+
Handoff,
10+
PersistentPty,
11+
ReadLines,
12+
Removed,
13+
Updated,
14+
type ReadResult,
15+
} from "@opencode/schema/persistent-pty"
816
import { Session } from "@opencode/schema/session"
917
import { SessionEvent } from "@opencode/schema/session-event"
1018
import { Bus } from "../bus.js"
@@ -64,6 +72,10 @@ export class InvalidCwdError extends Schema.TaggedError<InvalidCwdError>()("Pers
6472
message: Schema.String,
6573
}) {}
6674

75+
export class UnsupportedError extends Schema.TaggedError<UnsupportedError>()("PersistentPty.UnsupportedError", {
76+
feature: Schema.String,
77+
}) {}
78+
6779
export interface Interface {
6880
readonly list: (sessionID?: Session.ID) => Effect.Effect<Info[], UnavailableError>
6981
readonly get: (id: Pty.ID) => Effect.Effect<Info, NotFoundError | UnavailableError>
@@ -103,6 +115,10 @@ export interface Interface {
103115
rows: number,
104116
data: Uint8Array,
105117
) => Effect.Effect<void, NotFoundError | UnavailableError>
118+
readonly rename: (
119+
id: Pty.ID,
120+
title: string | null,
121+
) => Effect.Effect<Info, NotFoundError | UnsupportedError | UnavailableError>
106122
readonly snapshot: (id: Pty.ID) => Effect.Effect<Snapshot, NotFoundError | UnavailableError>
107123
readonly read: (sessionID: Session.ID, lines?: number) => Effect.Effect<ReadResult | null, UnavailableError>
108124
readonly remove: (id: Pty.ID) => Effect.Effect<void, NotFoundError | UnavailableError>
@@ -266,6 +282,18 @@ const makeLayer = (options: Options = {}) =>
266282
return undefined
267283
})
268284

285+
const rename = Effect.fn("PersistentPty.rename")(function* (id: Pty.ID, title: string | null) {
286+
const terminal = yield* get(id)
287+
// Daemons without the feature drop the connection on unknown requests.
288+
if (!(yield* daemon.supports("rename").pipe(Effect.mapError(unavailable))))
289+
return yield* new UnsupportedError({ feature: "rename" })
290+
const response = yield* request(daemon, { op: "rename", id: fromID(id), title })
291+
if (response.type !== "ok") return yield* unexpected(response)
292+
const renamed = yield* get(id)
293+
yield* bus.publish(Updated, { sessionID: terminal.sessionID, terminal: renamed })
294+
return renamed
295+
})
296+
269297
const snapshot = Effect.fn("PersistentPty.snapshot")(function* (id: Pty.ID) {
270298
yield* get(id)
271299
const response = yield* request(daemon, { op: "snapshot", id: fromID(id) })
@@ -404,6 +432,7 @@ const makeLayer = (options: Options = {}) =>
404432
resize,
405433
control,
406434
input,
435+
rename,
407436
snapshot,
408437
read,
409438
remove,
@@ -449,6 +478,7 @@ function toInfo(value: WireTerminal): Info {
449478
...(status === "exited" ? { exitCode: value.lifecycle.exit_code ?? undefined } : {}),
450479
}),
451480
sessionID: value.group_id,
481+
userTitle: value.user_title ?? null,
452482
foregroundProcess: value.foreground_process,
453483
size: { cols: value.cols, rows: value.rows },
454484
output: { head: value.output_head, tail: value.output_tail },

‎packages/core/test/fixture/persistent-pty-daemon.ts‎

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mkdtemp, rm, writeFile } from "node:fs/promises"
1+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
22
import net from "node:net"
33
import os from "node:os"
44
import path from "node:path"
@@ -89,13 +89,77 @@ function frame(value: object) {
8989
return output
9090
}
9191

92-
export function writeRegistration(directory: string, socket: string, instance = "test", token = "test") {
92+
export function writeRegistration(
93+
directory: string,
94+
socket: string,
95+
instance = "test",
96+
token = "test",
97+
features?: string[],
98+
) {
9399
return writeFile(
94100
path.join(directory, "service.json"),
95-
JSON.stringify({ instance_id: instance, pid: process.pid, protocol: 7, socket, token }),
101+
JSON.stringify({ instance_id: instance, pid: process.pid, protocol: 7, socket, token, features }),
96102
)
97103
}
98104

105+
// An in-process opencode-pty that a server adopts through a handoff, so no daemon is spawned. Without
106+
// features it answers like a daemon that predates the features list.
107+
export const fakeDaemon = Effect.fn(function* (features?: string[]) {
108+
const base = yield* temporaryDirectory()
109+
const root = path.join(base, "root")
110+
const directory = path.join(root, "inherited")
111+
const socket = path.join(base, "daemon.sock")
112+
const terminals: Array<ReturnType<typeof terminal> & { user_title?: string | null }> = []
113+
const failTerminate = new Set<string>()
114+
const ops: string[] = []
115+
let nextID = 1
116+
yield* listen(socket, (connection, request) => {
117+
ops.push(String(request.op))
118+
if (request.op === "ping") return features ? { ...pong, features } : pong
119+
if (request.op === "list") return { type: "terminals", terminals }
120+
if (request.op === "create") {
121+
const base = terminal(nextID++, String(request.group_id))
122+
// A daemon that advertises features always serializes user_title.
123+
const created = features ? { ...base, user_title: null } : base
124+
terminals.push(created)
125+
return { type: "created", terminal: created }
126+
}
127+
if (request.op === "control") return { type: "ok" }
128+
if (request.op === "terminate") {
129+
if (failTerminate.has(`pty_persistent_${request.id}`)) return { type: "error", message: "terminate failed" }
130+
const index = terminals.findIndex((item) => item.id === request.id)
131+
if (index === -1) return { type: "error", message: `terminal not found: ${String(request.id)}` }
132+
terminals.splice(index, 1)
133+
return { type: "ok" }
134+
}
135+
if (request.op === "rename" && features?.includes("rename")) {
136+
const found = terminals.find((item) => item.id === request.id)
137+
if (!found) return { type: "error", message: "terminal not found" }
138+
found.user_title = typeof request.title === "string" && request.title !== "" ? request.title : null
139+
found.title = found.user_title ?? "test"
140+
return { type: "ok" }
141+
}
142+
// Old daemons cannot parse unknown requests and drop the connection.
143+
if (request.op === "rename") {
144+
connection.destroy()
145+
return undefined
146+
}
147+
return { type: "error", message: `unexpected request: ${String(request.op)}` }
148+
})
149+
yield* Effect.promise(async () => {
150+
await mkdir(directory, { recursive: true })
151+
await writeRegistration(directory, socket, "test", "test", features)
152+
})
153+
return {
154+
failTerminate,
155+
ops,
156+
options: {
157+
root,
158+
handoff: { directory, instanceID: "test", ticket: "ticket", expiresAt: Date.now() + 60_000 },
159+
},
160+
}
161+
})
162+
99163
export function terminal(id: number, groupID = "ses_test") {
100164
return {
101165
id,

0 commit comments

Comments
 (0)