Переглянути джерело

feat(core): implement V2 session.shell (#35183)

James Long 1 місяць тому
батько
коміт
bd8d858bf7

+ 75 - 62
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<RawClient["server.session"]["session.compact"]>[0]
-type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] }
+type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.shell"]>[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<RawClient["server.session"]["session.wait"]>[0]
+type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.compact"]>[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<RawClient["server.session"]["session.wait"]>[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<RawClient["server.session"]["session.revert.stage"]>[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<RawClient["server.session"]["session.revert.stage"]>[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<RawClient["server.session"]["session.revert.clear"]>[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<RawClient["server.session"]["session.revert.commit"]>[0]
+type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[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<RawClient["server.session"]["session.context"]>[0]
+type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[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<RawClient["server.session"]["session.context"]>[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<RawClient["server.session"]["session.context.entry.list"]>[0]
-type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] }
-const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) =>
+type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.context.entry.list"]>[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<RawClient["server.session"]["session.context.entry.put"]>[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<RawClient["server.session"]["session.context.entry.put"]>[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<RawClient["server.session"]["session.context.entry.remove"]>[0]
-type Endpoint4_20Input = {
-  readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
-  readonly key: Endpoint4_20Request["params"]["key"]
+type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.context.entry.remove"]>[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<RawClient["server.session"]["session.log"]>[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<RawClient["server.session"]["session.log"]>[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<RawClient["server.session"]["session.interrupt"]>[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<RawClient["server.session"]["session.background"]>[0]
+type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.interrupt"]>[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<RawClient["server.session"]["session.background"]>[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<RawClient["server.session"]["session.message"]>[0]
-type Endpoint4_24Input = {
-  readonly sessionID: Endpoint4_24Request["params"]["sessionID"]
-  readonly messageID: Endpoint4_24Request["params"]["messageID"]
+type Endpoint4_25Request = Parameters<RawClient["server.session"]["session.message"]>[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<RawClient["server.message"]["session.messages"]>[0]

+ 14 - 0
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<SessionShellOutput>(
+          {
+            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<SessionCompactOutput>(
           {

+ 8 - 0
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

+ 64 - 6
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<NotFoundError>()("Ses
 export class OperationUnavailableError extends Schema.TaggedErrorClass<OperationUnavailableError>()(
   "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<void, OperationUnavailableError>
+  }) => Effect.Effect<void, NotFoundError>
   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<SessionSchema.ID>()
+    const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
     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),

+ 42 - 14
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<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
+  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<void, SessionV2.NotFoundError | SessionV2.OperationUnavailableError>,
-      ) =>
-        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", () =>

+ 69 - 59
packages/plugin/src/v2/effect/generated/api.ts

@@ -153,96 +153,105 @@ export type Endpoint4_11Input = {
 export type Endpoint4_11Output = EffectValue<ReturnType<RawClient["server.session"]["session.synthetic"]>>
 export type SessionSyntheticOperation<E = never> = (input: Endpoint4_11Input) => Effect.Effect<Endpoint4_11Output, E>
 
-type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
-export type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] }
-export type Endpoint4_12Output = EffectValue<ReturnType<RawClient["server.session"]["session.compact"]>>
-export type SessionCompactOperation<E = never> = (input: Endpoint4_12Input) => Effect.Effect<Endpoint4_12Output, E>
+type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.shell"]>[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_12Output = EffectValue<ReturnType<RawClient["server.session"]["session.shell"]>>
+export type SessionShellOperation<E = never> = (input: Endpoint4_12Input) => Effect.Effect<Endpoint4_12Output, E>
 
-type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
+type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
 export type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] }
-export type Endpoint4_13Output = EffectValue<ReturnType<RawClient["server.session"]["session.wait"]>>
-export type SessionWaitOperation<E = never> = (input: Endpoint4_13Input) => Effect.Effect<Endpoint4_13Output, E>
+export type Endpoint4_13Output = EffectValue<ReturnType<RawClient["server.session"]["session.compact"]>>
+export type SessionCompactOperation<E = never> = (input: Endpoint4_13Input) => Effect.Effect<Endpoint4_13Output, E>
 
-type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
-export type Endpoint4_14Input = {
-  readonly sessionID: Endpoint4_14Request["params"]["sessionID"]
-  readonly messageID: Endpoint4_14Request["payload"]["messageID"]
-  readonly files?: Endpoint4_14Request["payload"]["files"]
-}
-export type Endpoint4_14Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.stage"]>>["data"]
-export type SessionRevertStageOperation<E = never> = (input: Endpoint4_14Input) => Effect.Effect<Endpoint4_14Output, E>
+type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
+export type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] }
+export type Endpoint4_14Output = EffectValue<ReturnType<RawClient["server.session"]["session.wait"]>>
+export type SessionWaitOperation<E = never> = (input: Endpoint4_14Input) => Effect.Effect<Endpoint4_14Output, E>
 
