/**
* Subagent session discovery shared by `/export` (HTML) and `/dump all` (zip of text dumps).
*
* A session at `
/.jsonl` keeps its subagent sessions at `//.jsonl`;
* each subagent's own children nest the same way under `///`. Advisor
* transcripts (`__advisor*.jsonl`) share those directories and are only collected when
* requested. Read-only exports can also include gc-compressed `.jsonl.gz` transcripts.
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { isAdvisorTranscriptName } from "../advisor/transcript-recorder";
import { getAgentTombstonePath } from "../registry/agent-tombstone";
import { resolveSessionLeafId, type SessionEntry, type SessionHeader } from "./session-entries";
import { loadSessionFile, parseSessionContent, type SessionLoadResult } from "./session-loader";
const utf8Decoder = new TextDecoder();
/** Persisted child transcript (subagent or opted-in advisor), keyed by slash-joined path. */
export interface SubSession {
/** Transcript stem, e.g. "ToolAsk" or "__advisor.reviewer". */
agentId: string;
/** Key of the parent sub-session, or null when spawned by the main session. */
parent: string | null;
header: SessionHeader | null;
entries: SessionEntry[];
leafId: string | null;
/** The subagent was explicitly killed (a tombstone sidecar sits next to its transcript). */
aborted: boolean;
/** JSONL records skipped as malformed while loading this transcript. */
malformedRecords: number;
}
/** Receives the key of a subagent transcript that exists but has no readable session header. */
export type UnreadableSubSessionHandler = (key: string) => void;
/** Read-only discovery scope and malformed-transcript reporting for child collectors. */
export interface CollectSubSessionsOptions {
onUnreadable?: UnreadableSubSessionHandler;
/** Include advisor transcripts for read-only exports, never task restoration. */
includeAdvisors?: boolean;
/** Include gc-compressed transcripts for read-only exports. */
includeCompressed?: boolean;
}
/**
* Collect subagent session transcripts stored next to a session file.
*
* Keys in the returned record are slash-joined ids relative to the main session
* ("ToolAsk", "ToolAsk/Helper"). Empty files are skipped silently; corrupt ones
* are skipped and reported to `onUnreadable`. Advisors and compressed transcripts
* are opt-in, so task restoration and text dumps retain their subagent-only scope.
*/
export async function collectSubSessions(
sessionFile: string,
options: CollectSubSessionsOptions = {},
): Promise> {
const result: Record = {};
const transcriptName =
options.includeCompressed && sessionFile.endsWith(".jsonl.gz") ? sessionFile.slice(0, -3) : sessionFile;
if (!transcriptName.endsWith(".jsonl")) return result;
await collectSubSessionsFromDir(transcriptName.slice(0, -6), null, result, options);
return result;
}
/** Decompress archives before handing their JSONL to the central session parser. */
async function loadCollectedSession(file: string): Promise {
if (!file.endsWith(".jsonl.gz")) return loadSessionFile(file);
try {
return parseSessionContent(utf8Decoder.decode(Bun.gunzipSync(await Bun.file(file).bytes())));
} catch (err) {
// Match the plain loader when gc removes a transcript after discovery.
if (isEnoent(err)) return null;
throw err;
}
}
async function collectSubSessionsFromDir(
dir: string,
parentKey: string | null,
out: Record,
options: CollectSubSessionsOptions,
): Promise {
let dirents: fs.Dirent[];
try {
dirents = await fs.promises.readdir(dir, { withFileTypes: true });
} catch (err) {
if (isEnoent(err) || (err as NodeJS.ErrnoException).code === "ENOTDIR") return;
throw err;
}
const fileNames = new Set();
const childDirectories = new Set();
for (const dirent of dirents) {
if (dirent.isFile()) fileNames.add(dirent.name);
else if (dirent.isDirectory()) childDirectories.add(dirent.name);
}
for (const name of [...fileNames].sort()) {
const compressed = name.endsWith(".jsonl.gz");
if (compressed && !options.includeCompressed) continue;
const transcriptName = compressed ? name.slice(0, -3) : name;
if (
!transcriptName.endsWith(".jsonl") ||
name.includes(".bak") ||
(!options.includeAdvisors && isAdvisorTranscriptName(transcriptName))
)
continue;
// A live transcript wins over an archive of the same session during gc.
if (compressed && fileNames.has(transcriptName)) continue;
const agentId = transcriptName.slice(0, -6);
const key = parentKey ? `${parentKey}/${agentId}` : agentId;
const loaded = await loadCollectedSession(path.join(dir, name));
if (!loaded) continue;
const { entries: fileEntries, malformedRecords, invalidHeader } = loaded;
// Empty files load as [] and are skipped; a corrupt or missing header is reported so exports do
// not look complete while omitting the transcript.
if (!invalidHeader && fileEntries.length > 0) {
const header = (fileEntries.find(e => e.type === "session") as SessionHeader | undefined) ?? null;
const entries = fileEntries.filter((e): e is SessionEntry => e.type !== "session");
out[key] = {
agentId,
parent: parentKey,
header,
entries,
leafId: resolveSessionLeafId(entries),
aborted:
fileNames.has(getAgentTombstonePath(transcriptName)) ||
(compressed && fileNames.has(getAgentTombstonePath(name))),
malformedRecords,
};
} else if (invalidHeader) {
options.onUnreadable?.(key);
}
// Only descend into real child directories: a transcript stem such as "." or ".."
// would revisit an ancestor, and symlinked directories can loop back into the tree.
if (childDirectories.has(agentId)) {
await collectSubSessionsFromDir(path.join(dir, agentId), key, out, options);
}
}
}