@@ -21,6 +21,20 @@ export type PiImagePayload = {
2121} ;
2222/** Generic parameter bag for a pi RPC command written to child stdin. */
2323export type PiRpcParams = JsonRecord ;
24+ /**
25+ * Which RPC command continues a prior conversation.
26+ *
27+ * - `new-session-parent` (default): `new_session { parentSession }`. Upstream pi
28+ * loads the parent transcript into the freshly minted session.
29+ * - `switch-session`: `switch_session { sessionPath }`. Oh My Pi's fork narrowed
30+ * `new_session`'s `parentSession` to a lineage-only header field that records
31+ * provenance WITHOUT replaying the parent's entries, so resuming through it
32+ * silently yields an empty context. `switch_session` reopens the transcript in
33+ * place and keeps appending to the same `.jsonl`.
34+ */
35+ export type PiRpcResumeCommand = 'new-session-parent' | 'switch-session' ;
36+ /** Directory under the working directory holding a runtime's session `.jsonl` files. */
37+ export const DEFAULT_PI_SESSION_DIR_NAME = '.pi' ;
2438/** Options for `attachPiRpcSession`. All fields map directly to the pi RPC protocol. */
2539export type PiRpcSessionOptions = {
2640 child : ChildProcess ;
@@ -31,6 +45,8 @@ export type PiRpcSessionOptions = {
3145 imagePaths ?: string [ ] ;
3246 uploadRoot ?: string ;
3347 parentSession ?: string ;
48+ resumeCommand ?: PiRpcResumeCommand ;
49+ sessionDirName ?: string ;
3450} ;
3551/** Handle returned by `attachPiRpcSession` for querying run state and requesting abort. */
3652export type PiRpcSession = {
@@ -94,15 +110,34 @@ export function replyExtensionUi(writable: Writable, raw: JsonRecord): void {
94110/** Snapshot of `.pi/sessions/` file metadata taken before a prompt is sent. */
95111export type PiSessionFileSnapshot = Map < string , { mtimeMs : number ; size : number } > ;
96112/**
97- * Reads `.pi/sessions/*.jsonl` entries from the given working directory,
98- * returning file paths with their mtime and size. Returns an empty array
99- * when the directory is absent, empty, or unreadable.
113+ * Absolute path of the session directory a pi-family runtime writes into,
114+ * given its working directory. Each runtime owns its own directory name so two
115+ * adapters (pi and Oh My Pi) running against the same project cannot see each
116+ * other's transcripts as "the file this run changed".
100117 *
101- * @param cwd - Absolute path to the pi working directory; may be undefined.
118+ * @param cwd - Absolute path to the runtime's working directory.
119+ * @param sessionDirName - Directory under `cwd`; defaults to pi's `.pi`.
102120 */
103- export function readPiSessionFiles ( cwd : string | undefined ) : Array < { path : string ; mtimeMs : number ; size : number } > {
121+ export function piSessionsDir (
122+ cwd : string ,
123+ sessionDirName : string = DEFAULT_PI_SESSION_DIR_NAME ,
124+ ) : string {
125+ return path . join ( cwd , sessionDirName , 'sessions' ) ;
126+ }
127+ /**
128+ * Reads `<sessionDirName>/sessions/*.jsonl` entries from the given working
129+ * directory, returning file paths with their mtime and size. Returns an empty
130+ * array when the directory is absent, empty, or unreadable.
131+ *
132+ * @param cwd - Absolute path to the pi working directory; may be undefined.
133+ * @param sessionDirName - Directory under `cwd`; defaults to pi's `.pi`.
134+ */
135+ export function readPiSessionFiles (
136+ cwd : string | undefined ,
137+ sessionDirName : string = DEFAULT_PI_SESSION_DIR_NAME ,
138+ ) : Array < { path : string ; mtimeMs : number ; size : number } > {
104139 if ( typeof cwd !== 'string' || cwd . length === 0 ) return [ ] ;
105- const sessionsDir = path . join ( cwd , '.pi' , 'sessions' ) ;
140+ const sessionsDir = piSessionsDir ( cwd , sessionDirName ) ;
106141 let entries : fs . Dirent [ ] ;
107142 try {
108143 entries = fs . readdirSync ( sessionsDir , { withFileTypes : true } ) ;
@@ -123,33 +158,39 @@ export function readPiSessionFiles(cwd: string | undefined): Array<{ path: strin
123158 return files ;
124159}
125160/**
126- * Takes a before-snapshot of `.pi/sessions/` to enable changed-file detection
127- * after the prompt completes.
161+ * Takes a before-snapshot of the session directory to enable changed-file
162+ * detection after the prompt completes.
128163 *
129- * @param cwd - Absolute path to the pi working directory; may be undefined.
164+ * @param cwd - Absolute path to the pi working directory; may be undefined.
165+ * @param sessionDirName - Directory under `cwd`; defaults to pi's `.pi`.
130166 */
131- export function snapshotPiSessionFiles ( cwd : string | undefined ) : PiSessionFileSnapshot {
167+ export function snapshotPiSessionFiles (
168+ cwd : string | undefined ,
169+ sessionDirName : string = DEFAULT_PI_SESSION_DIR_NAME ,
170+ ) : PiSessionFileSnapshot {
132171 const snapshot : PiSessionFileSnapshot = new Map ( ) ;
133- for ( const file of readPiSessionFiles ( cwd ) ) {
172+ for ( const file of readPiSessionFiles ( cwd , sessionDirName ) ) {
134173 snapshot . set ( file . path , { mtimeMs : file . mtimeMs , size : file . size } ) ;
135174 }
136175 return snapshot ;
137176}
138177/**
139- * Compares the current `.pi/sessions/` directory against a before-snapshot
140- * and returns the path of the single changed file. Returns `null` when zero
141- * or more than one file changed — concurrent pi processes are detected this
142- * way to avoid associating the wrong session with this run.
178+ * Compares the current session directory against a before-snapshot and returns
179+ * the path of the single changed file. Returns `null` when zero or more than
180+ * one file changed — concurrent pi processes are detected this way to avoid
181+ * associating the wrong session with this run.
143182 *
144- * @param cwd - Absolute path to the pi working directory; may be undefined.
145- * @param before - Snapshot taken before the prompt was sent.
183+ * @param cwd - Absolute path to the pi working directory; may be undefined.
184+ * @param before - Snapshot taken before the prompt was sent.
185+ * @param sessionDirName - Directory under `cwd`; defaults to pi's `.pi`.
146186 * @returns Absolute path of the changed session file, or `null`.
147187 */
148188export function resolveSessionPathChangedSince (
149189 cwd : string | undefined ,
150190 before : PiSessionFileSnapshot ,
191+ sessionDirName : string = DEFAULT_PI_SESSION_DIR_NAME ,
151192) : string | null {
152- const changed = readPiSessionFiles ( cwd ) . filter ( ( file ) => {
193+ const changed = readPiSessionFiles ( cwd , sessionDirName ) . filter ( ( file ) => {
153194 const previous = before . get ( file . path ) ;
154195 return ! previous || file . mtimeMs > previous . mtimeMs || file . size !== previous . size ;
155196 } ) ;
@@ -159,10 +200,12 @@ export function resolveSessionPathChangedSince(
159200 * Attaches the daemon's run lifecycle to an already-spawned `pi --mode rpc` child process.
160201 *
161202 * Responsibilities:
162- * - Sends a `new_session` RPC command (with `parentSession`) before the prompt when
163- * resuming a prior conversation, waiting for acknowledgement before the prompt is sent.
164- * This preserves conversation history across edit rounds; if the parent session is
165- * rejected, the run is failed immediately rather than continuing without prior context.
203+ * - Sends the runtime's conversation-reload RPC command before the prompt when resuming a
204+ * prior conversation, waiting for acknowledgement before the prompt is sent. `pi` uses
205+ * `new_session` with `parentSession`; Oh My Pi uses `switch_session` (see
206+ * {@link PiRpcResumeCommand}). This preserves conversation history across edit rounds;
207+ * if the parent session is rejected, the run is failed immediately rather than
208+ * continuing without prior context.
166209 * - Encodes and forwards `imagePaths` as base64 in the `prompt` RPC command, subject to
167210 * `MAX_IMAGE_COUNT` and `MAX_TOTAL_IMAGE_BYTES` budgets. Symlinks are resolved via
168211 * `realpathSync` and re-verified against `uploadRoot` to prevent path-escape attacks.
@@ -187,6 +230,8 @@ export function attachPiRpcSession({
187230 imagePaths,
188231 uploadRoot,
189232 parentSession,
233+ resumeCommand = 'new-session-parent' ,
234+ sessionDirName = DEFAULT_PI_SESSION_DIR_NAME ,
190235} : PiRpcSessionOptions ) : PiRpcSession {
191236 const stdin = child . stdin ;
192237 const stdout = child . stdout ;
@@ -198,7 +243,7 @@ export function attachPiRpcSession({
198243 }
199244
200245 const runStartedAt = Date . now ( ) ;
201- const sessionFilesBeforePrompt = snapshotPiSessionFiles ( cwd ) ;
246+ const sessionFilesBeforePrompt = snapshotPiSessionFiles ( cwd , sessionDirName ) ;
202247 let finished = false ;
203248 let fatal = false ;
204249 const sentFirstToken = { value : false } ;
@@ -310,14 +355,30 @@ export function attachPiRpcSession({
310355 } ) ;
311356 } ;
312357
313- // If a prior session file path is provided, send new_session with
314- // parentSession so pi loads the prior conversation history into the
315- // new session, enabling conversational continuity across edit rounds.
316- // Do not send the prompt until pi acknowledges this RPC: resumed prompts
317- // intentionally contain only the latest user turn, so continuing after a
318- // failed parent load would silently drop prior conversation context.
358+ // If a prior session file path is provided, ask the runtime to reload that
359+ // conversation before prompting, enabling continuity across edit rounds.
360+ // Do not send the prompt until the runtime acknowledges this RPC: resumed
361+ // prompts intentionally contain only the latest user turn, so continuing
362+ // after a failed load would silently drop prior conversation context.
319363 if ( parentSession ) {
320- parentSessionRpcId = sendCommand ( stdin , 'new_session' , { parentSession } ) ;
364+ if ( resumeCommand === 'switch-session' ) {
365+ // `switch_session` reports success even for a path that no longer
366+ // exists — it just opens an empty transcript. Since the daemon already
367+ // trimmed the prompt to the latest turn, that would silently erase the
368+ // conversation, so prove the file is there before handing it over.
369+ if ( ! fs . existsSync ( parentSession ) ) {
370+ fail (
371+ `parent session file is missing: ${ parentSession } ` ,
372+ 'PI_PARENT_SESSION_FAILED' ,
373+ ) ;
374+ } else {
375+ parentSessionRpcId = sendCommand ( stdin , 'switch_session' , {
376+ sessionPath : parentSession ,
377+ } ) ;
378+ }
379+ } else {
380+ parentSessionRpcId = sendCommand ( stdin , 'new_session' , { parentSession } ) ;
381+ }
321382 } else {
322383 sendPromptCommand ( ) ;
323384 }
@@ -348,6 +409,17 @@ export function attachPiRpcSession({
348409 ) ;
349410 return ;
350411 }
412+ // A `switch_session` that reports `cancelled` (an extension vetoed the
413+ // reload) left the runtime on a different transcript than the one this
414+ // turn was trimmed against. Treat it as a resume failure rather than
415+ // prompting into the wrong conversation.
416+ if (
417+ resumeCommand === 'switch-session' &&
418+ getRecord ( raw . data ) ?. cancelled === true
419+ ) {
420+ fail ( 'parent session switch was cancelled' , 'PI_PARENT_SESSION_FAILED' ) ;
421+ return ;
422+ }
351423 sendPromptCommand ( ) ;
352424 return ;
353425 }
@@ -365,7 +437,11 @@ export function attachPiRpcSession({
365437 // Capture only the session file changed by this run. If another pi
366438 // process wrote to the shared session directory concurrently, the
367439 // resolver returns null instead of risking cross-conversation resume.
368- capturedSessionPath = resolveSessionPathChangedSince ( cwd , sessionFilesBeforePrompt ) ;
440+ capturedSessionPath = resolveSessionPathChangedSince (
441+ cwd ,
442+ sessionFilesBeforePrompt ,
443+ sessionDirName ,
444+ ) ;
369445 // pi's RPC process stays alive after agent_end (designed for
370446 // multi-prompt sessions). The daemon's /api/chat is single-shot,
371447 // so close stdin and let the process exit naturally, or kill it
0 commit comments