-type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
-export type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] }
-export type Endpoint4_15Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.clear"]>>
-export type SessionRevertClearOperation<E = never> = (input: Endpoint4_15Input) => Effect.Effect<Endpoint4_15Output, E>
+type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[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<ReturnType<RawClient["server.session"]["session.revert.stage"]>>["data"]
+export type SessionRevertStageOperation<E = never> = (input: Endpoint4_15Input) => Effect.Effect<Endpoint4_15Output, E>
 
-type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
+type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
 export type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] }
-export type Endpoint4_16Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.commit"]>>
-export type SessionRevertCommitOperation<E = never> = (input: Endpoint4_16Input) => Effect.Effect<Endpoint4_16Output, E>
+export type Endpoint4_16Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.clear"]>>
+export type SessionRevertClearOperation<E = never> = (input: Endpoint4_16Input) => Effect.Effect<Endpoint4_16Output, E>
 
-type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.context"]>[0]
+type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
 export type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] }
-export type Endpoint4_17Output = EffectValue<ReturnType<RawClient["server.session"]["session.context"]>>["data"]
-export type SessionContextOperation<E = never> = (input: Endpoint4_17Input) => Effect.Effect<Endpoint4_17Output, E>
+export type Endpoint4_17Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.commit"]>>
+export type SessionRevertCommitOperation<E = never> = (input: Endpoint4_17Input) => Effect.Effect<Endpoint4_17Output, E>
 
-type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.context.entry.list"]>[0]
+type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.context"]>[0]
 export type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] }
-export type Endpoint4_18Output = EffectValue<
+export type Endpoint4_18Output = EffectValue<ReturnType<RawClient["server.session"]["session.context"]>>["data"]
+export type SessionContextOperation<E = never> = (input: Endpoint4_18Input) => Effect.Effect<Endpoint4_18Output, E>
+
+type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.context.entry.list"]>[0]
+export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
+export type Endpoint4_19Output = EffectValue<
   ReturnType<RawClient["server.session"]["session.context.entry.list"]>
 >["data"]
 export type SessionListContextEntriesOperation<E = never> = (
-  input: Endpoint4_18Input,
-) => Effect.Effect<Endpoint4_18Output, E>
-
-type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.context.entry.put"]>[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<ReturnType<RawClient["server.session"]["session.context.entry.put"]>>
-export type SessionPutContextEntryOperation<E = never> = (
   input: Endpoint4_19Input,
 ) => Effect.Effect<Endpoint4_19Output, E>
 
-type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.context.entry.remove"]>[0]
+type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.context.entry.put"]>[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<ReturnType<RawClient["server.session"]["session.context.entry.remove"]>>
-export type SessionRemoveContextEntryOperation<E = never> = (
+export type Endpoint4_20Output = EffectValue<ReturnType<RawClient["server.session"]["session.context.entry.put"]>>
+export type SessionPutContextEntryOperation<E = never> = (
   input: Endpoint4_20Input,
 ) => Effect.Effect<Endpoint4_20Output, E>
 
-type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.log"]>[0]
+type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.context.entry.remove"]>[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<EffectValue<ReturnType<RawClient["server.session"]["session.log"]>>>
-export type SessionLogOperation<E = never> = (input: Endpoint4_21Input) => Stream.Stream<Endpoint4_21Output, E>
+export type Endpoint4_21Output = EffectValue<ReturnType<RawClient["server.session"]["session.context.entry.remove"]>>
+export type SessionRemoveContextEntryOperation<E = never> = (
+  input: Endpoint4_21Input,
+) => Effect.Effect<Endpoint4_21Output, E>
 
-type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
-export type Endpoint4_22Input = { readonly sessionID: Endpoint4_22Request["params"]["sessionID"] }
-export type Endpoint4_22Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
-export type SessionInterruptOperation<E = never> = (input: Endpoint4_22Input) => Effect.Effect<Endpoint4_22Output, E>
+type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.log"]>[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<EffectValue<ReturnType<RawClient["server.session"]["session.log"]>>>
+export type SessionLogOperation<E = never> = (input: Endpoint4_22Input) => Stream.Stream<Endpoint4_22Output, E>
 
-type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.background"]>[0]
+type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
 export type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] }
