pi session context persistence, fix new session button, add clear all sessions

- handle pi process death by nulling piProcess ref so next message respawns
- replay conversation history on respawn via --session flag
- fix user message JSONL format to array for pi compatibility
- fix container sessions mount to match storage path
- fix ChatPanelWrapper: key on inner component so usePiChat resets on new session
- add bulk delete sessions endpoint and clear all button in chat header

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 00:04:14 +00:00
co-authored by Claude Opus 4.6
parent a8d3d1a423
commit c443fe0fe2
9 changed files with 192 additions and 31 deletions
+7
View File
@@ -195,6 +195,10 @@ type SandboxOptions = {
homeDir: string;
};
type SpawnPiOptions = {
sessionFile?: string;
};
export async function spawnPi(
cwd: string,
model: string,
@@ -202,6 +206,7 @@ export async function spawnPi(
email: string,
onEvent: PiEventHandler,
sandbox?: SandboxOptions,
options?: SpawnPiOptions,
): Promise<Subprocess> {
let proc: Subprocess;
@@ -234,6 +239,7 @@ export async function spawnPi(
...resourceSkillFlags,
];
if (model) piArgs.push('--model', model);
if (options?.sessionFile) piArgs.push('--session', options.sessionFile);
const resourcesEnv = buildResourcesEnv();
@@ -284,6 +290,7 @@ export async function spawnPi(
const args = ['pi', '--mode', 'rpc', '--no-skills', '--no-prompt-templates', '--no-themes', ...skillFlags, ...extensionFlags, ...resourceSkillFlags];
if (model) args.push('--model', model);
if (options?.sessionFile) args.push('--session', options.sessionFile);
if (!existsSync(cwd)) {
mkdirSync(cwd, { recursive: true });
+33
View File
@@ -235,6 +235,39 @@ piRestRouter.delete('/pi/sessions/:sessionId', async (ctx: Context) => {
}
});
/**
* DELETE /api/pi/sessions
* Delete all sessions, optionally filtered by context
*/
piRestRouter.delete('/pi/sessions', async (ctx: Context) => {
const user = ctx.get('user');
if (!user) {
return ctx.json({ error: 'Unauthorized' }, 401);
}
const body = await ctx.req.json().catch(() => ({}));
const contextFilter = body.context ? { context: body.context as string, contextId: body.contextId as string | undefined } : undefined;
const userHome = getHomeDir(user.email);
try {
const sessions = await storage.listUserSessions(userHome, contextFilter);
let deleted = 0;
for (const session of sessions) {
try {
await storage.deleteSession(userHome, session.id, session.groupSlug);
deleted++;
} catch {
// Skip sessions that fail to delete
}
}
logger.info('Bulk deleted sessions', { email: user.email, deleted, total: sessions.length, context: contextFilter?.context });
return ctx.json({ success: true, deleted });
} catch (err) {
logger.error('Failed to bulk delete sessions', { email: user.email, error: String(err) });
return ctx.json({ error: 'Failed to delete sessions' }, 500);
}
});
/**
* GET /api/pi/sessions/search
* Search sessions by query
+16 -2
View File
@@ -129,7 +129,7 @@ export function messagesToJnlEntries(messages: Message[], meta: SessionMeta): Jn
id,
parentId: prevId,
timestamp: new Date(msg.timestamp).toISOString(),
message: { role: 'user', content: msg.text ?? '' },
message: { role: 'user', content: [{ type: 'text' as const, text: msg.text ?? '' }] },
};
entries.push(entry);
prevId = id;
@@ -281,11 +281,16 @@ export function jnlEntriesToMessages(entries: JnlEntry[]): ParsedSession {
const ts = new Date(entry.timestamp).getTime();
if (msgEntry.message.role === 'user') {
const rawContent = msgEntry.message.content;
const text =
typeof rawContent === 'string'
? rawContent
: (rawContent as Array<JnlTextContent>).map((c) => c.text).join('\n');
messages.push({
id: entry.id,
timestamp: ts,
role: 'user',
text: msgEntry.message.content as string,
text,
});
} else if (msgEntry.message.role === 'assistant') {
const contentBlocks = msgEntry.message.content as Array<JnlTextContent | JnlToolCall>;
@@ -380,6 +385,15 @@ function metaToIndexEntry(meta: SessionMeta, relFile: string): SessionIndexEntry
};
}
// ── Path resolution ─────────────────────────────────────────────────────
export async function getSessionFilePath(baseCwd: string, sessionId: string): Promise<string | null> {
const index = await loadIndex(baseCwd);
const entry = index[sessionId];
if (!entry) return null;
return path.join(getSessionsDir(baseCwd), entry.file);
}
// ── Session CRUD ───────────────────────────────────────────────────────
export async function saveSession(
+1 -1
View File
@@ -201,7 +201,7 @@ export type JnlUserMessage = JnlEntryBase & {
type: 'message';
message: {
role: 'user';
content: string;
content: string | Array<JnlTextContent>;
};
};
+61 -4
View File
@@ -286,8 +286,37 @@ async function handleChat(
if (!session.piProcess) {
try {
const onEvent = createEventHandler(sessionId, model, cwd, homeDir);
session.piProcess = await piBridge.spawnPi(cwd, model, userId, email, onEvent, sandboxed ? { userId, username, email, homeDir } : undefined);
logger.info('Spawned Pi process for session', { sessionId, model, cwd, sandboxed });
const sandbox = sandboxed ? { userId, username, email, homeDir } : undefined;
// If session has history, save to disk and pass --session for context replay
let spawnOptions: { sessionFile?: string } | undefined;
if (session.messages.length > 0) {
await storage.saveSession(homeDir, sessionId, session.meta, session.messages);
const hostPath = await storage.getSessionFilePath(homeDir, sessionId);
if (hostPath) {
if (sandboxed) {
const containerHome = `/home/${username}`;
const sessionsPrefix = join(homeDir, '.pi', 'agent', 'sessions');
const relativePart = hostPath.slice(sessionsPrefix.length);
spawnOptions = { sessionFile: `${containerHome}/.pi/agent/sessions${relativePart}` };
} else {
spawnOptions = { sessionFile: hostPath };
}
}
}
session.piProcess = await piBridge.spawnPi(cwd, model, userId, email, onEvent, sandbox, spawnOptions);
// Null out piProcess when the process dies so next message triggers respawn
const proc = session.piProcess;
proc.exited.then(() => {
if (session.piProcess === proc) {
session.piProcess = null;
logger.info('Pi process exited, nulled reference', { sessionId });
}
});
logger.info('Spawned Pi process for session', { sessionId, model, cwd, sandboxed, hasSessionFile: !!spawnOptions?.sessionFile });
} catch (err) {
logger.error('Failed to spawn Pi process', { sessionId, model, error: String(err) });
sendToClient(ws, { type: 'error', message: 'Failed to start Pi process' });
@@ -361,8 +390,36 @@ async function handleResume(
const homeDir = getHomeDir(email);
const sandbox = session.sandboxed && session.userId ? { userId: session.userId, username: ws.data.username, email, homeDir } : undefined;
const onEvent = createEventHandler(sessionId, session.model, session.cwd, homeDir);
session.piProcess = await piBridge.spawnPi(session.cwd, session.model, session.userId!, email, onEvent, sandbox);
logger.info('Spawned fresh Pi process for resumed session', { sessionId, model: session.model, sandboxed: session.sandboxed });
// If session has history, save to disk and pass --session for context replay
let spawnOptions: { sessionFile?: string } | undefined;
if (session.messages.length > 0) {
await storage.saveSession(homeDir, sessionId, session.meta, session.messages);
const hostPath = await storage.getSessionFilePath(homeDir, sessionId);
if (hostPath) {
if (sandbox) {
const containerHome = `/home/${ws.data.username}`;
const sessionsPrefix = join(homeDir, '.pi', 'agent', 'sessions');
const relativePart = hostPath.slice(sessionsPrefix.length);
spawnOptions = { sessionFile: `${containerHome}/.pi/agent/sessions${relativePart}` };
} else {
spawnOptions = { sessionFile: hostPath };
}
}
}
session.piProcess = await piBridge.spawnPi(session.cwd, session.model, session.userId!, email, onEvent, sandbox, spawnOptions);
// Null out piProcess when the process dies so next message triggers respawn
const proc = session.piProcess;
proc.exited.then(() => {
if (session.piProcess === proc) {
session.piProcess = null;
logger.info('Pi process exited, nulled reference', { sessionId });
}
});
logger.info('Spawned fresh Pi process for resumed session', { sessionId, model: session.model, sandboxed: session.sandboxed, hasSessionFile: !!spawnOptions?.sessionFile });
} catch (err) {
logger.error('Failed to spawn Pi process for resume', { sessionId, error: String(err) });
sendToClient(ws, { type: 'error', message: 'Failed to start Pi process' });