Add session archiving

This commit is contained in:
Federico Jaramillo Martinez
2026-05-07 21:06:11 +02:00
parent b5b1814383
commit 402d5b17aa
11 changed files with 272 additions and 25 deletions
+20 -1
View File
@@ -24,6 +24,8 @@ export interface SessionInfo {
modified: string; modified: string;
messageCount: number; messageCount: number;
firstMessage: string; firstMessage: string;
archived?: boolean;
archivedAt?: string;
} }
export interface SessionActivity { export interface SessionActivity {
@@ -77,7 +79,7 @@ export type CommandResult =
async function request<T>(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise<T> { async function request<T>(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers); const headers = new Headers(init?.headers);
headers.set("content-type", "application/json"); if (init?.body !== undefined) headers.set("content-type", "application/json");
const response = await fetch(url, { ...init, headers }); const response = await fetch(url, { ...init, headers });
if (!response.ok) { if (!response.ok) {
const body: unknown = await response.json().catch((): unknown => ({})); const body: unknown = await response.json().catch((): unknown => ({}));
@@ -107,6 +109,8 @@ export const api = {
runCommand: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/commands/run`, parseCommandResult, { method: "POST", body: JSON.stringify({ text }) }), runCommand: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/commands/run`, parseCommandResult, { method: "POST", body: JSON.stringify({ text }) }),
respondToCommand: (sessionId: string, requestId: string, value: string) => request(`/api/sessions/${sessionId}/commands/respond`, parseCommandResult, { method: "POST", body: JSON.stringify({ requestId, value }) }), respondToCommand: (sessionId: string, requestId: string, value: string) => request(`/api/sessions/${sessionId}/commands/respond`, parseCommandResult, { method: "POST", body: JSON.stringify({ requestId, value }) }),
stop: (sessionId: string) => request(`/api/sessions/${sessionId}/stop`, parseStopped, { method: "POST" }), stop: (sessionId: string) => request(`/api/sessions/${sessionId}/stop`, parseStopped, { method: "POST" }),
archive: (sessionId: string) => request(`/api/sessions/${sessionId}/archive`, parseArchived, { method: "POST" }),
restore: (sessionId: string) => request(`/api/sessions/${sessionId}/restore`, parseRestored, { method: "POST" }),
}; };
export function sessionEvents(sessionId: string): WebSocket { export function sessionEvents(sessionId: string): WebSocket {
@@ -204,6 +208,7 @@ function parseWorkspace(value: unknown): Workspace {
function parseSessionInfo(value: unknown): SessionInfo { function parseSessionInfo(value: unknown): SessionInfo {
const record = requireRecord(value); const record = requireRecord(value);
const name = optionalString(record, "name"); const name = optionalString(record, "name");
const archivedAt = optionalString(record, "archivedAt");
return { return {
id: requireString(record, "id"), id: requireString(record, "id"),
path: requireString(record, "path"), path: requireString(record, "path"),
@@ -213,6 +218,8 @@ function parseSessionInfo(value: unknown): SessionInfo {
modified: requireString(record, "modified"), modified: requireString(record, "modified"),
messageCount: requireNumber(record, "messageCount"), messageCount: requireNumber(record, "messageCount"),
firstMessage: requireString(record, "firstMessage"), firstMessage: requireString(record, "firstMessage"),
...(record["archived"] === true ? { archived: true } : {}),
...(archivedAt === undefined ? {} : { archivedAt }),
}; };
} }
@@ -299,6 +306,18 @@ function parseStopped(value: unknown): { stopped: true } {
return { stopped: true }; return { stopped: true };
} }
function parseArchived(value: unknown): { archived: true } {
const record = requireRecord(value);
if (record["archived"] !== true) throw new Error("Expected archived response");
return { archived: true };
}
function parseRestored(value: unknown): { restored: true } {
const record = requireRecord(value);
if (record["restored"] !== true) throw new Error("Expected restored response");
return { restored: true };
}
function optionalNumber(record: Record<string, unknown>, key: string): number | undefined { function optionalNumber(record: Record<string, unknown>, key: string): number | undefined {
const value = record[key]; const value = record[key];
if (value === undefined) return undefined; if (value === undefined) return undefined;
+2 -2
View File
@@ -109,13 +109,13 @@ export class PiWebApp extends LitElement {
</header> </header>
<project-list .projects=${state.projects} .selected=${state.selectedProject} .onSelect=${(project: Project) => this.withChatScrollTransition(() => this.workspaces.selectProject(project))}></project-list> <project-list .projects=${state.projects} .selected=${state.selectedProject} .onSelect=${(project: Project) => this.withChatScrollTransition(() => this.workspaces.selectProject(project))}></project-list>
<workspace-list .workspaces=${state.workspaces} .selected=${state.selectedWorkspace} .onSelect=${(workspace: Workspace) => this.withChatScrollTransition(() => this.workspaces.selectWorkspace(workspace))}></workspace-list> <workspace-list .workspaces=${state.workspaces} .selected=${state.selectedWorkspace} .onSelect=${(workspace: Workspace) => this.withChatScrollTransition(() => this.workspaces.selectWorkspace(workspace))}></workspace-list>
<session-list .sessions=${state.sessions} .statuses=${state.sessionStatuses} .activities=${state.sessionActivities} .selected=${state.selectedSession} .canStart=${!!state.selectedWorkspace} .onStart=${() => this.withChatScrollTransition(() => this.sessions.startSession())} .onSelect=${(session: SessionInfo) => this.withChatScrollTransition(() => this.sessions.selectSession(session))}></session-list> <session-list .sessions=${state.sessions} .statuses=${state.sessionStatuses} .activities=${state.sessionActivities} .selected=${state.selectedSession} .canStart=${!!state.selectedWorkspace} .onStart=${() => this.withChatScrollTransition(() => this.sessions.startSession())} .onSelect=${(session: SessionInfo) => this.withChatScrollTransition(() => this.sessions.selectSession(session))} .onArchive=${(session: SessionInfo) => this.sessions.archiveSession(session)} .onRestore=${(session: SessionInfo) => this.sessions.restoreSession(session)}></session-list>
</aside> </aside>
<main> <main>
${state.error ? html`<div class="error">${state.error}</div>` : null} ${state.error ? html`<div class="error">${state.error}</div>` : null}
${state.selectedSession ? html` ${state.selectedSession ? html`
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view> <chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => this.sessions.send(text, streamingBehavior)} .onStopSession=${() => this.sessions.stopSession()}></prompt-editor> <prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => this.sessions.send(text, streamingBehavior)} .onStopSession=${() => this.sessions.stopSession()}></prompt-editor>
<status-bar .status=${state.status} .activity=${state.activity} .workspace=${state.selectedWorkspace}></status-bar> <status-bar .status=${state.status} .activity=${state.activity} .workspace=${state.selectedWorkspace}></status-bar>
${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null} ${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null}
` : html`<div class="empty">Select or start a session.</div>`} ` : html`<div class="empty">Select or start a session.</div>`}
+55 -7
View File
@@ -1,5 +1,5 @@
import { LitElement, html } from "lit"; import { LitElement, html, type PropertyValues } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import type { SessionActivity, SessionInfo, SessionStatus } from "../api"; import type { SessionActivity, SessionInfo, SessionStatus } from "../api";
import { listStyles } from "./shared"; import { listStyles } from "./shared";
@@ -17,21 +17,69 @@ export class SessionList extends LitElement {
@property({ type: Boolean }) canStart = false; @property({ type: Boolean }) canStart = false;
@property({ attribute: false }) onSelect?: (session: SessionInfo) => void; @property({ attribute: false }) onSelect?: (session: SessionInfo) => void;
@property({ attribute: false }) onStart?: () => void; @property({ attribute: false }) onStart?: () => void;
@state() private openMenuSessionId: string | undefined;
private readonly onDocumentClick = (event: MouseEvent) => {
if (event.composedPath().includes(this)) return;
this.openMenuSessionId = undefined;
};
@property({ attribute: false }) onArchive?: (session: SessionInfo) => void;
@property({ attribute: false }) onRestore?: (session: SessionInfo) => void;
override connectedCallback(): void {
super.connectedCallback();
document.addEventListener("click", this.onDocumentClick);
}
override disconnectedCallback(): void {
document.removeEventListener("click", this.onDocumentClick);
super.disconnectedCallback();
}
protected override updated(changed: PropertyValues<this>): void {
if (changed.has("sessions") && this.openMenuSessionId !== undefined && !this.sessions.some((session) => session.id === this.openMenuSessionId)) this.openMenuSessionId = undefined;
}
override render() { override render() {
const active = this.sessions.filter((session) => session.archived !== true);
const archived = this.sessions.filter((session) => session.archived === true);
return html` return html`
<section> <section>
<h2>Sessions <button ?disabled=${!this.canStart} @click=${() => this.onStart?.()}>+</button></h2> <h2>Sessions <button ?disabled=${!this.canStart} @click=${() => this.onStart?.()}>+</button></h2>
${this.sessions.map((session) => html` ${active.map((session) => this.renderSession(session))}
<button class=${this.selected?.id === session.id ? "selected" : ""} @click=${() => this.onSelect?.(session)}> ${archived.length > 0 ? html`
<span>${sessionLabel(session)}</span><small>${this.renderStatus(session)}${String(session.messageCount)} messages</small> <h2 class="subheading">Archived</h2>
</button> ${archived.map((session) => this.renderSession(session))}
`)} ` : null}
</section> </section>
`; `;
} }
private renderSession(session: SessionInfo) {
return html`
<div class="session-row ${this.selected?.id === session.id ? "selected" : ""} ${session.archived === true ? "archived" : ""}">
<button class="session-main" @click=${() => this.onSelect?.(session)}>
<span>${sessionLabel(session)}</span><small>${this.renderStatus(session)}${String(session.messageCount)} messages</small>
</button>
<div class="session-menu">
<button class="session-menu-toggle" title="Session actions" @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(session.id); }}>⋯</button>
${this.openMenuSessionId === session.id ? html`
<div class="session-menu-panel">
${session.archived === true
? html`<button title="Restore session" @click=${() => { this.openMenuSessionId = undefined; this.onRestore?.(session); }}>Restore</button>`
: html`<button title="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button>`}
</div>
` : null}
</div>
</div>
`;
}
private toggleMenu(sessionId: string) {
this.openMenuSessionId = this.openMenuSessionId === sessionId ? undefined : sessionId;
}
private renderStatus(session: SessionInfo) { private renderStatus(session: SessionInfo) {
if (session.archived === true) return "read-only · ";
const status = this.statuses[session.id]; const status = this.statuses[session.id];
const activity = this.activities[session.id]; const activity = this.activities[session.id];
if (activity?.phase === "active") return `${activity.label} · `; if (activity?.phase === "active") return `${activity.label} · `;
+10
View File
@@ -45,6 +45,16 @@ export const listStyles = css`
h2 { display: flex; justify-content: space-between; align-items: center; margin: 0 0 8px; color: #8b949e; font-size: 12px; text-transform: uppercase; } h2 { display: flex; justify-content: space-between; align-items: center; margin: 0 0 8px; color: #8b949e; font-size: 12px; text-transform: uppercase; }
button { border: 1px solid #30363d; border-radius: 8px; background: #161b22; color: #e6edf3; padding: 7px 9px; cursor: pointer; } button { border: 1px solid #30363d; border-radius: 8px; background: #161b22; color: #e6edf3; padding: 7px 9px; cursor: pointer; }
section > button { display: block; width: 100%; text-align: left; margin: 6px 0; } section > button { display: block; width: 100%; text-align: left; margin: 6px 0; }
.subheading { margin-top: 14px; }
.session-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px; margin: 6px 0; }
.session-row.selected .session-main { border-color: #58a6ff; background: #0d2847; }
.session-row.archived .session-main { color: #8b949e; }
.session-main { min-width: 0; text-align: left; }
.session-menu { position: relative; align-self: stretch; }
.session-menu-toggle { display: grid; place-items: center; height: 100%; min-width: 30px; padding: 0; color: #8b949e; }
.session-menu-panel { position: absolute; right: 0; top: calc(100% + 4px); z-index: 5; min-width: 120px; padding: 4px; border: 1px solid #30363d; border-radius: 8px; background: #161b22; box-shadow: 0 8px 24px #0008; }
.session-menu-panel button { display: block; width: 100%; text-align: left; border: 0; background: transparent; color: #e6edf3; }
.session-menu-panel button:hover { background: #0d2847; }
button.selected { border-color: #58a6ff; background: #0d2847; } button.selected { border-color: #58a6ff; background: #0d2847; }
button:disabled { opacity: .5; cursor: not-allowed; } button:disabled { opacity: .5; cursor: not-allowed; }
small { display: block; color: #8b949e; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } small { display: block; color: #8b949e; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -46,6 +46,13 @@ export class SessionController {
async selectSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined }) { async selectSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined }) {
this.socket.close(); this.socket.close();
try { try {
if (session.archived === true) {
const page = await api.messages(session.id, { limit: MESSAGE_PAGE_SIZE });
const history = this.mergeAndCacheHistory(session.id, page);
this.setState({ selectedSession: session, messages: normalizeMessages(history.messages), messagePageStart: history.start, messagePageTotal: history.total, isLoadingEarlierMessages: false, status: undefined, activity: undefined });
if (options?.updateUrl !== false) this.updateUrl();
return;
}
const buffered: SessionUiEvent[] = []; const buffered: SessionUiEvent[] = [];
this.socket.connect(session.id, (event) => buffered.push(event)); this.socket.connect(session.id, (event) => buffered.push(event));
const [page, status] = await Promise.all([api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }), api.status(session.id)]); const [page, status] = await Promise.all([api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }), api.status(session.id)]);
@@ -86,7 +93,7 @@ export class SessionController {
if (trimmed.startsWith("/")) return this.runCommand(text); if (trimmed.startsWith("/")) return this.runCommand(text);
if (isShellInput(text)) return this.runShell(text); if (isShellInput(text)) return this.runShell(text);
const session = this.getState().selectedSession; const session = this.getState().selectedSession;
if (!session) return; if (!session || session.archived === true) return;
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] }); this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
try { try {
await api.prompt(session.id, text, streamingBehavior); await api.prompt(session.id, text, streamingBehavior);
@@ -97,7 +104,7 @@ export class SessionController {
async runShell(text: string) { async runShell(text: string) {
const session = this.getState().selectedSession; const session = this.getState().selectedSession;
if (!session) return; if (!session || session.archived === true) return;
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] }); this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
try { try {
await api.shell(session.id, text); await api.shell(session.id, text);
@@ -108,7 +115,7 @@ export class SessionController {
async runCommand(text: string) { async runCommand(text: string) {
const session = this.getState().selectedSession; const session = this.getState().selectedSession;
if (!session) return; if (!session || session.archived === true) return;
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] }); this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
try { try {
this.applyCommandResult(await api.runCommand(session.id, text)); this.applyCommandResult(await api.runCommand(session.id, text));
@@ -132,6 +139,34 @@ export class SessionController {
this.setState({ commandDialog: undefined }); this.setState({ commandDialog: undefined });
} }
async archiveSession(session = this.getState().selectedSession) {
if (!session) return;
try {
await api.archive(session.id);
this.replaceSession({ ...session, archived: true, archivedAt: new Date().toISOString() });
if (this.getState().selectedSession?.id === session.id) {
this.socket.close();
this.setState({ status: undefined, activity: undefined });
}
} catch (error) {
this.setState({ error: String(error) });
}
}
async restoreSession(session = this.getState().selectedSession) {
if (!session) return;
try {
await api.restore(session.id);
const restored = { ...session };
delete restored.archived;
delete restored.archivedAt;
this.replaceSession(restored);
if (this.getState().selectedSession?.id === restored.id) await this.selectSession(restored);
} catch (error) {
this.setState({ error: String(error) });
}
}
async stopSession() { async stopSession() {
const session = this.getState().selectedSession; const session = this.getState().selectedSession;
if (!session) return; if (!session) return;
@@ -145,6 +180,14 @@ export class SessionController {
} }
} }
private replaceSession(session: SessionInfo) {
const current = this.getState().selectedSession;
this.setState({
sessions: this.getState().sessions.map((candidate) => candidate.id === session.id ? session : candidate),
selectedSession: current?.id === session.id ? session : current,
});
}
private mergeAndCacheHistory(sessionId: string, page: RawMessagePage): RawMessagePage { private mergeAndCacheHistory(sessionId: string, page: RawMessagePage): RawMessagePage {
const history = mergeChatHistory(readChatHistoryCache(sessionId), page); const history = mergeChatHistory(readChatHistoryCache(sessionId), page);
writeChatHistoryCache(sessionId, history); writeChatHistoryCache(sessionId, history);
@@ -31,7 +31,7 @@ export class WorkspaceController {
const sessions = await api.sessions(workspace.path); const sessions = await api.sessions(workspace.path);
this.setState({ sessions }); this.setState({ sessions });
const sessionId = target?.sessionId; const sessionId = target?.sessionId;
const session = sessionId !== undefined && sessionId !== "" ? sessions.find((s) => s.id === sessionId || s.id.startsWith(sessionId)) : sessions[0]; const session = sessionId !== undefined && sessionId !== "" ? sessions.find((s) => s.id === sessionId || s.id.startsWith(sessionId)) : sessions.find((s) => s.archived !== true);
if (session) await this.sessions.selectSession(session, { updateUrl: target?.updateUrl }); if (session) await this.sessions.selectSession(session, { updateUrl: target?.updateUrl });
else if (target?.updateUrl !== false) this.updateUrl(); else if (target?.updateUrl !== false) this.updateUrl();
} catch (error) { } catch (error) {
@@ -27,6 +27,8 @@ export function registerSessionProxyRoutes(app: FastifyInstance, daemon = new Se
app.post<{ Params: { sessionId: string }; Body: { requestId: string; value: string } }>("/api/sessions/:sessionId/commands/respond", (request, reply) => proxy(request, reply)); app.post<{ Params: { sessionId: string }; Body: { requestId: string; value: string } }>("/api/sessions/:sessionId/commands/respond", (request, reply) => proxy(request, reply));
app.post<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/abort", (request, reply) => proxy(request, reply)); app.post<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/abort", (request, reply) => proxy(request, reply));
app.post<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/stop", (request, reply) => proxy(request, reply)); app.post<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/stop", (request, reply) => proxy(request, reply));
app.post<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/archive", (request, reply) => proxy(request, reply));
app.post<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/restore", (request, reply) => proxy(request, reply));
app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/events", { websocket: true }, (socket, request) => { app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/events", { websocket: true }, (socket, request) => {
bridgeSockets(socket, daemon.connectWebSocket(`/sessions/${request.params.sessionId}/events`)); bridgeSockets(socket, daemon.connectWebSocket(`/sessions/${request.params.sessionId}/events`));
+37 -11
View File
@@ -13,6 +13,7 @@ import type { ClientCommand, ClientCommandResult, ClientMessagePage, ClientSessi
import type { SessionEventHub } from "../realtime/sessionEventHub.js"; import type { SessionEventHub } from "../realtime/sessionEventHub.js";
import { BUILTIN_COMMANDS } from "./builtinCommands.js"; import { BUILTIN_COMMANDS } from "./builtinCommands.js";
import { SessionCommandService } from "./sessionCommandService.js"; import { SessionCommandService } from "./sessionCommandService.js";
import { SessionArchiveStore } from "./sessionArchiveStore.js";
import type { ActiveSession } from "./sessionRuntimeStore.js"; import type { ActiveSession } from "./sessionRuntimeStore.js";
function noop(): void { function noop(): void {
@@ -24,6 +25,7 @@ export class PiSessionService {
private readonly activities = new Map<string, { phase: "active" | "idle" | "error"; label: string; detail?: string; at: string }>(); private readonly activities = new Map<string, { phase: "active" | "idle" | "error"; label: string; detail?: string; at: string }>();
private readonly heartbeat: NodeJS.Timeout; private readonly heartbeat: NodeJS.Timeout;
private readonly commandService: SessionCommandService; private readonly commandService: SessionCommandService;
private readonly archiveStore = new SessionArchiveStore();
private readonly agentDir = getAgentDir(); private readonly agentDir = getAgentDir();
private readonly authStorage = AuthStorage.create(); private readonly authStorage = AuthStorage.create();
private readonly modelRegistry = ModelRegistry.create(this.authStorage); private readonly modelRegistry = ModelRegistry.create(this.authStorage);
@@ -46,17 +48,22 @@ export class PiSessionService {
} }
async list(cwd: string): Promise<ClientSession[]> { async list(cwd: string): Promise<ClientSession[]> {
const sessions = await SessionManager.list(cwd); const [sessions, archivedRecords] = await Promise.all([SessionManager.list(cwd), this.archiveStore.list()]);
return sessions.map((s) => ({ const archivedById = new Map(archivedRecords.filter((record) => record.cwd === cwd).map((record) => [record.sessionId, record]));
id: s.id, return sessions.map((s) => {
path: s.path, const archived = archivedById.get(s.id);
cwd: s.cwd, return {
...(s.name === undefined ? {} : { name: s.name }), id: s.id,
created: s.created.toISOString(), path: s.path,
modified: s.modified.toISOString(), cwd: s.cwd,
messageCount: s.messageCount, ...(s.name === undefined ? {} : { name: s.name }),
firstMessage: s.firstMessage, created: s.created.toISOString(),
})); modified: s.modified.toISOString(),
messageCount: s.messageCount,
firstMessage: s.firstMessage,
...(archived === undefined ? {} : { archived: true, archivedAt: archived.archivedAt }),
};
});
} }
async start(cwd: string): Promise<ClientSession> { async start(cwd: string): Promise<ClientSession> {
@@ -104,6 +111,7 @@ export class PiSessionService {
} }
async prompt(sessionId: string, text: string, streamingBehavior?: "steer" | "followUp"): Promise<void> { async prompt(sessionId: string, text: string, streamingBehavior?: "steer" | "followUp"): Promise<void> {
await this.assertWritable(sessionId);
const session = await this.getOrOpen(sessionId); const session = await this.getOrOpen(sessionId);
const behavior = session.isStreaming || session.isCompacting ? streamingBehavior ?? "followUp" : undefined; const behavior = session.isStreaming || session.isCompacting ? streamingBehavior ?? "followUp" : undefined;
this.publishActivity(session, session.isCompacting ? "message queued during compaction" : behavior === "steer" ? "steering queued" : behavior === "followUp" ? "message queued" : "prompt accepted", "active"); this.publishActivity(session, session.isCompacting ? "message queued during compaction" : behavior === "steer" ? "steering queued" : behavior === "followUp" ? "message queued" : "prompt accepted", "active");
@@ -115,6 +123,7 @@ export class PiSessionService {
} }
async shell(sessionId: string, text: string): Promise<void> { async shell(sessionId: string, text: string): Promise<void> {
await this.assertWritable(sessionId);
const active = await this.getActive(sessionId); const active = await this.getActive(sessionId);
const { session } = active.runtime; const { session } = active.runtime;
const isExcluded = text.startsWith("!!"); const isExcluded = text.startsWith("!!");
@@ -149,13 +158,26 @@ export class PiSessionService {
} }
async runCommand(sessionId: string, text: string): Promise<ClientCommandResult> { async runCommand(sessionId: string, text: string): Promise<ClientCommandResult> {
await this.assertWritable(sessionId);
return this.commandService.run(sessionId, text); return this.commandService.run(sessionId, text);
} }
async respondToCommand(sessionId: string, requestId: string, value: string): Promise<ClientCommandResult> { async respondToCommand(sessionId: string, requestId: string, value: string): Promise<ClientCommandResult> {
await this.assertWritable(sessionId);
return this.commandService.respond(sessionId, requestId, value); return this.commandService.respond(sessionId, requestId, value);
} }
async archive(sessionId: string): Promise<void> {
const session = await this.getOrOpen(sessionId);
if (session.isStreaming || session.isCompacting || session.isBashRunning || session.pendingMessageCount > 0) throw new Error("Stop current session activity before archiving");
await this.archiveStore.archive(sessionId, session.sessionManager.getCwd());
this.stop(sessionId);
}
async restore(sessionId: string): Promise<void> {
await this.archiveStore.restore(sessionId);
}
async abort(sessionId: string): Promise<void> { async abort(sessionId: string): Promise<void> {
const active = this.active.get(sessionId); const active = this.active.get(sessionId);
if (active) await active.runtime.session.abort(); if (active) await active.runtime.session.abort();
@@ -170,6 +192,10 @@ export class PiSessionService {
this.activities.delete(sessionId); this.activities.delete(sessionId);
} }
private async assertWritable(sessionId: string): Promise<void> {
if (await this.archiveStore.isArchived(sessionId)) throw new Error("Archived sessions are read-only. Restore the session to continue.");
}
private async getOrOpen(sessionId: string): Promise<AgentSession> { private async getOrOpen(sessionId: string): Promise<AgentSession> {
return (await this.getActive(sessionId)).runtime.session; return (await this.getActive(sessionId)).runtime.session;
} }
@@ -0,0 +1,79 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { homedir } from "node:os";
export interface ArchivedSessionRecord {
sessionId: string;
cwd: string;
archivedAt: string;
}
interface ArchiveFile {
sessions: ArchivedSessionRecord[];
}
export class SessionArchiveStore {
constructor(private readonly filePath = join(homedir(), ".pi-web", "archived-sessions.json")) {}
async list(): Promise<ArchivedSessionRecord[]> {
return (await this.read()).sessions;
}
async archive(sessionId: string, cwd: string): Promise<ArchivedSessionRecord> {
const data = await this.read();
const existing = data.sessions.find((session) => session.sessionId === sessionId);
if (existing !== undefined) return existing;
const record = { sessionId, cwd, archivedAt: new Date().toISOString() };
data.sessions.push(record);
await this.write(data);
return record;
}
async restore(sessionId: string): Promise<void> {
const data = await this.read();
const sessions = data.sessions.filter((session) => session.sessionId !== sessionId);
if (sessions.length === data.sessions.length) return;
await this.write({ sessions });
}
async isArchived(sessionId: string): Promise<boolean> {
return (await this.list()).some((session) => session.sessionId === sessionId);
}
private async read(): Promise<ArchiveFile> {
try {
const value: unknown = JSON.parse(await readFile(this.filePath, "utf8"));
return parseArchiveFile(value);
} catch (error: unknown) {
if (isNodeErrorWithCode(error, "ENOENT")) return { sessions: [] };
throw error;
}
}
private async write(data: ArchiveFile): Promise<void> {
await mkdir(dirname(this.filePath), { recursive: true });
await writeFile(this.filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
}
}
function parseArchiveFile(value: unknown): ArchiveFile {
if (!isRecord(value) || !Array.isArray(value["sessions"])) throw new Error("Invalid archive file");
return { sessions: value["sessions"].map(parseArchivedSessionRecord) };
}
function parseArchivedSessionRecord(value: unknown): ArchivedSessionRecord {
if (!isRecord(value)) throw new Error("Invalid archived session record");
const sessionId = value["sessionId"];
const cwd = value["cwd"];
const archivedAt = value["archivedAt"];
if (typeof sessionId !== "string" || typeof cwd !== "string" || typeof archivedAt !== "string") throw new Error("Invalid archived session record");
return { sessionId, cwd, archivedAt };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && error.code === code;
}
+18
View File
@@ -85,6 +85,24 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
return { stopped: true }; return { stopped: true };
}); });
app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/archive`, async (request, reply) => {
try {
await sessions.archive(request.params.sessionId);
return { archived: true };
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/restore`, async (request, reply) => {
try {
await sessions.restore(request.params.sessionId);
return { restored: true };
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/events`, { websocket: true }, (socket, request) => { app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/events`, { websocket: true }, (socket, request) => {
eventHub.add(request.params.sessionId, socket); eventHub.add(request.params.sessionId, socket);
}); });
+2
View File
@@ -24,6 +24,8 @@ export interface ClientSession {
modified: string; modified: string;
messageCount: number; messageCount: number;
firstMessage: string; firstMessage: string;
archived?: boolean;
archivedAt?: string;
} }
export interface ClientMessagePage { export interface ClientMessagePage {