From 596dca4deec5002746d31b428d09ce35e54ff4f9 Mon Sep 17 00:00:00 2001 From: Filip <34747899+neriousy@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:43:56 +0200 Subject: [PATCH] feat(cli): add session list and delete commands (#47812) --- packages/cli/src/commands/commands.ts | 28 +++++ .../src/commands/handlers/session/delete.ts | 34 ++++++ .../cli/src/commands/handlers/session/list.ts | 113 ++++++++++++++++++ packages/cli/src/index.ts | 4 + 4 files changed, 179 insertions(+) create mode 100644 packages/cli/src/commands/handlers/session/delete.ts create mode 100644 packages/cli/src/commands/handlers/session/list.ts diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index 875444201b8..732dee7ba10 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -361,6 +361,34 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME ...PermissionParams, }, }), + Spec.make("session", { + description: "Manage sessions", + commands: [ + Spec.make("list", { + description: "List top-level sessions in the current project, newest first", + params: { + ...ServerParams, + maxCount: Flag.integer("max-count").pipe( + Flag.withAlias("n"), + Flag.withSchema(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))), + Flag.withDescription("Limit to N most recent sessions (default: 100)"), + Flag.optional, + ), + format: Flag.choice("format", ["table", "json"]).pipe( + Flag.withDescription("Output format"), + Flag.withDefault("table"), + ), + }, + }), + Spec.make("delete", { + description: "Delete a session and its child sessions", + params: { + ...ServerParams, + sessionID: Argument.string("sessionID").pipe(Argument.withDescription("Session ID to delete")), + }, + }), + ], + }), Spec.make("service", { description: "Manage the background server", commands: [ diff --git a/packages/cli/src/commands/handlers/session/delete.ts b/packages/cli/src/commands/handlers/session/delete.ts new file mode 100644 index 00000000000..9b11f80c33b --- /dev/null +++ b/packages/cli/src/commands/handlers/session/delete.ts @@ -0,0 +1,34 @@ +import { OpenCode } from "@opencode-ai/client" +import { Service } from "@opencode-ai/client/effect/service" +import { Effect, Option } from "effect" +import { EOL } from "node:os" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { ServerConnection } from "../../../services/server-connection" +import { errorMessage } from "../../../util/error" + +const handler = Effect.fn("cli.session.delete")(function* ( + input: Runtime.Input, +) { + const server = yield* ServerConnection.resolve({ + server: Option.getOrUndefined(input.server), + standalone: input.standalone, + }) + const client = OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) }) + yield* Effect.tryPromise({ + try: (signal) => client.session.remove({ sessionID: input.sessionID }, { signal }), + catch: (cause) => cause, + }) + process.stdout.write(`Session ${input.sessionID} deleted${EOL}`) +}) + +export default Runtime.handler(Commands.commands.session.commands.delete, (input) => + handler(input).pipe( + Effect.catch((error) => + Effect.sync(() => { + process.stderr.write(errorMessage(error) + EOL) + process.exitCode = 1 + }), + ), + ), +) diff --git a/packages/cli/src/commands/handlers/session/list.ts b/packages/cli/src/commands/handlers/session/list.ts new file mode 100644 index 00000000000..e56a3195d20 --- /dev/null +++ b/packages/cli/src/commands/handlers/session/list.ts @@ -0,0 +1,113 @@ +import { OpenCode, type SessionInfo } from "@opencode-ai/client" +import { Service } from "@opencode-ai/client/effect/service" +import { Effect, Option, Stream } from "effect" +import { EOL } from "node:os" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { ServerConnection } from "../../../services/server-connection" +import { errorMessage } from "../../../util/error" + +const handler = Effect.fn("cli.session.list")(function* ( + input: Runtime.Input, +) { + const server = yield* ServerConnection.resolve({ + server: Option.getOrUndefined(input.server), + standalone: input.standalone, + }) + const client = OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) }) + const location = yield* Effect.tryPromise({ + try: (signal) => client.location.get({ location: { directory: process.cwd() } }, { signal }), + catch: (cause) => cause, + }) + const page = yield* Effect.tryPromise({ + try: (signal) => + client.session.list( + { + project: location.project.id, + parentID: null, + order: "desc", + limit: Option.getOrElse(input.maxCount, () => 100), + }, + { signal }, + ), + catch: (cause) => cause, + }) + if (input.format === "table" && page.data.length === 0) return + const output = + (input.format === "json" + ? JSON.stringify( + page.data.map((session) => ({ + id: session.id, + title: session.title, + updated: session.time.updated, + created: session.time.created, + projectId: session.projectID, + directory: session.location.directory, + })), + null, + 2, + ) + : formatTable(page.data)) + EOL + const write = Effect.tryPromise( + () => + new Promise((resolve, reject) => { + process.stdout.write(output, (error) => (error ? reject(error) : resolve())) + }), + ) + if (!process.stdout.isTTY || Option.isSome(input.maxCount) || input.format === "json") { + yield* write + return + } + + const { AppProcess } = yield* Effect.promise(() => import("@opencode-ai/util/process")) + const { LayerNode } = yield* Effect.promise(() => import("@opencode-ai/util/effect/layer-node")) + const { ChildProcess } = yield* Effect.promise(() => import("effect/unstable/process")) + yield* Effect.gen(function* () { + const processService = yield* AppProcess.Service + const pager = yield* processService + .spawn( + ChildProcess.make( + process.platform === "win32" ? "cmd" : "less", + process.platform === "win32" ? ["/c", "more"] : ["-R", "-S"], + { + stdin: Stream.make(new TextEncoder().encode(output)), + stdout: "inherit", + stderr: "inherit", + }, + ), + ) + .pipe(Effect.option) + if (Option.isNone(pager)) { + yield* write + return + } + yield* pager.value.exitCode + }).pipe(Effect.provide(LayerNode.compile(AppProcess.node))) +}) + +export default Runtime.handler(Commands.commands.session.commands.list, (input) => + handler(input).pipe( + Effect.catch((error) => + Effect.sync(() => { + process.stderr.write(errorMessage(error) + EOL) + process.exitCode = 1 + }), + ), + ), +) + +function formatTable(sessions: ReadonlyArray) { + const rows = sessions.map((session) => ({ + id: session.id, + title: (session.title ?? "Untitled session").replace(/[\r\n\t]/g, " "), + updated: new Date(session.time.updated).toLocaleString(), + })) + const idWidth = Math.max(20, ...rows.map((row) => row.id.length)) + const titleWidth = Math.max(25, ...rows.map((row) => row.title.length)) + const header = `${"Session ID".padEnd(idWidth)} ${"Title".padEnd(titleWidth)} Updated` + return [ + header, + "─".repeat(header.length), + ...rows.map((row) => `${row.id.padEnd(idWidth)} ${row.title.padEnd(titleWidth)} ${row.updated}`), + ].join(EOL) +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 72a3e8c93d6..7f445f2d799 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -53,6 +53,10 @@ const Handlers = Runtime.handlers(Commands, { mini: () => import("./commands/handlers/mini"), run: () => import("./commands/handlers/run"), pair: () => import("./commands/handlers/pair"), + session: { + list: () => import("./commands/handlers/session/list"), + delete: () => import("./commands/handlers/session/delete"), + }, service: { start: () => import("./commands/handlers/service/start"), restart: () => import("./commands/handlers/service/restart"),