@@ -5,18 +5,25 @@ import {
55 type BashOperations ,
66 createAgentSession ,
77 type CreateAgentSessionOptions ,
8+ DefaultResourceLoader ,
9+ getAgentDir ,
810 ModelRuntime ,
911 SessionManager ,
1012 SettingsManager ,
1113} from "@earendil-works/pi-coding-agent" ;
14+ import type { Sandbox , SandboxProvider } from "@rivet-dev/sandbox-adapter" ;
1215import type { ActorContext } from "rivetkit" ;
1316import type { DatabaseProvider , RawAccess } from "rivetkit/db" ;
17+ import { createSandboxBashOperations , createSandboxTools } from "./sandbox.js" ;
1418import {
1519 appendPiEntry ,
1620 createPiSession ,
21+ loadPiSandbox ,
1722 loadPiSession ,
1823 type PiSettings ,
24+ savePiSandbox ,
1925 savePiSettings ,
26+ type StoredSandbox ,
2027 toFileEntries ,
2128} from "./storage.js" ;
2229
@@ -39,21 +46,34 @@ export interface PiSessionOptions
3946 extends Omit < CreateAgentSessionOptions , "sessionManager" | "settingsManager" > {
4047 /** Initial Pi settings for a brand-new session. Later changes persist per actor. */
4148 settings ?: Partial < PiSettings > ;
49+ /**
50+ * Runs Pi's built-in file and shell tools in a sandbox. Without one, Pi has
51+ * no file or shell tools and only the `customTools` passed in.
52+ */
53+ sandbox ?: SandboxProvider ;
4254}
4355
4456/** One open Pi session for a live actor generation. */
4557export interface PiSession {
4658 session : AgentSession ;
4759 settingsManager : SettingsManager ;
4860 cwd : string ;
49- /** Command execution for `executeBash`. Undefined runs on the actor host. */
61+ sandbox : ConnectedSandbox | undefined ;
62+ /** Command execution for `executeBash`. Undefined when there is no sandbox. */
5063 bashOperations : BashOperations | undefined ;
5164 /** JSON of the settings last written to SQLite, to skip no-op writes. */
5265 persistedSettings : string ;
5366 /** Entries (header excluded) already written to SQLite, in Pi's append order. */
5467 persistedEntryCount : number ;
5568}
5669
70+ /** The sandbox a session's tools run in for this actor generation. */
71+ export interface ConnectedSandbox {
72+ provider : SandboxProvider ;
73+ id : string ;
74+ sandbox : Sandbox ;
75+ }
76+
5777/** Per-actor-generation runtime state, stored on `c.vars` under `PI_RUNTIME`. */
5878export interface PiRuntime {
5979 ready ?: Promise < PiSession > ;
@@ -94,6 +114,9 @@ export function ensurePiSession(
94114 return runtime . ready ;
95115}
96116
117+ /** Pi's built-in tools, which all run on the actor host. */
118+ const PI_BUILT_IN_TOOLS = [ "read" , "bash" , "powershell" , "edit" , "write" , "grep" , "find" , "ls" ] ;
119+
97120let defaultModelRuntime : Promise < ModelRuntime > | undefined ;
98121
99122/** One `ModelRuntime` per process. It loads provider catalogs and credentials, which are not per actor. */
@@ -107,12 +130,24 @@ async function openPiSession(
107130 runtime : PiRuntime ,
108131 options : PiSessionOptions ,
109132) : Promise < PiSession > {
110- const { settings, ...sessionOptions } = options ;
133+ const { settings, sandbox : sandboxProvider , ...sessionOptions } =
134+ options ;
111135 const stored = await loadPiSession ( c . db ) ;
112- const cwd = stored ?. cwd ?? sessionOptions . cwd ?? process . cwd ( ) ;
136+ const connected = sandboxProvider
137+ ? await connectSandbox ( c , sandboxProvider )
138+ : undefined ;
139+ const sandbox = connected ?. sandbox ;
140+ const cwd = sandbox ?. cwd ?? stored ?. cwd ?? sessionOptions . cwd ?? process . cwd ( ) ;
113141 if ( ! isAbsolute ( cwd ) ) {
114142 throw new Error ( `pi() cwd must be an absolute path, received ${ cwd } ` ) ;
115143 }
144+ if ( stored && stored . cwd !== cwd ) {
145+ c . log . warn ( {
146+ msg : "pi session cwd changed since it was stored" ,
147+ storedCwd : stored . cwd ,
148+ cwd,
149+ } ) ;
150+ }
116151
117152 const settingsManager = SettingsManager . inMemory (
118153 stored ?. settings ?? settings ?? { } ,
@@ -121,13 +156,26 @@ async function openPiSession(
121156 ? SessionManager . inMemory ( cwd , undefined , toFileEntries ( stored ) )
122157 : SessionManager . inMemory ( cwd ) ;
123158 const modelRuntime = sessionOptions . modelRuntime ?? ( await sharedModelRuntime ( ) ) ;
159+ const resourceLoader =
160+ sessionOptions . resourceLoader ??
161+ ( await isolatedResourceLoader ( cwd , sessionOptions . agentDir , settingsManager ) ) ;
124162
125163 const { session, modelFallbackMessage } = await createAgentSession ( {
126164 ...sessionOptions ,
127165 cwd,
128166 modelRuntime,
129167 settingsManager,
130168 sessionManager,
169+ resourceLoader,
170+ customTools : sandbox
171+ ? [ ...( sessionOptions . customTools ?? [ ] ) , ...createSandboxTools ( sandbox ) ]
172+ : sessionOptions . customTools ,
173+ excludeTools : [
174+ ...new Set ( [
175+ ...( sessionOptions . excludeTools ?? [ ] ) ,
176+ ...( sandbox ? [ "powershell" ] : PI_BUILT_IN_TOOLS ) ,
177+ ] ) ,
178+ ] ,
131179 } ) ;
132180 if ( modelFallbackMessage ) {
133181 c . log . warn ( { msg : "pi model fallback" , detail : modelFallbackMessage } ) ;
@@ -137,7 +185,8 @@ async function openPiSession(
137185 session,
138186 settingsManager,
139187 cwd,
140- bashOperations : undefined ,
188+ sandbox : connected ,
189+ bashOperations : sandbox ? createSandboxBashOperations ( sandbox ) : undefined ,
141190 persistedSettings : JSON . stringify ( settingsManager . getGlobalSettings ( ) ) ,
142191 persistedEntryCount : stored ?. entries . length ?? 0 ,
143192 } ;
@@ -176,6 +225,65 @@ async function openPiSession(
176225 return handle ;
177226}
178227
228+ /**
229+ * Connects to the actor's sandbox, creating one when none is stored or the
230+ * provider reports the stored one no longer exists. A new sandbox id is saved
231+ * as soon as `create` returns, so a failure later in the start reuses it. Any
232+ * other connect failure is thrown, so a temporary outage never replaces a
233+ * sandbox.
234+ */
235+ async function connectSandbox (
236+ c : PiContext ,
237+ provider : SandboxProvider ,
238+ ) : Promise < ConnectedSandbox > {
239+ const existing = await loadPiSandbox ( c . db ) ;
240+ if ( existing && existing . provider !== provider . name ) {
241+ throw new Error (
242+ `pi sandbox was created by provider ${ existing . provider } , but the actor now uses ${ provider . name } ` ,
243+ ) ;
244+ }
245+ if ( existing ) {
246+ const sandbox = await provider . connect ( c , existing . id ) ;
247+ if ( sandbox ) return { provider, id : existing . id , sandbox } ;
248+ c . log . warn ( {
249+ msg : "pi sandbox no longer exists, creating a new one; files from the previous sandbox are lost" ,
250+ provider : provider . name ,
251+ sandboxId : existing . id ,
252+ } ) ;
253+ }
254+ const id = await provider . create ( c ) ;
255+ await savePiSandbox ( c . db , { provider : provider . name , id } ) ;
256+ const sandbox = await provider . connect ( c , id ) ;
257+ if ( ! sandbox ) {
258+ throw new Error ( `pi sandbox ${ provider . name } /${ id } was not found right after it was created` ) ;
259+ }
260+ return { provider, id, sandbox } ;
261+ }
262+
263+ /**
264+ * Pi's resource discovery reads the actor host's filesystem and loads host
265+ * code as extensions. Extensions, skills, prompt templates, context files, and
266+ * themes stay off unless the developer passes a loader.
267+ */
268+ async function isolatedResourceLoader (
269+ cwd : string ,
270+ agentDir : string | undefined ,
271+ settingsManager : SettingsManager ,
272+ ) : Promise < DefaultResourceLoader > {
273+ const loader = new DefaultResourceLoader ( {
274+ cwd,
275+ agentDir : agentDir ?? getAgentDir ( ) ,
276+ settingsManager,
277+ noExtensions : true ,
278+ noSkills : true ,
279+ noPromptTemplates : true ,
280+ noContextFiles : true ,
281+ noThemes : true ,
282+ } ) ;
283+ await loader . reload ( ) ;
284+ return loader ;
285+ }
286+
179287/** Token and output deltas never append entries, so they skip the entry diff. */
180288function isStreamingDelta ( event : AgentSessionEvent ) : boolean {
181289 return (
@@ -239,40 +347,71 @@ export async function persistPiState(
239347
240348/**
241349 * Stops the Pi session for this actor generation: aborts any run, lets
242- * extensions shut down, flushes settings, and waits for queued entry writes.
350+ * extensions shut down, and flushes settings and entries. Then suspends the
351+ * sandbox on sleep, or destroys it on destroy.
243352 */
244- export async function closePiSession ( c : PiContext ) : Promise < void > {
353+ export async function closePiSession (
354+ c : PiContext ,
355+ options : PiSessionOptions ,
356+ reason : "sleep" | "destroy" ,
357+ ) : Promise < void > {
245358 const runtime = piRuntime ( c ) ;
246359 const ready = runtime . ready ;
247360 runtime . ready = undefined ;
248- if ( ! ready ) return ;
249- let handle : PiSession ;
361+ const errors : unknown [ ] = [ ] ;
362+ let handle : PiSession | undefined ;
250363 try {
251364 handle = await ready ;
252365 } catch {
253- return ;
254366 }
255367
256- const errors : unknown [ ] = [ ] ;
257- await attempt ( errors , ( ) => handle . session . abort ( ) ) ;
258- await attempt ( errors , async ( ) => {
259- if ( handle . session . hasExtensionHandlers ( "session_shutdown" ) ) {
260- await handle . session . extensionRunner . emit ( {
261- type : "session_shutdown" ,
262- reason : "quit" ,
263- } ) ;
264- }
265- } ) ;
266- await attempt ( errors , ( ) => persistPiState ( c , handle ) ) ;
267- handle . session . dispose ( ) ;
368+ if ( handle ) {
369+ const open = handle ;
370+ await attempt ( errors , ( ) => open . session . abort ( ) ) ;
371+ await attempt ( errors , async ( ) => {
372+ if ( open . session . hasExtensionHandlers ( "session_shutdown" ) ) {
373+ await open . session . extensionRunner . emit ( {
374+ type : "session_shutdown" ,
375+ reason : "quit" ,
376+ } ) ;
377+ }
378+ } ) ;
379+ await attempt ( errors , ( ) => persistPiState ( c , open ) ) ;
380+ open . session . dispose ( ) ;
381+ c . log . info ( { msg : "pi session closed" , sessionId : open . session . sessionId } ) ;
382+ }
383+
384+ const provider = options . sandbox ;
385+ if ( provider ) {
386+ await attempt ( errors , async ( ) => {
387+ if ( reason === "sleep" ) {
388+ if ( handle ?. sandbox && provider . suspend ) {
389+ await provider . suspend ( c , handle . sandbox . id ) ;
390+ }
391+ return ;
392+ }
393+ const sandbox = handle ?. sandbox ?? ( await storedSandbox ( c , provider ) ) ;
394+ if ( sandbox && provider . destroy ) {
395+ await provider . destroy ( c , sandbox . id ) ;
396+ }
397+ } ) ;
398+ }
268399
269- c . log . info ( { msg : "pi session closed" , sessionId : handle . session . sessionId } ) ;
270400 if ( errors . length === 1 ) throw errors [ 0 ] ;
271401 if ( errors . length > 1 ) {
272402 throw new AggregateError ( errors , "pi session shutdown failed" ) ;
273403 }
274404}
275405
406+ /** The stored sandbox, when it belongs to `provider`. */
407+ async function storedSandbox (
408+ c : PiContext ,
409+ provider : SandboxProvider ,
410+ ) : Promise < StoredSandbox | undefined > {
411+ const sandbox = await loadPiSandbox ( c . db ) ;
412+ return sandbox ?. provider === provider . name ? sandbox : undefined ;
413+ }
414+
276415async function attempt (
277416 errors : unknown [ ] ,
278417 operation : ( ) => void | Promise < void > ,
0 commit comments