From bd8d858bf7fafc8bb9b5d1ac88001e83bede430e Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 3 Jul 2026 12:19:09 -0400 Subject: [PATCH] feat(core): implement V2 session.shell (#35183) --- .../client/src/effect/generated/client.ts | 137 ++++++++++-------- .../client/src/promise/generated/client.ts | 14 ++ .../client/src/promise/generated/types.ts | 8 + packages/core/src/session.ts | 70 ++++++++- packages/core/test/session-create.test.ts | 56 +++++-- .../plugin/src/v2/effect/generated/api.ts | 132 +++++++++-------- packages/protocol/src/groups/session.ts | 20 +++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 39 +++++ packages/sdk/js/src/v2/gen/types.gen.ts | 38 +++++ packages/server/src/handlers/session.ts | 18 +++ packages/tui/src/component/prompt/index.tsx | 7 +- packages/tui/src/routes/session/index.tsx | 27 +++- 12 files changed, 414 insertions(+), 152 deletions(-) diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index f25557c0de7..a9a91eb8c61 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -208,23 +208,35 @@ const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11I payload: { text: input["text"], description: input["description"], metadata: input["metadata"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_12Request = Parameters[0] -type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] } +type Endpoint4_12Request = Parameters[0] +type Endpoint4_12Input = { + readonly sessionID: Endpoint4_12Request["params"]["sessionID"] + readonly id?: Endpoint4_12Request["payload"]["id"] + readonly command: Endpoint4_12Request["payload"]["command"] +} const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) => - raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + raw["session.shell"]({ + params: { sessionID: input["sessionID"] }, + payload: { id: input["id"], command: input["command"] }, + }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_13Request = Parameters[0] +type Endpoint4_13Request = Parameters[0] type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] } const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) => + raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_14Request = Parameters[0] +type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] } +const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) => raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_14Request = Parameters[0] -type Endpoint4_14Input = { - readonly sessionID: Endpoint4_14Request["params"]["sessionID"] - readonly messageID: Endpoint4_14Request["payload"]["messageID"] - readonly files?: Endpoint4_14Request["payload"]["files"] +type Endpoint4_15Request = Parameters[0] +type Endpoint4_15Input = { + readonly sessionID: Endpoint4_15Request["params"]["sessionID"] + readonly messageID: Endpoint4_15Request["payload"]["messageID"] + readonly files?: Endpoint4_15Request["payload"]["files"] } -const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) => +const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) => raw["session.revert.stage"]({ params: { sessionID: input["sessionID"] }, payload: { messageID: input["messageID"], files: input["files"] }, @@ -233,61 +245,61 @@ const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14I Effect.map((value) => value.data), ) -type Endpoint4_15Request = Parameters[0] -type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] } -const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) => - raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) - -type Endpoint4_16Request = Parameters[0] +type Endpoint4_16Request = Parameters[0] type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] } const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) => - raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_17Request = Parameters[0] +type Endpoint4_17Request = Parameters[0] type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] } const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) => + raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_18Request = Parameters[0] +type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] } +const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) => raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint4_18Request = Parameters[0] -type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] } -const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) => +type Endpoint4_19Request = Parameters[0] +type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] } +const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) => raw["session.context.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint4_19Request = Parameters[0] -type Endpoint4_19Input = { - readonly sessionID: Endpoint4_19Request["params"]["sessionID"] - readonly key: Endpoint4_19Request["params"]["key"] - readonly value: Endpoint4_19Request["payload"]["value"] +type Endpoint4_20Request = Parameters[0] +type Endpoint4_20Input = { + readonly sessionID: Endpoint4_20Request["params"]["sessionID"] + readonly key: Endpoint4_20Request["params"]["key"] + readonly value: Endpoint4_20Request["payload"]["value"] } -const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) => +const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) => raw["session.context.entry.put"]({ params: { sessionID: input["sessionID"], key: input["key"] }, payload: { value: input["value"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_20Request = Parameters[0] -type Endpoint4_20Input = { - readonly sessionID: Endpoint4_20Request["params"]["sessionID"] - readonly key: Endpoint4_20Request["params"]["key"] +type Endpoint4_21Request = Parameters[0] +type Endpoint4_21Input = { + readonly sessionID: Endpoint4_21Request["params"]["sessionID"] + readonly key: Endpoint4_21Request["params"]["key"] } -const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) => +const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) => raw["session.context.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint4_21Request = Parameters[0] -type Endpoint4_21Input = { - readonly sessionID: Endpoint4_21Request["params"]["sessionID"] - readonly after?: Endpoint4_21Request["query"]["after"] - readonly follow?: Endpoint4_21Request["query"]["follow"] +type Endpoint4_22Request = Parameters[0] +type Endpoint4_22Input = { + readonly sessionID: Endpoint4_22Request["params"]["sessionID"] + readonly after?: Endpoint4_22Request["query"]["after"] + readonly follow?: Endpoint4_22Request["query"]["follow"] } -const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) => +const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) => Stream.unwrap( raw["session.log"]({ params: { sessionID: input["sessionID"] }, @@ -298,22 +310,22 @@ const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21I ), ) -type Endpoint4_22Request = Parameters[0] -type Endpoint4_22Input = { readonly sessionID: Endpoint4_22Request["params"]["sessionID"] } -const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) => - raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) - -type Endpoint4_23Request = Parameters[0] +type Endpoint4_23Request = Parameters[0] type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] } const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23Input) => + raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_24Request = Parameters[0] +type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] } +const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) => raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_24Request = Parameters[0] -type Endpoint4_24Input = { - readonly sessionID: Endpoint4_24Request["params"]["sessionID"] - readonly messageID: Endpoint4_24Request["params"]["messageID"] +type Endpoint4_25Request = Parameters[0] +type Endpoint4_25Input = { + readonly sessionID: Endpoint4_25Request["params"]["sessionID"] + readonly messageID: Endpoint4_25Request["params"]["messageID"] } -const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) => +const Endpoint4_25 = (raw: RawClient["server.session"]) => (input: Endpoint4_25Input) => raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), @@ -332,19 +344,20 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({ command: Endpoint4_9(raw), skill: Endpoint4_10(raw), synthetic: Endpoint4_11(raw), - compact: Endpoint4_12(raw), - wait: Endpoint4_13(raw), - revertStage: Endpoint4_14(raw), - revertClear: Endpoint4_15(raw), - revertCommit: Endpoint4_16(raw), - context: Endpoint4_17(raw), - listContextEntries: Endpoint4_18(raw), - putContextEntry: Endpoint4_19(raw), - removeContextEntry: Endpoint4_20(raw), - log: Endpoint4_21(raw), - interrupt: Endpoint4_22(raw), - background: Endpoint4_23(raw), - message: Endpoint4_24(raw), + shell: Endpoint4_12(raw), + compact: Endpoint4_13(raw), + wait: Endpoint4_14(raw), + revertStage: Endpoint4_15(raw), + revertClear: Endpoint4_16(raw), + revertCommit: Endpoint4_17(raw), + context: Endpoint4_18(raw), + listContextEntries: Endpoint4_19(raw), + putContextEntry: Endpoint4_20(raw), + removeContextEntry: Endpoint4_21(raw), + log: Endpoint4_22(raw), + interrupt: Endpoint4_23(raw), + background: Endpoint4_24(raw), + message: Endpoint4_25(raw), }) type Endpoint5_0Request = Parameters[0] diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index ee752f23ef7..79368764c74 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -29,6 +29,8 @@ import type { SessionSkillOutput, SessionSyntheticInput, SessionSyntheticOutput, + SessionShellInput, + SessionShellOutput, SessionCompactInput, SessionCompactOutput, SessionWaitInput, @@ -525,6 +527,18 @@ export function make(options: ClientOptions) { }, requestOptions, ), + shell: (input: SessionShellInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/shell`, + body: { id: input["id"], command: input["command"] }, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), compact: (input: SessionCompactInput, requestOptions?: RequestOptions) => request( { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index d858ded9333..6c8286fc52f 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -865,6 +865,14 @@ export type SessionSyntheticInput = { export type SessionSyntheticOutput = void +export type SessionShellInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly id?: { readonly id?: string | undefined; readonly command: string }["id"] + readonly command: { readonly id?: string | undefined; readonly command: string }["command"] +} + +export type SessionShellOutput = void + export type SessionCompactInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } export type SessionCompactOutput = void diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 4f1be4151c8..acae2691c8a 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -41,6 +41,9 @@ import type { EventLog } from "@opencode-ai/schema/event-log" import { SkillV2 } from "./skill" import { Job } from "./job" import { CommandV2 } from "./command" +import { Identifier } from "./util/identifier" +import { Shell } from "./shell" +import { KeyedMutex } from "./effect/keyed-mutex" export const RevertState = Revert.State export type RevertState = Revert.State @@ -106,7 +109,7 @@ export class NotFoundError extends Schema.TaggedErrorClass()("Ses export class OperationUnavailableError extends Schema.TaggedErrorClass()( "Session.OperationUnavailableError", { - operation: Schema.Literals(["move", "shell", "skill", "switchAgent", "compact"]), + operation: Schema.Literals(["move", "skill", "switchAgent", "compact"]), }, ) {} @@ -208,8 +211,7 @@ export interface Interface { id?: EventV2.ID sessionID: SessionSchema.ID command: string - resume?: boolean - }) => Effect.Effect + }) => Effect.Effect readonly skill: (input: { id?: SessionMessage.ID sessionID: SessionSchema.ID @@ -255,6 +257,8 @@ const layer = Layer.effect( const locations = yield* LocationServiceMap.Service const jobs = yield* Job.Service const scope = yield* Scope.Scope + const activeShells = new Set() + const shellLocks = KeyedMutex.makeUnsafe() const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) const isDurableSessionEvent = Schema.is(SessionEvent.Durable) const decode = (row: typeof SessionMessageTable.$inferSelect) => @@ -268,6 +272,19 @@ const layer = Layer.effect( ), ) + // Session shell is user-initiated and synchronous at the API boundary, while + // the Location shell service owns process lifecycle and file-backed output. + const runShellCommand = (command: string, cwd: string) => + Effect.gen(function* () { + const shell = yield* Shell.Service + const info = yield* shell.create({ command, cwd }) + yield* shell.wait(info.id) + const output = yield* shell.output(info.id, { limit: SHELL_MAX_CAPTURE_BYTES }) + return output.output || "(no output)" + }).pipe( + Effect.catchTag("Shell.NotFoundError", () => Effect.succeed("Shell command output is no longer available.")), + ) + const result = Service.of({ create: Effect.fn("V2Session.create")(function* (input) { const sessionID = input.id ?? SessionSchema.ID.create() @@ -489,7 +506,10 @@ const layer = Layer.effect( ) if (!SessionInput.equivalent(admitted, expected)) return yield* new PromptConflictError({ sessionID: input.sessionID, messageID }) - if (input.resume !== false) yield* execution.wake(admitted.sessionID) + if (input.resume !== false) { + if (activeShells.has(admitted.sessionID)) return admitted + yield* execution.wake(admitted.sessionID) + } return admitted }), ), @@ -525,8 +545,43 @@ const layer = Layer.effect( resume: input.resume, }) }), - shell: Effect.fn("V2Session.shell")(function* () { - return yield* new OperationUnavailableError({ operation: "shell" }) + shell: Effect.fn("V2Session.shell")(function* (input) { + const session = yield* result.get(input.sessionID) + yield* shellLocks.withLock(input.sessionID)( + Effect.gen(function* () { + activeShells.add(input.sessionID) + if ((yield* execution.active).has(input.sessionID)) yield* execution.awaitIdle(input.sessionID) + const messageID = SessionMessage.ID.create() + const callID = Identifier.ascending() + yield* events.publish( + SessionEvent.Shell.Started, + { + sessionID: input.sessionID, + messageID, + callID, + command: input.command, + timestamp: yield* DateTime.now, + }, + { id: input.id }, + ) + const output = yield* runShellCommand(input.command, session.location.directory).pipe( + Effect.provide(locations.get(session.location)), + ) + yield* events.publish(SessionEvent.Shell.Ended, { + sessionID: input.sessionID, + callID, + output, + timestamp: yield* DateTime.now, + }) + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + activeShells.delete(input.sessionID) + yield* execution.wake(input.sessionID) + }), + ), + ), + ) }), skill: Effect.fn("V2Session.skill")(function* (input) { const session = yield* result.get(input.sessionID) @@ -679,6 +734,9 @@ const resolvePrompt = (input: PromptInput.Prompt) => }), }) +// Mirrors the shell tool's in-memory preview safety limit. +const SHELL_MAX_CAPTURE_BYTES = 1024 * 1024 + export const node = makeGlobalNode({ service: Service, layer: layer.pipe(Layer.orDie), diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 9c41d1d9ac6..b22c65ec97d 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -17,11 +17,11 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Prompt } from "@opencode-ai/core/session/prompt" +import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionInput } from "@opencode-ai/core/session/input" import { SessionEvent } from "@opencode-ai/core/session/event" -import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" import { WorkspaceV2 } from "@opencode-ai/core/workspace" @@ -62,6 +62,13 @@ const assertCreateInputTypes = (session: SessionV2.Interface) => { } void assertCreateInputTypes +function withTmp(f: (directory: string) => Effect.Effect) { + return Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe(Effect.flatMap((tmp) => f(tmp.path))) +} + describe("SessionV2.create", () => { it.effect("creates a fresh projected session when the ID is omitted", () => Effect.gen(function* () { @@ -476,20 +483,41 @@ describe("SessionV2.create", () => { }), ) - it.effect("reports unfinished Session operations as unavailable", () => - Effect.gen(function* () { - const session = yield* SessionV2.Service - const created = yield* session.create({ location }) - const unavailable = ( - effect: Effect.Effect, - ) => - effect.pipe( - Effect.flip, - Effect.map((error) => (error instanceof SessionV2.OperationUnavailableError ? error.operation : "not-found")), - ) + it.live("runs a shell command and projects the started/ended shell message", () => + withTmp((directory) => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const created = yield* session.create({ + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) - expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell") - }), + yield* session.shell({ sessionID: created.id, command: "echo hello" }) + + const messages = yield* session.messages({ sessionID: created.id, order: "asc" }) + const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell") + expect(shell).toMatchObject({ type: "shell", command: "echo hello" }) + expect(shell?.output).toContain("hello") + expect(shell?.time.completed).toBeDefined() + }), + ), + ) + + it.live("still emits shell ended for a failing command", () => + withTmp((directory) => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const created = yield* session.create({ + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) + + yield* session.shell({ sessionID: created.id, command: "false" }) + + const messages = yield* session.messages({ sessionID: created.id, order: "asc" }) + const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell") + expect(shell).toMatchObject({ type: "shell", command: "false" }) + expect(shell?.time.completed).toBeDefined() + }), + ), ) it.effect("switches the selected agent through the durable Session event", () => diff --git a/packages/plugin/src/v2/effect/generated/api.ts b/packages/plugin/src/v2/effect/generated/api.ts index 049ca7486c1..8e764daa826 100644 --- a/packages/plugin/src/v2/effect/generated/api.ts +++ b/packages/plugin/src/v2/effect/generated/api.ts @@ -153,96 +153,105 @@ export type Endpoint4_11Input = { export type Endpoint4_11Output = EffectValue> export type SessionSyntheticOperation = (input: Endpoint4_11Input) => Effect.Effect -type Endpoint4_12Request = Parameters[0] -export type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] } -export type Endpoint4_12Output = EffectValue> -export type SessionCompactOperation = (input: Endpoint4_12Input) => Effect.Effect - -type Endpoint4_13Request = Parameters[0] -export type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] } -export type Endpoint4_13Output = EffectValue> -export type SessionWaitOperation = (input: Endpoint4_13Input) => Effect.Effect - -type Endpoint4_14Request = Parameters[0] -export type Endpoint4_14Input = { - readonly sessionID: Endpoint4_14Request["params"]["sessionID"] - readonly messageID: Endpoint4_14Request["payload"]["messageID"] - readonly files?: Endpoint4_14Request["payload"]["files"] +type Endpoint4_12Request = Parameters[0] +export type Endpoint4_12Input = { + readonly sessionID: Endpoint4_12Request["params"]["sessionID"] + readonly id?: Endpoint4_12Request["payload"]["id"] + readonly command: Endpoint4_12Request["payload"]["command"] } -export type Endpoint4_14Output = EffectValue>["data"] -export type SessionRevertStageOperation = (input: Endpoint4_14Input) => Effect.Effect +export type Endpoint4_12Output = EffectValue> +export type SessionShellOperation = (input: Endpoint4_12Input) => Effect.Effect -type Endpoint4_15Request = Parameters[0] -export type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] } -export type Endpoint4_15Output = EffectValue> -export type SessionRevertClearOperation = (input: Endpoint4_15Input) => Effect.Effect +type Endpoint4_13Request = Parameters[0] +export type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] } +export type Endpoint4_13Output = EffectValue> +export type SessionCompactOperation = (input: Endpoint4_13Input) => Effect.Effect -type Endpoint4_16Request = Parameters[0] +type Endpoint4_14Request = Parameters[0] +export type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] } +export type Endpoint4_14Output = EffectValue> +export type SessionWaitOperation = (input: Endpoint4_14Input) => Effect.Effect + +type Endpoint4_15Request = Parameters[0] +export type Endpoint4_15Input = { + readonly sessionID: Endpoint4_15Request["params"]["sessionID"] + readonly messageID: Endpoint4_15Request["payload"]["messageID"] + readonly files?: Endpoint4_15Request["payload"]["files"] +} +export type Endpoint4_15Output = EffectValue>["data"] +export type SessionRevertStageOperation = (input: Endpoint4_15Input) => Effect.Effect + +type Endpoint4_16Request = Parameters[0] export type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] } -export type Endpoint4_16Output = EffectValue> -export type SessionRevertCommitOperation = (input: Endpoint4_16Input) => Effect.Effect +export type Endpoint4_16Output = EffectValue> +export type SessionRevertClearOperation = (input: Endpoint4_16Input) => Effect.Effect -type Endpoint4_17Request = Parameters[0] +type Endpoint4_17Request = Parameters[0] export type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] } -export type Endpoint4_17Output = EffectValue>["data"] -export type SessionContextOperation = (input: Endpoint4_17Input) => Effect.Effect +export type Endpoint4_17Output = EffectValue> +export type SessionRevertCommitOperation = (input: Endpoint4_17Input) => Effect.Effect -type Endpoint4_18Request = Parameters[0] +type Endpoint4_18Request = Parameters[0] export type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] } -export type Endpoint4_18Output = EffectValue< +export type Endpoint4_18Output = EffectValue>["data"] +export type SessionContextOperation = (input: Endpoint4_18Input) => Effect.Effect + +type Endpoint4_19Request = Parameters[0] +export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] } +export type Endpoint4_19Output = EffectValue< ReturnType >["data"] export type SessionListContextEntriesOperation = ( - input: Endpoint4_18Input, -) => Effect.Effect - -type Endpoint4_19Request = Parameters[0] -export type Endpoint4_19Input = { - readonly sessionID: Endpoint4_19Request["params"]["sessionID"] - readonly key: Endpoint4_19Request["params"]["key"] - readonly value: Endpoint4_19Request["payload"]["value"] -} -export type Endpoint4_19Output = EffectValue> -export type SessionPutContextEntryOperation = ( input: Endpoint4_19Input, ) => Effect.Effect -type Endpoint4_20Request = Parameters[0] +type Endpoint4_20Request = Parameters[0] export type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] readonly key: Endpoint4_20Request["params"]["key"] + readonly value: Endpoint4_20Request["payload"]["value"] } -export type Endpoint4_20Output = EffectValue> -export type SessionRemoveContextEntryOperation = ( +export type Endpoint4_20Output = EffectValue> +export type SessionPutContextEntryOperation = ( input: Endpoint4_20Input, ) => Effect.Effect -type Endpoint4_21Request = Parameters[0] +type Endpoint4_21Request = Parameters[0] export type Endpoint4_21Input = { readonly sessionID: Endpoint4_21Request["params"]["sessionID"] - readonly after?: Endpoint4_21Request["query"]["after"] - readonly follow?: Endpoint4_21Request["query"]["follow"] + readonly key: Endpoint4_21Request["params"]["key"] } -export type Endpoint4_21Output = StreamValue>> -export type SessionLogOperation = (input: Endpoint4_21Input) => Stream.Stream +export type Endpoint4_21Output = EffectValue> +export type SessionRemoveContextEntryOperation = ( + input: Endpoint4_21Input, +) => Effect.Effect -type Endpoint4_22Request = Parameters[0] -export type Endpoint4_22Input = { readonly sessionID: Endpoint4_22Request["params"]["sessionID"] } -export type Endpoint4_22Output = EffectValue> -export type SessionInterruptOperation = (input: Endpoint4_22Input) => Effect.Effect +type Endpoint4_22Request = Parameters[0] +export type Endpoint4_22Input = { + readonly sessionID: Endpoint4_22Request["params"]["sessionID"] + readonly after?: Endpoint4_22Request["query"]["after"] + readonly follow?: Endpoint4_22Request["query"]["follow"] +} +export type Endpoint4_22Output = StreamValue>> +export type SessionLogOperation = (input: Endpoint4_22Input) => Stream.Stream -type Endpoint4_23Request = Parameters[0] +type Endpoint4_23Request = Parameters[0] export type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] } -export type Endpoint4_23Output = EffectValue> -export type SessionBackgroundOperation = (input: Endpoint4_23Input) => Effect.Effect +export type Endpoint4_23Output = EffectValue> +export type SessionInterruptOperation = (input: Endpoint4_23Input) => Effect.Effect -type Endpoint4_24Request = Parameters[0] -export type Endpoint4_24Input = { - readonly sessionID: Endpoint4_24Request["params"]["sessionID"] - readonly messageID: Endpoint4_24Request["params"]["messageID"] +type Endpoint4_24Request = Parameters[0] +export type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] } +export type Endpoint4_24Output = EffectValue> +export type SessionBackgroundOperation = (input: Endpoint4_24Input) => Effect.Effect + +type Endpoint4_25Request = Parameters[0] +export type Endpoint4_25Input = { + readonly sessionID: Endpoint4_25Request["params"]["sessionID"] + readonly messageID: Endpoint4_25Request["params"]["messageID"] } -export type Endpoint4_24Output = EffectValue>["data"] -export type SessionMessageOperation = (input: Endpoint4_24Input) => Effect.Effect +export type Endpoint4_25Output = EffectValue>["data"] +export type SessionMessageOperation = (input: Endpoint4_25Input) => Effect.Effect export interface SessionApi { readonly list: SessionListOperation @@ -257,6 +266,7 @@ export interface SessionApi { readonly command: SessionCommandOperation readonly skill: SessionSkillOperation readonly synthetic: SessionSyntheticOperation + readonly shell: SessionShellOperation readonly compact: SessionCompactOperation readonly wait: SessionWaitOperation readonly revertStage: SessionRevertStageOperation diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index d2114ddee99..527dcc6f618 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -349,6 +349,26 @@ export const makeSessionGroup = (sessionLo }), ), ) + .add( + HttpApiEndpoint.post("session.shell", "/api/session/:sessionID/shell", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ + id: Event.ID.pipe(Schema.optional), + command: Schema.String, + }), + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.shell", + summary: "Run shell command", + description: + "Execute one shell command in the session's working directory. Emits a shell.started event before execution and a shell.ended event with the merged output after.", + }), + ), + ) .add( HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", { params: { sessionID: Session.ID }, diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 1c896d80a38..ae8318d78e7 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -422,6 +422,8 @@ import type { V2SessionRevertCommitResponses, V2SessionRevertStageErrors, V2SessionRevertStageResponses, + V2SessionShellErrors, + V2SessionShellResponses, V2SessionSkillErrors, V2SessionSkillResponses, V2SessionSwitchAgentErrors, @@ -6240,6 +6242,43 @@ export class Session3 extends HeyApiClient { }) } + /** + * Run shell command + * + * Execute one shell command in the session's working directory. Emits a shell.started event before execution and a shell.ended event with the merged output after. + */ + public shell( + parameters: { + sessionID: string + id?: string | null + command?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "id" }, + { in: "body", key: "command" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/shell", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Compact session * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 13fbacae2b1..2f476f28447 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -16686,6 +16686,44 @@ export type V2SessionSyntheticResponses = { export type V2SessionSyntheticResponse = V2SessionSyntheticResponses[keyof V2SessionSyntheticResponses] +export type V2SessionShellData = { + body: { + id?: string | null + command: string + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/shell" +} + +export type V2SessionShellErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedErrorV2 + /** + * SessionNotFoundError + */ + 404: SessionNotFoundErrorV2 +} + +export type V2SessionShellError = V2SessionShellErrors[keyof V2SessionShellErrors] + +export type V2SessionShellResponses = { + /** + * + */ + 204: void +} + +export type V2SessionShellResponse = V2SessionShellResponses[keyof V2SessionShellResponses] + export type V2SessionCompactData = { body?: never path: { diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index f6dbabdbf8d..8a3cf1b1305 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -323,6 +323,24 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl return HttpApiSchema.NoContent.make() }), ) + .handle( + "session.shell", + Effect.fn(function* (ctx) { + yield* session + .shell({ sessionID: ctx.params.sessionID, id: ctx.payload.id, command: ctx.payload.command }) + .pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) .handle( "session.compact", Effect.fn(function* (ctx) { diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 7b0724e5dc0..9fad2eb21c8 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -1101,13 +1101,8 @@ export function Prompt(props: PromptProps) { if (store.mode === "shell") { move.startSubmit() - void sdk.client.session.shell({ + void sdk.client.v2.session.shell({ sessionID, - agent: agent.id, - model: { - providerID: selectedModel.providerID, - modelID: selectedModel.modelID, - }, command: inputText, }) setStore("mode", "normal") diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index fc7c2d3a0e0..d406fe664e8 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1068,9 +1068,7 @@ function SessionMessageView(props: { message: SessionMessage }) { - - {props.message.type === "shell" ? `$ ${props.message.command}\n${props.message.output}` : ""} - + } /> @@ -1346,6 +1344,29 @@ function RevertMessage(props: { ) } +function ShellMessage(props: { message: Extract }) { + const { theme } = useTheme() + const output = createMemo(() => stripAnsi(props.message.output.trim())) + + return ( + + $ {props.message.command} + + {output()} + + + ) +} + function UserMessage(props: { message: SessionMessageUser }) { const ctx = use() const data = useData()