-export type Endpoint4_23Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
-export type SessionBackgroundOperation<E = never> = (input: Endpoint4_23Input) => Effect.Effect<Endpoint4_23Output, E>
+export type Endpoint4_23Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
+export type SessionInterruptOperation<E = never> = (input: Endpoint4_23Input) => Effect.Effect<Endpoint4_23Output, E>
+
+type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.background"]>[0]
+export type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] }
+export type Endpoint4_24Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
+export type SessionBackgroundOperation<E = never> = (input: Endpoint4_24Input) => Effect.Effect<Endpoint4_24Output, E>
 
-type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.message"]>[0]
-export type Endpoint4_24Input = {
-  readonly sessionID: Endpoint4_24Request["params"]["sessionID"]
-  readonly messageID: Endpoint4_24Request["params"]["messageID"]
+type Endpoint4_25Request = Parameters<RawClient["server.session"]["session.message"]>[0]
+export type Endpoint4_25Input = {
+  readonly sessionID: Endpoint4_25Request["params"]["sessionID"]
+  readonly messageID: Endpoint4_25Request["params"]["messageID"]
 }
-export type Endpoint4_24Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
-export type SessionMessageOperation<E = never> = (input: Endpoint4_24Input) => Effect.Effect<Endpoint4_24Output, E>
+export type Endpoint4_25Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
+export type SessionMessageOperation<E = never> = (input: Endpoint4_25Input) => Effect.Effect<Endpoint4_25Output, E>
 
 export interface SessionApi<E = never> {
   readonly list: SessionListOperation<E>
@@ -257,6 +266,7 @@ export interface SessionApi<E = never> {
   readonly command: SessionCommandOperation<E>
   readonly skill: SessionSkillOperation<E>
   readonly synthetic: SessionSyntheticOperation<E>
+  readonly shell: SessionShellOperation<E>
   readonly compact: SessionCompactOperation<E>
   readonly wait: SessionWaitOperation<E>
   readonly revertStage: SessionRevertStageOperation<E>

+ 20 - 0
packages/protocol/src/groups/session.ts

@@ -349,6 +349,26 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(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 },

+ 39 - 0
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<ThrowOnError extends boolean = false>(
+    parameters: {
+      sessionID: string
+      id?: string | null
+      command?: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "path", key: "sessionID" },
+            { in: "body", key: "id" },
+            { in: "body", key: "command" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).post<V2SessionShellResponses, V2SessionShellErrors, ThrowOnError>({
+      url: "/api/session/{sessionID}/shell",
+      ...options,
+      ...params,
+      headers: {
+        "Content-Type": "application/json",
+        ...options?.headers,
+        ...params.headers,
+      },
+    })
+  }
+
   /**
    * Compact session
    *

+ 38 - 0
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 = {
+  /**
+   * <No Content>
+   */
+  204: void
+}
+
+export type V2SessionShellResponse = V2SessionShellResponses[keyof V2SessionShellResponses]
+
 export type V2SessionCompactData = {
   body?: never
   path: {

+ 18 - 0
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) {

+ 1 - 6
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")

+ 24 - 3
packages/tui/src/routes/session/index.tsx

@@ -1068,9 +1068,7 @@ function SessionMessageView(props: { message: SessionMessage }) {
         <UserMessage message={props.message as SessionMessageUser} />
       </Match>
       <Match when={props.message.type === "shell"}>
-        <box paddingLeft={3}>
-          <text>{props.message.type === "shell" ? `$ ${props.message.command}\n${props.message.output}` : ""}</text>
-        </box>
+        <ShellMessage message={props.message as Extract<SessionMessage, { type: "shell" }>} />
       </Match>
       <Match when={props.message.type === "agent-switched" || props.message.type === "model-switched"}>
         <SessionSwitchMessageV2 message={props.message} />
@@ -1346,6 +1344,29 @@ function RevertMessage(props: {
   )
 }
 
+function ShellMessage(props: { message: Extract<SessionMessage, { type: "shell" }> }) {
+  const { theme } = useTheme()
+  const output = createMemo(() => stripAnsi(props.message.output.trim()))
+
+  return (
+    <box
+      border={["left"]}
+      paddingTop={1}
+      paddingBottom={1}
+      paddingLeft={2}
+      gap={1}
+      backgroundColor={theme.backgroundPanel}
+      customBorderChars={SplitBorder.customBorderChars}
+      borderColor={theme.background}
+    >
+      <text fg={theme.text}>$ {props.message.command}</text>
+      <Show when={output()}>
+        <text fg={theme.textMuted}>{output()}</text>
+      </Show>
+    </box>
+  )
+}
+
 function UserMessage(props: { message: SessionMessageUser }) {
   const ctx = use()
   const data = useData()