import { closeSync, existsSync, fsyncSync, openSync, writeFileSync } from "node:fs"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { SessionEntry } from "@earendil-works/pi-coding-agent"; import { buildSessionProjection, SessionManager } from "@earendil-works/pi-coding-agent"; import type { ForkTurns } from "./types.ts"; const INHERITED_COMPACTION_CUSTOM_TYPE = "pi-subagent/inherited-compaction-summary"; const INHERITED_BRANCH_CUSTOM_TYPE = "pi-subagent/inherited-branch-summary"; const COMPACTION_SUMMARY_PREFIX = "The conversation history before this point was compacted into the following summary:\n\n\n"; const COMPACTION_SUMMARY_SUFFIX = "\n"; const BRANCH_SUMMARY_PREFIX = "The following is a summary of a branch that this conversation came back from:\n\n\n"; const BRANCH_SUMMARY_SUFFIX = ""; export interface SessionView { getBranch(): SessionEntry[]; getCwd(): string; getSessionDir(): string; getSessionFile(): string | undefined; getSessionId(): string; getEntries(): SessionEntry[]; buildContextEntries(): SessionEntry[]; appendCustomEntry(customType: string, data?: unknown): string; } interface CompletedTurnSpan { start: number; end: number; } function isSummaryEntry(entry: SessionEntry): boolean { return ( entry.type === "compaction" || entry.type === "branch_summary" || ( entry.type === "message" && ( entry.message.role === "compactionSummary" || entry.message.role === "branchSummary" ) ) ); } function startsModelTurn(entry: SessionEntry): boolean { if (entry.type === "custom_message") return true; if (entry.type !== "message") return false; switch (entry.message.role) { case "user": case "custom": return true; case "bashExecution": return !entry.message.excludeFromContext; default: return false; } } function completedTurnSpans( entries: readonly SessionEntry[], ): { turns: CompletedTurnSpan[]; latestSummary?: number; } { const turns: CompletedTurnSpan[] = []; let latestSummary: number | undefined; let currentStart: number | undefined; let currentEnd: number | undefined; const finishCurrent = () => { if (currentStart !== undefined && currentEnd !== undefined) { turns.push({ start: currentStart, end: currentEnd }); } currentStart = undefined; currentEnd = undefined; }; for (let index = 0; index < entries.length; index++) { const entry = entries[index]!; if (isSummaryEntry(entry)) { finishCurrent(); latestSummary = index; continue; } if (startsModelTurn(entry)) { finishCurrent(); currentStart = index; continue; } if (entry.type !== "message") continue; if ( currentStart === undefined && ( entry.message.role === "assistant" || entry.message.role === "toolResult" ) ) { currentStart = latestSummary ?? 0; } if ( entry.message.role === "assistant" && entry.message.stopReason !== "toolUse" ) { currentEnd = index; } } finishCurrent(); return { turns, ...(latestSummary !== undefined ? { latestSummary } : {}), }; } export function completedContextEntries( entries: readonly SessionEntry[], ): SessionEntry[] { const { turns, latestSummary } = completedTurnSpans(entries); const end = Math.max( turns.at(-1)?.end ?? -1, latestSummary ?? -1, ); if (end < 0) return []; return entries .slice(0, end + 1) .map((entry) => structuredClone(entry)); } function appendInheritedEntry( session: SessionManager, entry: SessionEntry, ): void { if (entry.type === "message") { // Children inherit conversation data, not the parent's prompt or executable loadout. if (entry.message.role === "system") return; if (entry.message.role === "compactionSummary") { session.appendCustomMessageEntry( INHERITED_COMPACTION_CUSTOM_TYPE, `${COMPACTION_SUMMARY_PREFIX}${entry.message.summary}${COMPACTION_SUMMARY_SUFFIX}`, false, { tokensBefore: entry.message.tokensBefore }, ); return; } if (entry.message.role === "branchSummary") { session.appendCustomMessageEntry( INHERITED_BRANCH_CUSTOM_TYPE, `${BRANCH_SUMMARY_PREFIX}${entry.message.summary}${BRANCH_SUMMARY_SUFFIX}`, false, { fromId: entry.message.fromId }, ); return; } session.appendMessage( structuredClone( entry.message, ) as Parameters[0], ); return; } if (entry.type === "custom_message") { session.appendCustomMessageEntry( entry.customType, structuredClone(entry.content), entry.display, structuredClone(entry.details), ); return; } if (entry.type === "compaction") { session.appendCustomMessageEntry( INHERITED_COMPACTION_CUSTOM_TYPE, `${COMPACTION_SUMMARY_PREFIX}${entry.summary}${COMPACTION_SUMMARY_SUFFIX}`, false, { tokensBefore: entry.tokensBefore }, ); return; } if (entry.type === "branch_summary") { session.appendCustomMessageEntry( INHERITED_BRANCH_CUSTOM_TYPE, `${BRANCH_SUMMARY_PREFIX}${entry.summary}${BRANCH_SUMMARY_SUFFIX}`, false, { fromId: entry.fromId }, ); } } export function prepareChildSession( parent: SessionView, sessionDir: string, forkTurns: ForkTurns, ): SessionManager { const parentFile = parent.getSessionFile(); if (!parentFile) throw new Error("spawn_agent requires a persisted parent session"); const session = SessionManager.create(parent.getCwd(), sessionDir, { parentSession: parentFile }); if (forkTurns === "none") return session; const inherited = completedContextEntries( buildSessionProjection(parent.getBranch()).entries.flatMap( ({ sourceEntry, messages }) => messages.map((message): SessionEntry => ({ type: "message", id: sourceEntry.id, parentId: sourceEntry.parentId, timestamp: sourceEntry.timestamp, message, })), ), ); for (const entry of inherited) { appendInheritedEntry(session, entry); } return session; } /** * Pi normally defers setup-only JSONL writes. Materialize the documented file * format before publishing an agent, then reopen it so Pi owns future writes. * No SDK private fields or fabricated conversation messages are used. */ export function persistPreparedSession(session: SessionManager): SessionManager { const path = session.getSessionFile(); if (!path) throw new Error("child session must be persistent"); if (!existsSync(path)) { const fd = openSync(path, "wx", 0o600); try { writeFileSync(fd, [session.getHeader(), ...session.getEntries()].map(e => JSON.stringify(e)).join("\n") + "\n"); fsyncSync(fd); } finally { closeSync(fd); } } else { const fd = openSync(path, "r"); try { fsyncSync(fd); } finally { closeSync(fd); } } return SessionManager.open(path); } export class SelectionPersistenceError extends Error { constructor(cause: unknown) { super("failed to persist child execution profile selection", { cause }); this.name = "SelectionPersistenceError"; } } /** Explicit SDK options select the model but do not record switches on existing * sessions. Keep Pi's selection records aligned without rebuilding history or * treating a virtual model's physical assistant response as its selection. */ export function recordChildSelection( session: SessionManager, model: { provider: string; id: string }, thinking: ThinkingLevel, ): void { try { const branch = session.getBranch().reverse(); const selected = branch.find(entry => entry.type === "model_change"); if (!selected || selected.provider !== model.provider || selected.modelId !== model.id) { session.appendModelChange(model.provider, model.id); } const level = branch.find(entry => entry.type === "thinking_level_change"); if (!level || level.thinkingLevel !== thinking) session.appendThinkingLevelChange(thinking); persistPreparedSession(session); } catch (cause) { throw new SelectionPersistenceError(cause); } }