11export * as SessionV2 from "./session"
22export * from "./session/schema"
33
4- import { DateTime , Effect , Layer , Schema , Context , Stream , Scope } from "effect"
4+ import { DateTime , Duration , Effect , Layer , Schema , Context , Stream , Scope } from "effect"
5+ import { ChildProcess } from "effect/unstable/process"
56import { ListAnchor } from "@opencode-ai/schema/session"
67import { and , asc , desc , eq , gt , like , lt , or , type SQL } from "drizzle-orm"
78import { ProjectV2 } from "./project"
@@ -38,6 +39,11 @@ import { Revert } from "@opencode-ai/schema/revert"
3839import { FSUtil } from "./fs-util"
3940import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest"
4041import { SkillV2 } from "./skill"
42+ import { Identifier } from "./util/identifier"
43+ import { Shell } from "./shell"
44+ import { KeyedMutex } from "./effect/keyed-mutex"
45+ import { AppProcess } from "./process"
46+ import { Config } from "./config"
4147
4248export const RevertState = Revert . State
4349export type RevertState = Revert . State
@@ -96,7 +102,7 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Ses
96102export class OperationUnavailableError extends Schema . TaggedErrorClass < OperationUnavailableError > ( ) (
97103 "Session.OperationUnavailableError" ,
98104 {
99- operation : Schema . Literals ( [ "move" , "shell" , " skill", "switchAgent" , "compact" ] ) ,
105+ operation : Schema . Literals ( [ "move" , "skill" , "switchAgent" , "compact" ] ) ,
100106 } ,
101107) { }
102108
@@ -159,8 +165,7 @@ export interface Interface {
159165 id ?: EventV2 . ID
160166 sessionID : SessionSchema . ID
161167 command : string
162- resume ?: boolean
163- } ) => Effect . Effect < void , OperationUnavailableError >
168+ } ) => Effect . Effect < void , NotFoundError >
164169 readonly skill : ( input : {
165170 id ?: SessionMessage . ID
166171 sessionID : SessionSchema . ID
@@ -195,7 +200,10 @@ const layer = Layer.effect(
195200 const execution = yield * SessionExecution . Service
196201 const store = yield * SessionStore . Service
197202 const locations = yield * LocationServiceMap . Service
203+ const appProcess = yield * AppProcess . Service
198204 const scope = yield * Scope . Scope
205+ const activeShells = new Set < SessionSchema . ID > ( )
206+ const shellLocks = KeyedMutex . makeUnsafe < SessionSchema . ID > ( )
199207 const decodeMessage = Schema . decodeUnknownEffect ( SessionMessage . Message )
200208 const isDurableSessionEvent = Schema . is ( SessionEvent . Durable )
201209 const decode = ( row : typeof SessionMessageTable . $inferSelect ) =>
@@ -209,6 +217,26 @@ const layer = Layer.effect(
209217 ) ,
210218 )
211219
220+ // Session shell is user-initiated and synchronous at the API boundary. The
221+ // upstream location Shell service is not at this HEAD yet, so run through
222+ // AppProcess with the v1 user-facing shell selection semantics.
223+ const runShellCommand = ( command : string , cwd : string ) =>
224+ Effect . gen ( function * ( ) {
225+ const config = yield * Config . Service
226+ const sh = Shell . preferred ( Config . latest ( yield * config . entries ( ) , "shell" ) )
227+ const result = yield * appProcess . run (
228+ ChildProcess . make ( sh , Shell . args ( sh , command , cwd ) , {
229+ cwd,
230+ extendEnv : true ,
231+ env : { TERM : "dumb" } ,
232+ stdin : "ignore" ,
233+ forceKillAfter : Duration . seconds ( 3 ) ,
234+ } ) ,
235+ { combineOutput : true , maxOutputBytes : SHELL_MAX_CAPTURE_BYTES } ,
236+ )
237+ return result . output ?. toString ( "utf8" ) || "(no output)"
238+ } ) . pipe ( Effect . catchTag ( "AppProcessError" , ( error ) => Effect . succeed ( error . message ) ) )
239+
212240 const result = Service . of ( {
213241 create : Effect . fn ( "V2Session.create" ) ( function * ( input ) {
214242 const sessionID = input . id ?? SessionSchema . ID . create ( )
@@ -384,13 +412,51 @@ const layer = Layer.effect(
384412 )
385413 if ( ! SessionInput . equivalent ( admitted , expected ) )
386414 return yield * new PromptConflictError ( { sessionID : input . sessionID , messageID } )
387- if ( input . resume !== false ) yield * execution . wake ( admitted . sessionID )
415+ if ( input . resume !== false ) {
416+ if ( activeShells . has ( admitted . sessionID ) ) return admitted
417+ yield * execution . wake ( admitted . sessionID )
418+ }
388419 return admitted
389420 } ) ,
390421 ) ,
391422 ) ,
392- shell : Effect . fn ( "V2Session.shell" ) ( function * ( ) {
393- return yield * new OperationUnavailableError ( { operation : "shell" } )
423+ shell : Effect . fn ( "V2Session.shell" ) ( function * ( input ) {
424+ const session = yield * result . get ( input . sessionID )
425+ yield * shellLocks . withLock ( input . sessionID ) (
426+ Effect . gen ( function * ( ) {
427+ activeShells . add ( input . sessionID )
428+ if ( ( yield * execution . active ) . has ( input . sessionID ) ) yield * execution . awaitIdle ( input . sessionID )
429+ const messageID = SessionMessage . ID . create ( )
430+ const callID = Identifier . ascending ( )
431+ yield * events . publish (
432+ SessionEvent . Shell . Started ,
433+ {
434+ sessionID : input . sessionID ,
435+ messageID,
436+ callID,
437+ command : input . command ,
438+ timestamp : yield * DateTime . now ,
439+ } ,
440+ { id : input . id } ,
441+ )
442+ const output = yield * runShellCommand ( input . command , session . location . directory ) . pipe (
443+ Effect . provide ( locations . get ( session . location ) ) ,
444+ )
445+ yield * events . publish ( SessionEvent . Shell . Ended , {
446+ sessionID : input . sessionID ,
447+ callID,
448+ output,
449+ timestamp : yield * DateTime . now ,
450+ } )
451+ } ) . pipe (
452+ Effect . ensuring (
453+ Effect . gen ( function * ( ) {
454+ activeShells . delete ( input . sessionID )
455+ yield * execution . wake ( input . sessionID )
456+ } ) ,
457+ ) ,
458+ ) ,
459+ )
394460 } ) ,
395461 skill : Effect . fn ( "V2Session.skill" ) ( function * ( input ) {
396462 const session = yield * result . get ( input . sessionID )
@@ -497,6 +563,9 @@ const resolvePrompt = (input: PromptInput.Prompt) =>
497563 } ) ,
498564 } )
499565
566+ // Mirrors the shell tool's in-memory preview safety limit.
567+ const SHELL_MAX_CAPTURE_BYTES = 1024 * 1024
568+
500569export const node = makeGlobalNode ( {
501570 service : Service ,
502571 layer : layer . pipe ( Layer . orDie ) ,
@@ -508,5 +577,6 @@ export const node = makeGlobalNode({
508577 SessionStore . node ,
509578 LocationServiceMap . node ,
510579 SessionProjector . node ,
580+ AppProcess . node ,
511581 ] ,
512582} )
0 commit comments