Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion server/session/dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ export interface SessionInfoDto {
firstMessage?: string;
created: string;
modified: string;
messageCount: number;
/** Exact for live sessions; omitted for cold sessions because deriving it requires a transcript parse. */
messageCount?: number;
cwd: string;
isCurrent: false;
}
Expand Down
73 changes: 48 additions & 25 deletions server/session/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
SessionServiceEvent,
SlashCommandDto,
} from "./dto.js";
import { shallowListSessions, shallowSessionCwd } from "./shallowList.js";
import {
conversationTreeForSession,
getSessionSlashCommands,
Expand Down Expand Up @@ -225,7 +226,7 @@ export class LocalSessionService implements SessionService {
async cwdForSessionId(id: string) {
const live = this.liveById.get(id);
if (live) return this.sessionCwd(live);
const info = await this.findSessionInfoById(id);
const info = await this.findSessionLocationById(id);
if (!info) throw new SessionServiceError("Session not found", 404);
return info.cwd || this.deps.globalCwd();
}
Expand Down Expand Up @@ -427,10 +428,20 @@ export class LocalSessionService implements SessionService {
const request = (async () => {
const groups = await Promise.all(orderedCwds.map(async (cwd) => {
try {
const infos = this.deps.sessionFactory?.list
? await this.deps.sessionFactory.list(cwd)
: await SessionManager.list(cwd);
return infos.map((info) => this.simplifySessionInfo(info, cwd));
if (this.deps.sessionFactory?.list) {
const infos = await this.deps.sessionFactory.list(cwd);
return infos.map((info) => this.simplifySessionInfo(info, cwd));
}
return (await shallowListSessions(cwd, this.defaultSessionDir(cwd))).map((info) => {
this.rememberSessionLocation(info, cwd);
const live = this.liveById.get(info.id);
return live ? {
...info,
name: live.getSessionName?.() || info.name,
messageCount: live.messages.length,
cwd: this.sessionCwd(live),
} : info;
});
} catch { return []; }
}));
return groups.flat().sort((a, b) => Date.parse(b.modified) - Date.parse(a.modified));
Expand All @@ -456,7 +467,7 @@ export class LocalSessionService implements SessionService {

async delete(sessionId: string, cwd?: string): Promise<DeleteSessionResultDto> {
if (this.noSession) throw new Error("Sessions are disabled.");
const info = await this.findSessionInfoById(sessionId, cwd);
const info = await this.findSessionLocationById(sessionId, cwd);
if (!info) throw new SessionServiceError("Session not found", 404);
const live = this.liveSessions.get(info.path);
if (live?.session.isStreaming || live?.session.isCompacting) throw new SessionServiceError("Wait for the session to finish before deleting it.", 409);
Expand Down Expand Up @@ -814,26 +825,14 @@ export class LocalSessionService implements SessionService {
});
}

private async findSessionInfoById(id: string, cwd?: string) {
private async findSessionLocationById(id: string, cwd?: string): Promise<{ id: string; path: string; cwd: string } | undefined> {
if (!id || this.noSession) return undefined;
if (this.deps.sessionFactory?.list) {
const infos = await this.deps.sessionFactory.list(cwd || this.deps.globalCwd());
return infos.find((info) => info.id === id);
const info = (await this.deps.sessionFactory.list(cwd || this.deps.globalCwd())).find((item) => item.id === id);
return info ? { id, path: info.path, cwd: info.cwd || cwd || this.deps.globalCwd() } : undefined;
}
if (cwd?.trim()) {
const resolvedCwd = resolve(cwd);
const info = (await SessionManager.list(resolvedCwd)).find((item) => item.id === id);
if (info?.cwd) this.knownSessionCwds.add(resolve(info.cwd));
if (info) return info;
}
for (const knownCwd of this.knownCwds()) {
const info = (await SessionManager.list(knownCwd)).find((item) => item.id === id);
if (info?.cwd) this.knownSessionCwds.add(resolve(info.cwd));
if (info) return info;
}
const info = (await SessionManager.listAll()).find((item) => item.id === id);
if (info?.cwd) this.knownSessionCwds.add(resolve(info.cwd));
return info;
const location = await this.resolveSessionLocation(id, cwd);
return location ? { id, ...location } : undefined;
}

private defaultSessionDir(cwd: string) {
Expand All @@ -849,16 +848,40 @@ export class LocalSessionService implements SessionService {
return info ? this.sessionLocations.get(id) : undefined;
}
const suffix = `_${id}.jsonl`;
const checkedDirectories = new Set<string>();
for (const resolvedCwd of new Set([resolve(cwd), ...this.knownCwds()])) {
const directory = this.defaultSessionDir(resolvedCwd);
checkedDirectories.add(directory);
let names: string[];
try { names = await readdir(this.defaultSessionDir(resolvedCwd)); } catch { continue; }
try { names = await readdir(directory); } catch { continue; }
const name = names.find((entry) => entry.endsWith(suffix));
if (!name) continue;
const location = { path: join(this.defaultSessionDir(resolvedCwd), name), cwd: resolvedCwd };
const location = { path: join(directory, name), cwd: resolvedCwd };
this.sessionLocations.set(id, location);
this.knownSessionCwds.add(resolvedCwd);
return location;
}

// Bookmarked IDs may be opened before their cwd has been visited in this process.
// Scan directory names and filenames only, then read the one matching header.
const sessionsRoot = join(getAgentDir(), "sessions");
let directories: string[];
try { directories = await readdir(sessionsRoot); } catch { return undefined; }
for (const directoryName of directories) {
const directory = join(sessionsRoot, directoryName);
if (checkedDirectories.has(directory)) continue;
let names: string[];
try { names = await readdir(directory); } catch { continue; }
const name = names.find((entry) => entry.endsWith(suffix));
if (!name) continue;
const path = join(directory, name);
const sessionCwd = await shallowSessionCwd(path);
if (!sessionCwd || this.defaultSessionDir(sessionCwd) !== directory) continue;
const location = { path, cwd: resolve(sessionCwd) };
this.sessionLocations.set(id, location);
this.knownSessionCwds.add(location.cwd);
return location;
}
return undefined;
}

Expand Down
105 changes: 105 additions & 0 deletions server/session/shallowList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { open, readdir, stat } from "node:fs/promises";
import { join } from "node:path";
import type { SessionInfoDto } from "./dto.js";

const HEAD_BYTES = 32 * 1024;
const TAIL_BYTES = 8 * 1024;

function parseLines(text: string) {
const entries: any[] = [];
for (const line of text.split("\n")) {
if (!line.trim()) continue;
try { entries.push(JSON.parse(line)); } catch { /* a bounded read may end mid-entry */ }
}
return entries;
}

function textContent(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content.flatMap((part: any) => part?.type === "text" && typeof part.text === "string" ? [part.text] : []).join("\n");
}

function filenameMetadata(name: string) {
const match = name.match(/^(.+)_([^_]+)\.jsonl$/);
if (!match) return undefined;
const encoded = match[1];
const iso = encoded.replace(
/^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/,
"$1T$2:$3:$4.$5Z",
);
const created = new Date(iso);
if (Number.isNaN(created.getTime())) return undefined;
return { id: match[2], created };
}

export interface ShallowListMetrics { files: number; bytesRead: number }

async function boundedContents(path: string, size: number, metrics?: ShallowListMetrics) {
const handle = await open(path, "r");
try {
const headSize = Math.min(size, HEAD_BYTES);
const head = Buffer.allocUnsafe(headSize);
const { bytesRead: headRead } = await handle.read(head, 0, headSize, 0);
if (metrics) metrics.bytesRead += headRead;
let text = head.subarray(0, headRead).toString("utf8");
if (size > HEAD_BYTES) {
const tailSize = Math.min(size - HEAD_BYTES, TAIL_BYTES);
const tail = Buffer.allocUnsafe(tailSize);
const position = size - tailSize;
const { bytesRead: tailRead } = await handle.read(tail, 0, tailSize, position);
if (metrics) metrics.bytesRead += tailRead;
// Deliberately drop any entry straddling the head/tail boundary, including
// contiguous 32–40 KiB reads; bounded metadata projection tolerates that loss.
const tailText = tail.subarray(0, tailRead).toString("utf8");
text += `\n${tailText.slice(Math.max(0, tailText.indexOf("\n") + 1))}`;
}
return text;
} finally { await handle.close(); }
}

export async function shallowSessionCwd(path: string): Promise<string | undefined> {
try {
const fileStat = await stat(path);
const header = parseLines(await boundedContents(path, fileStat.size)).find((entry) => entry?.type === "session");
return typeof header?.cwd === "string" && header.cwd ? header.cwd : undefined;
} catch { return undefined; }
}

/** A bounded projection of pi's append-only JSONL. It never reads transcript bodies. */
export async function shallowListSessions(cwd: string, directory: string, metrics?: ShallowListMetrics): Promise<SessionInfoDto[]> {
let names: string[];
try { names = await readdir(directory); } catch { return []; }
return (await Promise.all(names.filter((name) => name.endsWith(".jsonl")).map(async (name) => {
const metadata = filenameMetadata(name);
if (!metadata) return undefined;
const path = join(directory, name);
try {
const fileStat = await stat(path);
if (!fileStat.isFile()) return undefined;
if (metrics) metrics.files += 1;
const entries = parseLines(await boundedContents(path, fileStat.size, metrics));
const header = entries.find((entry) => entry?.type === "session");
if (header?.id && header.id !== metadata.id) return undefined;
let sessionName: string | undefined;
let firstMessage: string | undefined;
for (const entry of entries) {
if (entry?.type === "session_info") sessionName = typeof entry.name === "string" && entry.name ? entry.name : undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve renames that fall outside the bounded windows

For a cold session renamed after its first 32 KiB and followed by more than 8 KiB of transcript, the authoritative session_info entry is in the unread middle of the file. This loop consequently reports an older head-window name or no name at all, so a renamed session reverts in the drawer after restart; the latest name needs storage or indexing that remains accessible without parsing the transcript body.

Useful? React with 👍 / 👎.

if (!firstMessage && entry?.type === "message" && entry.message?.role === "user") {
firstMessage = textContent(entry.message.content).replace(/\s+/g, " ").trim() || undefined;
}
}
const result: SessionInfoDto = {
id: metadata.id,
path,
name: sessionName,
firstMessage,
created: metadata.created.toISOString(),
modified: fileStat.mtime.toISOString(),
cwd: typeof header?.cwd === "string" && header.cwd ? header.cwd : cwd,
isCurrent: false as const,
};
return result;
} catch { return undefined; }
}))).filter((value): value is SessionInfoDto => value !== undefined);
}
2 changes: 1 addition & 1 deletion src/app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ export type SessionInfo = {
firstMessage?: string;
created: string;
modified: string;
messageCount: number;
messageCount?: number;
cwd?: string;
isCurrent: boolean;
runtime?: {
Expand Down
22 changes: 8 additions & 14 deletions src/realtime/realtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import type { ConversationTreeController } from "../tree/conversationTree.js";
import { renderWebFooters } from "../extensions/webFooter.js";
import { assistantErrorBody, normalizeAssistantError } from "../messages/content.js";

export function shouldRefreshSessionsForPiEvent(event: PiEvent | undefined) {
return event?.type === "message_end";
}

export type RealtimeController = {
connect: () => void;
handlePiEvent: (event: PiEvent) => void;
Expand Down Expand Up @@ -83,18 +87,6 @@ export function createRealtime(options: {
return Boolean(event?.willRetry);
}

function shouldRefreshSessionsForPiEvent(event: PiEvent | undefined) {
switch (event?.type) {
case "session_info_changed":
case "message_end":
case "agent_end":
case "compaction_end":
return true;
default:
return false;
}
}

function noteRuntimeEvent(sessionKey: string, event: PiEvent | undefined) {
if (!sessionKey) return;
switch (event?.type) {
Expand Down Expand Up @@ -751,7 +743,7 @@ export function createRealtime(options: {
return;
}
if (data.type === "session_deleted") {
if (!isReplay) scheduleSessionRefresh();
if (!isReplay) sessions.removeSession(String(data.sessionId || ""));
return;
}
if (data.type === "session_runtime_changed") {
Expand Down Expand Up @@ -848,7 +840,9 @@ export function createRealtime(options: {
if (data.type === "pi_event") {
const eventSessionKey = String(data.sessionId || data.sessionFile || "");
noteRuntimeEvent(eventSessionKey, data.event);
if (!isReplay && shouldRefreshSessionsForPiEvent(data.event)) scheduleSessionRefresh();
if (!isReplay && data.event?.type === "session_info_changed") {
sessions.updateSessionName(String(data.sessionId || ""), String(data.event.name || ""));
} else if (!isReplay && shouldRefreshSessionsForPiEvent(data.event)) scheduleSessionRefresh();
if (data.sessionId) {
if (data.event?.type === "agent_start") {
sessions.updateSessionRuntime(String(data.sessionId), { loaded: true, isRunning: true, isStreaming: true, isRetrying: false, isCompacting: false, pendingMessageCount: 0 });
Expand Down
40 changes: 38 additions & 2 deletions src/sessions/sessionDrawer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,21 @@ import type { RightPanelHandle, RightPanelManager } from "../layout/rightPanel.j
import type { AppState, SessionInfo, SessionMarkerColorId, SessionUiState } from "../app/types.js";
import { defaultSessionUiState, normalizeSessionUiState, persistCollapsedSessionFolders, sessionFolderPreviewLimit, sessionMarkerColors, writeActiveSessionIdToUrl } from "../app/types.js";

export async function fetchSessionList(url: string, headers: HeadersInit, timeoutMs = 15_000) {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
try { return await fetch(url, { headers, signal: controller.signal }); }
finally { window.clearTimeout(timeout); }
}

export type SessionsController = {
init: () => void;
refreshSessions: () => Promise<void>;
setSessionDrawerOpen: (open: boolean) => void;
startNewSession: (cwd?: string) => Promise<void>;
updateSessionRuntime: (sessionId: string, runtime: SessionInfo["runtime"]) => void;
updateSessionName: (sessionId: string, name: string) => void;
removeSession: (sessionId: string) => void;
beginTranscriptLoading: () => void;
updateEmptyCwdChooser: () => void;
finishTranscriptLoading: () => void;
Expand Down Expand Up @@ -411,7 +420,7 @@ export function createSessions(options: {
const params = new URLSearchParams();
for (const cwd of readKnownSessionCwds()) params.append("cwd", cwd);
const url = params.toString() ? `/api/sessions?${params}` : "/api/sessions";
const res = await fetch(url, { headers: api.headers() });
const res = await fetchSessionList(url, api.headers());
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
cachedSessions = (data.sessions || []).map((item: SessionInfo) => ({ ...item, isCurrent: item.id === state.currentSessionId }));
Expand Down Expand Up @@ -450,6 +459,29 @@ export function createSessions(options: {
renderSessionBar();
}

function updateSessionName(sessionId: string, name: string) {
if (!sessionId) return;
let changed = false;
cachedSessions = cachedSessions.map((session) => {
if (session.id !== sessionId) return session;
changed = true;
return { ...session, name: name || undefined };
});
if (state.sessionsById[sessionId]) state.sessionsById[sessionId].name = name || undefined;
if (changed && !elements.sessionDrawer.hidden) renderSessionList(cachedSessions);
renderSessionBar();
}

function removeSession(sessionId: string) {
if (!sessionId) return;
cachedSessions = cachedSessions.filter((session) => session.id !== sessionId);
delete state.sessionsById[sessionId];
pinnedRuntimes.delete(sessionId);
if (!elements.sessionDrawer.hidden) renderSessionList(cachedSessions);
renderSessionBar();
updateSessionButtonUnread();
}

function updateSessionRuntime(sessionId: string, runtime: SessionInfo["runtime"]) {
if (!sessionId) return;
// Always cache runtime for pinned sessions — this lets renderSessionBar show
Expand Down Expand Up @@ -1884,7 +1916,9 @@ export function createSessions(options: {

const meta = document.createElement("span");
meta.className = "sessionItemMeta";
meta.textContent = `${formatRelativeTime(item.modified)} · ${item.messageCount}`;
meta.textContent = item.messageCount === undefined
? formatRelativeTime(item.modified)
: `${formatRelativeTime(item.modified)} · ${item.messageCount}`;

navBtn.append(titleRow, meta);
navBtn.addEventListener("click", async () => {
Expand Down Expand Up @@ -2038,6 +2072,8 @@ export function createSessions(options: {
updateEmptyCwdChooser,
finishTranscriptLoading,
updateSessionRuntime,
updateSessionName,
removeSession,
renderSessionBar,
renderCurrentSessionBucketButton,
applySessionUiState,
Expand Down
1 change: 0 additions & 1 deletion src/status/statusBar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ export function createStatusBar(options: {
const data = text ? JSON.parse(text) : {};
if (!res.ok || data.ok === false) throw new Error(data.error || text);
updateMeta(data);
if (!elements.sessionDrawer.hidden) refreshSessions().catch(() => undefined);
} catch (error) {
setStatusTitle(previous);
addMessage("system", error instanceof Error ? error.message : String(error), "error");
Expand Down
Loading