Read PR description, comments, commits, and changed files.
UnderstandIdentified and reviewed linked issues.
UnderstandAnalyzed PR diff and relevant source files.
UnderstandChecked documentation and extension docs.
UnderstandCompiled detailed PR review with findings and recommendations.
BuildYou are given one or more GitHub PR URLs: https://github.com/badlogic/pi-mono/pull/789/changes
For each PR URL, do the following in order:
packages/*/CHANGELOG.md files. Report whether an entry exists. If missing, state that a changelog entry is required before merge and that you will add it if the user decides to merge. Follow the changelog format rules in AGENTS.md. Verify:
### Breaking Changes, ### Added, ### Fixed, etc.)Fixed foo ([#123](https://github.com/badlogic/pi-mono/pull/123) by [@user](https://github.com/user))### Breaking Changes, not just ### FixedOutput format per PR: PR: Changelog:
If no issues are found, say so under Bad and Ugly.
The user wants me to review a GitHub PR. Let me start by fetching the PR details including description, comments, commits, and changed files.
>cd /Users/badlogic/workspaces/pi-mono && gh pr view 789 --json title,body,comments,commits,files,author,labels,state,baseRefName,headRefName{"author":{"id":"MDQ6VXNlcjI5NTgxMzM=","is_bot":false,"login":"nicobailon","name":"Nico Bailon"},"baseRefName":"main","body":"I know you are busy with other priorities and this is a non critical thing so I don't expect this to be merged, but wanted to share anyway. Feel free to close.\r\n\r\nThis PR adds core primitives for extensions to create child agent sessions in overlays. Includes SessionManager.createEmbedded(), ctx.session in extension context, embedded session ref entries, and a full example extension.\r\n\r\n<img width=\"1262\" height=\"1136\" alt=\"image\" src=\"https://github.com/user-attachments/assets/5ebf8f21-1a76-41eb-9f9d-0f6cc3ccff61\" />\r\n\r\n**Core additions (~330 lines):**\r\n\r\n- `SessionManager.createEmbedded(parentId, cwd)` - creates a session file under `~/.pi/agent/sessions/embedded/{parent-id}/`\r\n- `SessionManager.getEntriesInPath()` - returns entries from root to current leaf (needed for rendering refs correctly after branching)\r\n- `ctx.session` exposed in extension context - gives extensions access to the parent AgentSession\r\n- `EmbeddedSessionRefEntry` type and `appendEmbeddedSessionRef()` - stores a \"receipt\" in the parent session when an embedded session closes\r\n- `EmbeddedSessionRefComponent` - renders those receipts in the parent chat on session resume\r\n- `isEmbeddedSession` / `parentSessionId` in command context\r\n- Exports `theme` and `getEditorTheme` so extensions can style their overlays\r\n\r\n**Example extension (~900 lines):**\r\n\r\nFull working embedded session in overlay implementation at `examples/extensions/embedded-sessions/`.\r\n\r\n**Testing**\r\n\r\n```bash\r\n# Copy to extensions folder\r\ncp -r examples/extensions/embedded-sessions ~/.pi/agent/extensions/\r\n\r\n# Then in pi\r\n/embed What's in this codebase?\r\n```\r\n\r\n**I have manually tested:**\r\n- Basic `/embed` and `/embed-context` flows\r\n- Model switching within embedded session","comments":[],"commits":[{"authoredDate":"2026-01-16T20:36:36Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjI5NTgxMzM=","login":"nicobailon","name":"Nico Bailon"}],"committedDate":"2026-01-16T20:54:26Z","messageBody":"Adds core primitives for extensions to create child agent sessions in overlays.\nIncludes SessionManager.createEmbedded(), ctx.session in extension context,\nembedded session ref entries, and a full example extension.","messageHeadline":"feat: embedded sessions infrastructure for extensions","oid":"5f63b87d0c56abdbc73d0ee1c87da07c2f033124"}],"files":[{"path":"packages/coding-agent/examples/extensions/embedded-sessions/README.md","additions":119,"deletions":0},{"path":"packages/coding-agent/examples/extensions/embedded-sessions/embedded-session-component.ts","additions":671,"deletions":0},{"path":"packages/coding-agent/examples/extensions/embedded-sessions/index.ts","additions":131,"deletions":0},{"path":"packages/coding-agent/examples/extensions/embedded-sessions/package.json","additions":10,"deletions":0},{"path":"packages/coding-agent/examples/extensions/embedded-sessions/types.ts","additions":92,"deletions":0},{"path":"packages/coding-agent/src/core/extensions/runner.ts","additions":10,"deletions":0},{"path":"packages/coding-agent/src/core/extensions/types.ts","additions":17,"deletions":0},{"path":"packages/coding-agent/src/core/session-manager.ts","additions":188,"deletions":4},{"path":"packages/coding-agent/src/index.ts","additions":5,"deletions":0},{"path":"packages/coding-agent/src/modes/interactive/components/embedded-session-ref.ts","additions":91,"deletions":0},{"path":"packages/coding-agent/src/modes/interactive/components/index.ts","additions":1,"deletions":0},{"path":"packages/coding-agent/src/modes/interactive/interactive-mode.ts","additions":23,"deletions":0},{"path":"packages/coding-agent/src/modes/print-mode.ts","additions":1,"deletions":0},{"path":"packages/coding-agent/src/modes/rpc/rpc-mode.ts","additions":1,"deletions":0},{"path":"packages/coding-agent/test/compaction-extensions.test.ts","additions":1,"deletions":0}],"headRefName":"feat/embedded-sessions","labels":[],"state":"OPEN","title":"feat: embedded sessions infrastructure for extensions"}>cd /Users/badlogic/workspaces/pi-mono && gh pr diff 789+import type { CompactionSummaryMessage } from "@mariozechner/pi-coding-agent/core/messages.js";
+import { Container, Loader, Spacer, Text, type TUI, visibleWidth } from "@mariozechner/pi-tui";
+import type { EmbeddedSessionOptions, EmbeddedSessionResult } from "./types.js";
+
+export interface EmbeddedSessionComponentConfig {
+ tui: TUI;
+ parentSession: AgentSession;
+ options: EmbeddedSessionOptions;
+ keybindings: KeybindingsManager;
+ onClose: (result: EmbeddedSessionResult) => void;
+ getToolDefinition?: (name: string) => ToolDefinition | undefined;
+}
+
+/**
+ * Component that renders an embedded session in an overlay.
+ */
+export class EmbeddedSessionComponent extends Container {
+ private tui: TUI;
+ private parentSession: AgentSession;
+ private embeddedSession!: AgentSession;
+ private options: EmbeddedSessionOptions;
+ private keybindings: KeybindingsManager;
+ private onCloseCallback: (result: EmbeddedSessionResult) => void;
+ private getToolDefinitionFn: (name: string) => ToolDefinition | undefined;
+ private cwd: string;
+
+ // UI Components
+ private chatContainer!: Container;
+ private editor!: CustomEditor;
+ private statusLine!: Container;
+ private loadingIndicator: Loader | undefined;
+
+ // Overlay dimensions - use options if provided, otherwise defaults
+ get width(): number {
+ const opt = this.options.width;
+ if (typeof opt === "number") return opt;
+ if (typeof opt === "string" && opt.endsWith("%")) {
+ const pct = parseInt(opt, 10);
+ if (!Number.isNaN(pct) && pct > 0) {
+ return Math.floor(this.tui.terminal.columns * (pct / 100));
+ }
+ }
+ return Math.floor(this.tui.terminal.columns * 0.9);
+ }
+ get maxHeight(): number {
+ const opt = this.options.maxHeight;
+ if (typeof opt === "number") return opt;
+ if (typeof opt === "string" && opt.endsWith("%")) {
+ const pct = parseInt(opt, 10);
+ if (!Number.isNaN(pct) && pct > 0) {
+ return Math.floor(this.tui.terminal.rows * (pct / 100));
+ }
+ }
+ return Math.floor(this.tui.terminal.rows * 0.85);
+ }
+
+ // State
+ private startTime: number;
+ private filesRead = new Set<string>();
+ private filesModified = new Set<string>();
+ private closed = false;
+ private unsubscribe?: () => void;
+
+ // Streaming state
+ private streamingComponent: AssistantMessageComponent | undefined;
+ private pendingTools = new Map<string, ToolExecutionComponent>();
+ private toolArgsCache = new Map<string, Record<string, unknown>>();
+ private toolOutputExpanded = false;
+
+ private constructor(config: EmbeddedSessionComponentConfig) {
+ super();
+ this.tui = config.tui;
+ this.parentSession = config.parentSession;
+ this.options = config.options;
+ this.keybindings = config.keybindings;
+ this.onCloseCallback = config.onClose;
+ this.getToolDefinitionFn = config.getToolDefinition ?? (() => undefined);
+ this.cwd = config.parentSession.sessionManager.getCwd();
+ this.startTime = Date.now();
+ }
+
+ static async create(config: EmbeddedSessionComponentConfig): Promise<EmbeddedSessionComponent> {
+ const component = new EmbeddedSessionComponent(config);
+ await component.initialize();
+ return component;
+ }
+
+ private async initialize(): Promise<void> {
+ // 1. Create SessionManager
+ const sessionManager = this.createSessionManager();
+
+ // 2. Create tools
+ const tools = this.createTools();
+
+ // 3. Get system prompt from parent
+ const systemPrompt = this.parentSession.agent.state.systemPrompt;
+
+ // 4. Get model
+ const model = this.options.model ?? this.parentSession.model;
+ if (!model) {
+ throw new Error("Cannot create embedded session: no model specified and parent has no model");
+ }
+
+ // 5. Get initial messages
+ const initialMessages = this.buildInitialMessages();
+
+ // 6. Create Agent
+ const agent = new Agent({
+ initialState: {
+ model,
+ systemPrompt,
+ tools,
+ messages: initialMessages,
+ thinkingLevel: this.options.thinkingLevel ?? this.parentSession.thinkingLevel,
+ },
+ getApiKey: (provider) => this.parentSession.modelRegistry.getApiKeyForProvider(provider),
+ });
+
+ // 7. Create AgentSession
+ this.embeddedSession = new AgentSession({
+ agent,
+ sessionManager,
+ settingsManager: this.parentSession.settingsManager,
+ modelRegistry: this.parentSession.modelRegistry,
+ promptTemplates: [...this.parentSession.promptTemplates],
+ skills: [...this.parentSession.skills],
+ });
+
+ // 8. Build UI
+ this.buildUI();
+
+ // 9. Subscribe to events
+ this.unsubscribe = this.embeddedSession.subscribe(this.handleEvent);
+
+ // 10. Send initial message if provided
+ if (this.options.initialMessage) {
+ this.embeddedSession.prompt(this.options.initialMessage).catch((err) => {
+ this.showError(err.message);
+ });
+ }
+ }
+
+ private createSessionManager(): SessionManager {
+ const parentId = this.parentSession.sessionManager.getSessionId();
+ const cwd = this.parentSession.sessionManager.getCwd();
+
+ if (this.options.sessionFile === false) {
+ return SessionManager.inMemory(cwd);
+ }
+
+ return SessionManager.createEmbedded(parentId, cwd, {
+ sessionFile: typeof this.options.sessionFile === "string" ? this.options.sessionFile : undefined,
+ });
+ }
+
+ private createTools(): AgentTool[] {
+ if (this.options.inheritTools === false) {
+ return this.options.additionalTools ?? [];
+ }
+
+ let tools = [...this.parentSession.agent.state.tools];
+
+ if (this.options.excludeTools?.length) {
+ const excluded = new Set(this.options.excludeTools);
+ tools = tools.filter((t) => !excluded.has(t.name));
+ }
+
+ if (this.options.additionalTools?.length) {
+ tools.push(...this.options.additionalTools);
+ }
+
+ return tools;
+ }
+
+ private buildInitialMessages(): AgentMessage[] {
+ if (!this.options.includeParentContext) {
+ return [];
+ }
+
+ const depth = this.options.parentContextDepth ?? 5;
+ const parentMessages = this.parentSession.agent.state.messages;
+ const totalUserMessages = parentMessages.filter((m) => m.role === "user").length;
+ const startAfterExchange = totalUserMessages - depth;
+
+ const relevantMessages: AgentMessage[] = [];
+ let userMessageCount = 0;
+
+ for (const msg of parentMessages) {
+ if (msg.role === "user") {
+ userMessageCount++;
+ }
+ if (userMessageCount > startAfterExchange) {
+ if (msg.role === "user" || msg.role === "assistant") {
+ relevantMessages.push(JSON.parse(JSON.stringify(msg)));
+ }
+ }
+ }
+
+ return relevantMessages;
+ }
+
+ private buildUI(): void {
+ this.chatContainer = new Container();
+ this.addChild(this.chatContainer);
+
+ this.editor = new CustomEditor(this.tui, getEditorTheme(), this.keybindings);
+ this.editor.onSubmit = (text) => this.handleSubmit(text);
+ this.editor.onEscape = () => {
+ if (this.embeddedSession.isStreaming) {
+ this.embeddedSession.abort();
+ } else {
+ this.close(true);
+ }
+ };
+ this.addChild(this.editor as any);
+
+ this.statusLine = new Container();
+ this.updateStatusLine();
+ this.addChild(this.statusLine);
+ }
+
+ private updateStatusLine(): void {
+ this.statusLine.clear();
+ const stats = this.embeddedSession.getSessionStats();
+ const tokens = `${this.formatTokens(stats.tokens.input)}in/${this.formatTokens(stats.tokens.output)}out`;
+
+ const hints = [
+ theme.fg("muted", `tokens: ${tokens}`),
+ theme.fg("dim", "│"),
+ theme.fg("dim", "Enter") + theme.fg("muted", " send"),
+ theme.fg("dim", "/model") + theme.fg("muted", " switch"),
+ theme.fg("dim", "/done") + theme.fg("muted", " complete"),
+ theme.fg("dim", "Esc") + theme.fg("muted", " cancel"),
+ ].join(" ");
+
+ this.statusLine.addChild(new Text(hints, 0, 0));
+ }
+
+ private formatTokens(n: number): string {
+ return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
+ }
+
+ private showModelSelector(initialSearchInput?: string): void {
+ const selector = new ModelSelectorComponent(
+ this.tui,
+ this.embeddedSession.model,
+ this.embeddedSession.settingsManager,
+ this.embeddedSession.modelRegistry,
+ [],
+ async (model: Model<any>) => {
+ try {
+ await this.embeddedSession.setModel(model);
+ this.tui.hideOverlay();
+ this.tui.setFocus(this.editor);
+ this.updateStatusLine();
+ this.tui.requestRender();
+ } catch (error) {
+ this.tui.hideOverlay();
+ this.tui.setFocus(this.editor);
+ this.showError(error instanceof Error ? error.message : String(error));
+ }
+ },
+ () => {
+ this.tui.hideOverlay();
+ this.tui.setFocus(this.editor);
+ this.tui.requestRender();
+ },
+ initialSearchInput,
+ );
+ this.tui.showOverlay(selector, { anchor: "center" });
+ this.tui.setFocus(selector);
+ }
+
+ handleInput(data: string): void {
+ this.editor.handleInput(data);
+ }
+
+ private handleSubmit(text: string): void {
+ const trimmed = text.trim();
+ if (!trimmed) return;
+
+ this.editor.setText("");
+
+ if (trimmed === "/done" || trimmed === "/close") {
+ this.close(false);
+ return;
+ }
+
+ if (trimmed === "/compact") {
+ this.embeddedSession.compact().catch((err) => this.showError(err.message));
+ return;
+ }
+
+ if (trimmed === "/model" || trimmed.startsWith("/model ")) {
+ const searchTerm = trimmed.startsWith("/model ") ? trimmed.slice(7).trim() : undefined;
+ this.showModelSelector(searchTerm);
+ return;
+ }
+
+ const behavior = this.embeddedSession.isStreaming ? "followUp" : undefined;
+ this.embeddedSession.prompt(trimmed, { streamingBehavior: behavior }).catch((err) => {
+ this.showError(err.message);
+ });
+ }
+
+ private handleEvent = (event: AgentSessionEvent): void => {
+ switch (event.type) {
+ case "agent_start":
+ this.showLoading();
+ break;
+ case "agent_end":
+ this.hideLoading();
+ this.updateStatusLine();
+ break;
+ case "message_start":
+ if (event.message.role === "user") {
+ this.renderUserMessage(event.message);
+ } else if (event.message.role === "assistant") {
+ this.startStreamingAssistant(event.message as AssistantMessage);
+ } else if (event.message.role === "compactionSummary") {
+ this.renderCompactionSummary(event.message as CompactionSummaryMessage);
+ }
+ break;
+ case "message_update":
+ if (event.message.role === "assistant") {
+ this.updateStreamingAssistant(event.message as AssistantMessage);
+ }
+ break;
+ case "message_end":
+ if (event.message.role === "assistant") {
+ this.endStreamingAssistant(event.message as AssistantMessage);
+ }
+ break;
+ case "tool_execution_start":
+ this.handleToolStart(event);
+ break;
+ case "tool_execution_update":
+ this.handleToolUpdate(event);
+ break;
+ case "tool_execution_end":
+ this.handleToolEnd(event);
+ break;
+ }
+ this.tui.requestRender();
+ };
+
+ private renderUserMessage(message: AgentMessage): void {
+ if (message.role !== "user" || !("content" in message)) return;
+ const textContent = this.extractTextFromContent(message.content);
+ if (textContent) {
+ this.chatContainer.addChild(new UserMessageComponent(textContent));
+ this.chatContainer.addChild(new Spacer(1));
+ }
+ }
+
+ private extractTextFromContent(content: any): string {
+ if (typeof content === "string") return content;
+ return content
+ .filter((c: any) => c.type === "text")
+ .map((c: any) => c.text)
+ .join(" ");
+ }
+
+ private renderCompactionSummary(message: CompactionSummaryMessage): void {
+ this.chatContainer.addChild(new Spacer(1));
+ const component = new CompactionSummaryMessageComponent(message);
+ component.setExpanded(this.toolOutputExpanded);
+ this.chatContainer.addChild(component);
+ }
+
+ private startStreamingAssistant(message: AssistantMessage): void {
+ this.streamingComponent = new AssistantMessageComponent(
+ message,
+ this.embeddedSession.settingsManager.getHideThinkingBlock(),
+ );
+ this.chatContainer.addChild(this.streamingComponent);
+ this.renderToolCallsFromMessage(message);
+ }
+
+ private updateStreamingAssistant(message: AssistantMessage): void {
+ if (this.streamingComponent) {
+ this.streamingComponent.updateContent(message);
+ this.renderToolCallsFromMessage(message);
+ }
+ }
+
+ private endStreamingAssistant(message: AssistantMessage): void {
+ if (this.streamingComponent) {
+ this.streamingComponent.updateContent(message);
+
+ if (message.stopReason === "aborted" || message.stopReason === "error") {
+ const errorMessage =
+ message.stopReason === "aborted" ? "Operation aborted" : message.errorMessage || "Error";
+ for (const [, component] of this.pendingTools) {
+ component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true });
+ }
+ this.pendingTools.clear();
+ } else {
+ for (const [, component] of this.pendingTools) {
+ component.setArgsComplete();
+ }
+ }
+
+ this.streamingComponent = undefined;
+ this.chatContainer.addChild(new Spacer(1));
+ }
+ }
+
+ private renderToolCallsFromMessage(message: AssistantMessage): void {
+ for (const content of message.content) {
+ if (content.type === "toolCall") {
+ if (!this.pendingTools.has(content.id)) {
+ const component = new ToolExecutionComponent(
+ content.name,
+ content.arguments,
+ { showImages: this.parentSession.settingsManager.getShowImages() },
+ this.getToolDefinitionFn(content.name),
+ this.tui,
+ this.cwd,
+ );
+ component.setExpanded(this.toolOutputExpanded);
+ this.pendingTools.set(content.id, component);
+ this.chatContainer.addChild(component);
+ this.toolArgsCache.set(content.id, content.arguments as Record<string, unknown>);
+ } else {
+ const component = this.pendingTools.get(content.id);
+ if (component) {
+ component.updateArgs(content.arguments);
+ this.toolArgsCache.set(content.id, content.arguments as Record<string, unknown>);
+ }
+ }
+ }
+ }
+ }
+
+ private handleToolStart(event: { toolCallId: string; toolName: string; args: unknown }): void {
+ if (this.pendingTools.has(event.toolCallId)) return;
+
+ this.toolArgsCache.set(event.toolCallId, event.args as Record<string, unknown>);
+ const component = new ToolExecutionComponent(
+ event.toolName,
+ event.args,
+ { showImages: this.parentSession.settingsManager.getShowImages() },
+ this.getToolDefinitionFn(event.toolName),
+ this.tui,
+ this.cwd,
+ );
+ component.setExpanded(this.toolOutputExpanded);
+ this.pendingTools.set(event.toolCallId, component);
+ this.chatContainer.addChild(component);
+ }
+
+ private handleToolUpdate(event: { toolCallId: string; partialResult: unknown }): void {
+ const component = this.pendingTools.get(event.toolCallId);
+ if (component) {
+ component.updateResult(event.partialResult as any);
+ }
+ }
+
+ private handleToolEnd(event: { toolCallId: string; toolName: string; result: any; isError: boolean }): void {
+ const component = this.pendingTools.get(event.toolCallId);
+ if (component) {
+ component.updateResult({ ...event.result, isError: event.isError });
+ this.pendingTools.delete(event.toolCallId);
+ }
+
+ const args = this.toolArgsCache.get(event.toolCallId);
+ this.toolArgsCache.delete(event.toolCallId);
+
+ if (args && typeof args.path === "string") {
+ if (event.toolName === "read") {
+ this.filesRead.add(args.path);
+ } else if (event.toolName === "write" || event.toolName === "edit") {
+ this.filesModified.add(args.path);
+ }
+ }
+ }
+
+ private showLoading(): void {
+ if (!this.loadingIndicator) {
+ this.loadingIndicator = new Loader(
+ this.tui,
+ (s) => theme.fg("accent", s),
+ (t) => theme.fg("muted", t),
+ "Working...",
+ );
+ this.statusLine.addChild(this.loadingIndicator);
+ }
+ }
+
+ private hideLoading(): void {
+ if (this.loadingIndicator) {
+ this.loadingIndicator.stop();
+ this.loadingIndicator = undefined;
+ this.statusLine.clear();
+ this.updateStatusLine();
+ }
+ }
+
+ private showError(message: string): void {
+ this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${message}`), 1, 0));
+ this.chatContainer.addChild(new Spacer(1));
+ }
+
+ async close(cancelled: boolean): Promise<void> {
+ if (this.closed) return;
+ this.closed = true;
+
+ await this.embeddedSession.abort();
+ this.unsubscribe?.();
+
+ let summary: string | undefined;
+ if (!cancelled && this.options.generateSummary !== false) {
+ summary = await this.generateSummary();
+ }
+
+ const stats = this.embeddedSession.getSessionStats();
+ const result: EmbeddedSessionResult = {
+ cancelled,
+ summary,
+ sessionId: this.embeddedSession.sessionManager.getSessionId(),
+ sessionFile: this.embeddedSession.sessionManager.getSessionFile(),
+ durationMs: Date.now() - this.startTime,
+ filesRead: Array.from(this.filesRead),
+ filesModified: Array.from(this.filesModified),
+ messageCount: stats.totalMessages,
+ tokens: {
+ input: stats.tokens.input,
+ output: stats.tokens.output,
+ cacheRead: stats.tokens.cacheRead,
+ cacheWrite: stats.tokens.cacheWrite,
+ },
+ };
+
+ this.hideLoading();
+ this.onCloseCallback(result);
+ }
+
+ private async generateSummary(): Promise<string | undefined> {
+ const messages = this.embeddedSession.agent.state.messages;
+ if (messages.length === 0) return undefined;
+
+ const lastAssistant = messages.filter((m) => m.role === "assistant").pop() as AssistantMessage | undefined;
+ if (!lastAssistant) return undefined;
+
+ const text = lastAssistant.content
+ .filter((c): c is TextContent => c.type === "text")
+ .map((c) => c.text)
+ .join("\n")
+ .trim();
+
+ if (!text) return undefined;
+
+ const maxLength = 500;
+ return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;
+ }
+
+ invalidate(): void {
+ this.chatContainer?.invalidate?.();
+ this.statusLine?.invalidate?.();
+ }
+
+ render(_width: number): string[] {
+ const w = this.width;
+ const maxH = this.maxHeight;
+ const border = (s: string) => theme.fg("border", s);
+ const innerWidth = Math.max(1, w - 2);
+
+ const pad = (s: string, len: number) => {
+ const vis = visibleWidth(s);
+ return s + " ".repeat(Math.max(0, len - vis));
+ };
+
+ const row = (content: string) => border("│") + pad(content, innerWidth) + border("│");
+ const emptyRow = () => border("│") + " ".repeat(innerWidth) + border("│");
+ const separator = () => border(`├${"─".repeat(innerWidth)}┤`);
+
+ const editorLines = this.editor.render(innerWidth - 2);
+ const statusLines = this.statusLine.render(innerWidth - 2);
+
+ const hasModel = !!this.embeddedSession?.model;
+ const fixedLines = 1 + (hasModel ? 1 : 0) + 1 + 1 + 1 + editorLines.length + 1 + 1 + statusLines.length + 1;
+ const chatMaxHeight = Math.max(1, maxH - fixedLines);
+
+ let chatLines = this.chatContainer.render(innerWidth - 2);
+ if (chatLines.length > chatMaxHeight) {
+ chatLines = chatLines.slice(-chatMaxHeight);
+ }
+
+ const result: string[] = [];
+
+ // Top border with title
+ const title = this.options.title ?? "Embedded Session";
+ const model = this.embeddedSession?.model;
+ const modelStr = model ? `${model.provider}/${model.id}` : "";
+ let titleText = ` ${title} `;
+ let titleLen = visibleWidth(titleText);
+
+ // Truncate title if it's too long for the border
+ if (titleLen > innerWidth - 4) {
+ const maxTitleLen = innerWidth - 7; // Account for borders and ellipsis
+ titleText = ` ${title.slice(0, Math.max(0, maxTitleLen))}... `;
+ titleLen = visibleWidth(titleText);
+ }
+
+ const borderLen = Math.max(0, innerWidth - titleLen);
+ const leftBorder = Math.floor(borderLen / 2);
+ const rightBorder = borderLen - leftBorder;
+ result.push(
+ border(`╭${"─".repeat(leftBorder)}`) + theme.fg("accent", titleText) + border(`${"─".repeat(rightBorder)}╮`),
+ );
+
+ if (modelStr) {
+ result.push(row(` ${theme.fg("dim", modelStr)}`));
+ }
+
+ result.push(separator());
+
+ if (chatLines.length === 0) {
+ result.push(emptyRow());
+ } else {
+ for (const line of chatLines) {
+ result.push(row(` ${line}`));
+ }
+ }
+
+ result.push(separator());
+ result.push(emptyRow());
+
+ for (const line of editorLines) {
+ result.push(row(` ${line}`));
+ }
+
+ result.push(emptyRow());
+ result.push(separator());
+
+ for (const line of statusLines) {
+ result.push(row(` ${line}`));
+ }
+
+ result.push(border(`╰${"─".repeat(innerWidth)}╯`));
+
+ return result;
+ }
+
+ dispose(): void {
+ this.close(true);
+ }
+}
diff --git a/packages/coding-agent/examples/extensions/embedded-sessions/index.ts b/packages/coding-agent/examples/extensions/embedded-sessions/index.ts
new file mode 100644
index 000000000..cf888c342
--- /dev/null
+++ b/packages/coding-agent/examples/extensions/embedded-sessions/index.ts
@@ -0,0 +1,131 @@
+/**
+ * Embedded Sessions Extension
+ *
+ * Child agent sessions that run in an overlay within the parent session.
+ * Useful for focused subtasks, exploration, or read-only review.
+ *
+ * Commands:
+ * /embed [message] - Open embedded session with optional initial message
+ * /embed-context - Open embedded session with parent context included
+ */
+
+import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
+import { EmbeddedSessionComponent } from "./embedded-session-component.js";
+import type { EmbeddedSessionOptions, EmbeddedSessionResult } from "./types.js";
+
+export default function embeddedSessions(pi: ExtensionAPI) {
+ /**
+ * Create an embedded session in an overlay.
+ */
+ async function createEmbeddedSession(
+ ctx: ExtensionCommandContext,
+ options: EmbeddedSessionOptions = {},
+ ): Promise<EmbeddedSessionResult> {
+ const session = ctx.session;
+
+ const result = await ctx.ui.custom<EmbeddedSessionResult>(
+ async (tui, _theme, keybindings, done) => {
+ const component = await EmbeddedSessionComponent.create({
+ tui,
+ parentSession: session,
+ options,
+ keybindings,
+ onClose: done,
+ });
+ return component;
+ },
+ {
+ overlay: true,
+ overlayOptions: {
+ width: options.width ?? "90%",
+ maxHeight: options.maxHeight ?? "85%",
+ anchor: "center",
+ },
+ },
+ );
+
+ // Persist reference to parent session (if session had any activity)
+ if (!result.cancelled || result.messageCount > 0) {
+ session.sessionManager.appendEmbeddedSessionRef({
+ embeddedSessionId: result.sessionId,
+ embeddedSessionFile: result.sessionFile,
+ title: options.title,
+ summary: result.summary,
+ durationMs: result.durationMs,
+ messageCount: result.messageCount,
+ model: {
+ provider: (options.model ?? session.model)?.provider ?? "unknown",
+ modelId: (options.model ?? session.model)?.id ?? "unknown",
+ },
+ thinkingLevel: options.thinkingLevel ?? session.thinkingLevel,
+ cancelled: result.cancelled,
+ filesRead: result.filesRead,
+ filesModified: result.filesModified,
+ tokens: result.tokens,
+ });
+ }
+
+ return result;
+ }
+
+ // Basic embedded session
+ pi.registerCommand("embed", {
+ description: "Open an embedded session (optional: initial message)",
+ handler: async (args, ctx) => {
+ const title = args.trim() ? "Embedded Task" : "Embedded Session";
+ const result = await createEmbeddedSession(ctx, {
+ title,
+ initialMessage: args.trim() || undefined,
+ generateSummary: true,
+ });
+
+ if (!result.cancelled && result.summary) {
+ pi.sendMessage(
+ {
+ customType: "embedded_session_summary",
+ content: `Embedded session "${title}" completed.\n\nSummary:\n${result.summary}\n\nFiles read: ${result.filesRead.length > 0 ? result.filesRead.join(", ") : "none"}\nFiles modified: ${result.filesModified.length > 0 ? result.filesModified.join(", ") : "none"}`,
+ display: true,
+ },
+ { triggerTurn: false },
+ );
+ ctx.ui.notify("Embedded session completed", "info");
+ } else if (result.cancelled) {
+ ctx.ui.notify("Embedded session cancelled", "warning");
+ }
+ },
+ });
+
+ // Embedded session with parent context
+ pi.registerCommand("embed-context", {
+ description: "Open embedded session with parent context forked in",
+ handler: async (args, ctx) => {
+ const title = "Context-Aware Session";
+ const result = await createEmbeddedSession(ctx, {
+ title,
+ includeParentContext: true,
+ parentContextDepth: 3,
+ initialMessage: args.trim() || "I have context from the parent session. How can I help?",
+ generateSummary: true,
+ });
+
+ if (!result.cancelled && result.summary) {
+ pi.sendMessage(
+ {
+ customType: "embedded_session_summary",
+ content: `Context-aware session completed.\n\nSummary:\n${result.summary}`,
+ display: true,
+ },
+ { triggerTurn: false },
+ );
+ ctx.ui.notify("Embedded session completed", "info");
+ } else if (result.cancelled) {
+ ctx.ui.notify("Embedded session cancelled", "warning");
+ }
+ },
+ });
+}
+
+export type { EmbeddedSessionComponentConfig } from "./embedded-session-component.js";
+// Re-export for programmatic use by other extensions
+export { EmbeddedSessionComponent } from "./embedded-session-component.js";
+export type { EmbeddedSessionOptions, EmbeddedSessionResult } from "./types.js";
diff --git a/packages/coding-agent/examples/extensions/embedded-sessions/package.json b/packages/coding-agent/examples/extensions/embedded-sessions/package.json
new file mode 100644
index 000000000..5a0f8a1f1
--- /dev/null
+++ b/packages/coding-agent/examples/extensions/embedded-sessions/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "pi-embedded-sessions",
+ "version": "1.0.0",
+ "description": "Embedded sessions extension for pi - create child agent sessions in overlays",
+ "main": "index.ts",
+ "type": "module",
+ "keywords": ["pi", "extension", "embedded-sessions"],
+ "author": "nicobailon",
+ "license": "MIT"
+}
diff --git a/packages/coding-agent/examples/extensions/embedded-sessions/types.ts b/packages/coding-agent/examples/extensions/embedded-sessions/types.ts
new file mode 100644
index 000000000..5aef1d15d
--- /dev/null
+++ b/packages/coding-agent/examples/extensions/embedded-sessions/types.ts
@@ -0,0 +1,92 @@
+/**
+ * Types for embedded sessions.
+ */
+
+import type { AgentTool, ThinkingLevel } from "@mariozechner/pi-agent-core";
+import type { Model } from "@mariozechner/pi-ai";
+
+/**
+ * Options for creating an embedded session.
+ */
+export interface EmbeddedSessionOptions {
+ /** Display title for the overlay. Default: "Embedded Session" */
+ title?: string;
+
+ /** Override model. Default: inherit from parent */
+ model?: Model<any>;
+
+ /** Override thinking level. Default: inherit from parent */
+ thinkingLevel?: ThinkingLevel;
+
+ /** Include parent's tools. Default: true */
+ inheritTools?: boolean;
+
+ /** Additional tools for this embedded session only */
+ additionalTools?: AgentTool[];
+
+ /** Exclude specific tools from parent. E.g., ["write", "edit"] for read-only */
+ excludeTools?: string[];
+
+ /** Initial message to send automatically when session opens */
+ initialMessage?: string;
+
+ /** Include parent context (fork recent messages). Default: false */
+ includeParentContext?: boolean;
+
+ /** Number of recent parent exchanges to include. Default: 5 */
+ parentContextDepth?: number;
+
+ /**
+ * Session file path.
+ * - undefined: auto-generate in embedded/ directory
+ * - false: in-memory only (no persistence)
+ * - string: specific path
+ */
+ sessionFile?: string | false;
+
+ /** Generate summary on close. Default: true */
+ generateSummary?: boolean;
+
+ /** Overlay width. Default: "90%" */
+ width?: number | `${number}%`;
+
+ /** Overlay max height. Default: "85%" */
+ maxHeight?: number | `${number}%`;
+}
+
+/**
+ * Result from an embedded session.
+ */
+export interface EmbeddedSessionResult {
+ /** Whether user cancelled (Escape) vs completed (/done) */
+ cancelled: boolean;
+
+ /** Generated summary (if generateSummary was true and not cancelled) */
+ summary?: string;
+
+ /** Embedded session ID */
+ sessionId: string;
+
+ /** Session file path (if persisted) */
+ sessionFile?: string;
+
+ /** Duration in ms */
+ durationMs: number;
+
+ /** Files read during session */
+ filesRead: string[];
+
+ /** Files modified during session */
+ filesModified: string[];
+
+ /** Message count */
+ messageCount: number;
+
+ /** Token usage */
+ tokens: {
+ input: number;
+ output: number;
+ cacheRead: number;
+ cacheWrite: number;
+ };
+}
diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts
index 4fa869ae0..c4486d7e9 100644
--- a/packages/coding-agent/src/core/extensions/runner.ts
+++ b/packages/coding-agent/src/core/extensions/runner.ts
@@ -117,6 +117,9 @@ export class ExtensionRunner {
private forkHandler: ForkHandler = async () => ({ cancelled: false });
private navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false });
private shutdownHandler: ShutdownHandler = () => {};
+ private getSessionFn: ExtensionContextActions["getSession"] = () => {
+ throw new Error("session not available yet");
+ };
constructor(
extensions: Extension[],
@@ -154,6 +157,7 @@ export class ExtensionRunner {
// Context actions (required)
this.getModel = contextActions.getModel;
+ this.getSessionFn = contextActions.getSession;
this.isIdleFn = contextActions.isIdle;
this.abortFn = contextActions.abort;
this.hasPendingMessagesFn = contextActions.hasPendingMessages;
@@ -324,6 +328,7 @@ export class ExtensionRunner {
*/
createContext(): ExtensionContext {
const getModel = this.getModel;
+ const getSession = this.getSessionFn;
return {
ui: this.uiContext,
hasUI: this.hasUI(),
@@ -333,6 +338,9 @@ export class ExtensionRunner {
get model() {
return getModel();
},
+ get session() {
+ return getSession();
+ },
isIdle: () => this.isIdleFn(),
abort: () => this.abortFn(),
hasPendingMessages: () => this.hasPendingMessagesFn(),
@@ -347,6 +355,8 @@ export class ExtensionRunner {
newSession: (options) => this.newSessionHandler(options),
fork: (entryId) => this.forkHandler(entryId),
navigateTree: (targetId, options) => this.navigateTreeHandler(targetId, options),
+ isEmbeddedSession: this.sessionManager.isEmbedded(),
+ parentSessionId: this.sessionManager.getParentSessionId(),
};
}
diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts
index 1753576a2..f125b1505 100644
--- a/packages/coding-agent/src/core/extensions/types.ts
+++ b/packages/coding-agent/src/core/extensions/types.ts
@@ -27,6 +27,7 @@ import type {
} from "@mariozechner/pi-tui";
import type { Static, TSchema } from "@sinclair/typebox";
import type { Theme } from "../../modes/interactive/theme/theme.js";
+import type { AgentSession } from "../agent-session.js";
import type { BashResult } from "../bash-executor.js";
import type { CompactionPreparation, CompactionResult } from "../compaction/index.js";
import type { EventBus } from "../event-bus.js";
@@ -209,6 +210,15 @@ export interface ExtensionContext {
modelRegistry: ModelRegistry;
/** Current model (may be undefined) */
model: Model<any> | undefined;
+ /**
+ * The current AgentSession. Provides access to:
+ * - `agent.state` (systemPrompt, tools, messages, model, thinkingLevel)
+ * - `settingsManager`, `modelRegistry`, `promptTemplates`, `skills`
+ * - `subscribe()` for event handling
+ *
+ * Use for advanced scenarios like creating embedded sessions.
+ */
+ session: AgentSession;
/** Whether the agent is idle (not streaming) */
isIdle(): boolean;
/** Abort the current agent operation */
@@ -241,6 +251,12 @@ export interface ExtensionCommandContext extends ExtensionContext {
targetId: string,
options?: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string },
): Promise<{ cancelled: boolean }>;
+
+ /** Whether currently in an embedded session */
+ isEmbeddedSession: boolean;
+
+ /** Parent session ID (if in embedded session) */
+ parentSessionId?: string;
}
// ============================================================================
@@ -915,6 +931,7 @@ export interface ExtensionActions {
*/
export interface ExtensionContextActions {
getModel: () => Model<any> | undefined;
+ getSession: () => AgentSession;
isIdle: () => boolean;
abort: () => void;
hasPendingMessages: () => boolean;
diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts
index 95e2d9db4..feb8a5a0e 100644
--- a/packages/coding-agent/src/core/session-manager.ts
+++ b/packages/coding-agent/src/core/session-manager.ts
@@ -10,11 +10,13 @@ import {
readdirSync,
readFileSync,
readSync,
+ rmdirSync,
statSync,
+ unlinkSync,
writeFileSync,
} from "fs";
import { readdir, readFile, stat } from "fs/promises";
-import { join, resolve } from "path";
+import { dirname, join, resolve } from "path";
import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.js";
import {
type BashExecutionMessage,
@@ -112,6 +114,32 @@ export interface SessionInfoEntry extends SessionEntryBase {
name?: string;
}
+/**
+ * Reference to an embedded session, stored in parent session.
+ * Does NOT participate in LLM context (like CustomEntry).
+ * Rendered as a collapsible block in parent chat.
+ */
+export interface EmbeddedSessionRefEntry extends SessionEntryBase {
+ type: "embedded_session_ref";
+ embeddedSessionId: string;
+ embeddedSessionFile?: string;
+ title?: string;
+ summary?: string;
+ durationMs: number;
+ messageCount: number;
+ model: { provider: string; modelId: string };
+ thinkingLevel: string;
+ cancelled: boolean;
+ filesRead?: string[];
+ filesModified?: string[];
+ tokens?: {
+ input: number;
+ output: number;
+ cacheRead: number;
+ cacheWrite: number;
+ };
+}
+
/**
* Custom message entry for extensions to inject messages into LLM context.
* Use customType to identify your extension's entries.
@@ -142,7 +170,8 @@ export type SessionEntry =
| CustomEntry
| CustomMessageEntry
| LabelEntry
- | SessionInfoEntry;
+ | SessionInfoEntry
+ | EmbeddedSessionRefEntry;
/** Raw file entry (includes header) */
export type FileEntry = SessionHeader | SessionEntry;
@@ -628,7 +657,13 @@ export class SessionManager {
private labelsById: Map<string, string> = new Map();
private leafId: string | null = null;
- private constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) {
+ private constructor(
+ cwd: string,
+ sessionDir: string,
+ sessionFile: string | undefined,
+ persist: boolean,
+ newSessionOptions?: NewSessionOptions,
+ ) {
this.cwd = cwd;
this.sessionDir = sessionDir;
this.persist = persist;
@@ -639,7 +674,7 @@ export class SessionManager {
if (sessionFile) {
this.setSessionFile(sessionFile);
} else {
- this.newSession();
+ this.newSession(newSessionOptions);
}
}
@@ -891,6 +926,23 @@ export class SessionManager {
return entry.id;
}
+ /**
+ * Append an embedded session reference as child of current leaf.
+ * This entry does NOT participate in LLM context - it's for UI display only.
+ * @returns Entry id
+ */
+ appendEmbeddedSessionRef(ref: Omit<EmbeddedSessionRefEntry, "id" | "parentId" | "timestamp" | "type">): string {
+ const entry: EmbeddedSessionRefEntry = {
+ type: "embedded_session_ref",
+ id: generateId(this.byId),
+ parentId: this.leafId,
+ timestamp: new Date().toISOString(),
+ ...ref,
+ };
+ this._appendEntry(entry);
+ return entry.id;
+ }
+
// =========================================================================
// Tree Traversal
// =========================================================================
@@ -985,6 +1037,22 @@ export class SessionManager {
return h ? (h as SessionHeader) : null;
}
+ /**
+ * Whether this is an embedded session (has a parent session).
+ */
+ isEmbedded(): boolean {
+ const header = this.getHeader();
+ return header?.parentSession !== undefined;
+ }
+
+ /**
+ * Get parent session ID (embedded sessions only).
+ */
+ getParentSessionId(): string | undefined {
+ const header = this.getHeader();
+ return header?.parentSession;
+ }
+
/**
* Get all session entries (excludes header). Returns a shallow copy.
* The session is append-only: use appendXXX() to add entries, branch() to
@@ -994,6 +1062,24 @@ export class SessionManager {
return this.fileEntries.filter((e): e is SessionEntry => e.type !== "session");
}
+ /**
+ * Get entries in the current path (from root to leaf).
+ * Only includes entries that are ancestors of the current leaf.
+ */
+ getEntriesInPath(): SessionEntry[] {
+ if (!this.leafId) {
+ return [];
+ }
+
+ const path: SessionEntry[] = [];
+ let current = this.byId.get(this.leafId);
+ while (current) {
+ path.unshift(current);
+ current = current.parentId ? this.byId.get(current.parentId) : undefined;
+ }
+ return path;
+ }
+
/**
* Get the session as a tree structure. Returns a shallow defensive copy of all entries.
* A well-formed session has exactly one root (first entry with parentId === null).
@@ -1269,6 +1355,44 @@ export class SessionManager {
return new SessionManager(targetCwd, dir, newSessionFile, true);
}
+ /**
+ * Create a session manager for an embedded session.
+ *
+ * If sessionFile is provided, persists to that path.
+ * Otherwise creates in: ~/.pi/agent/sessions/embedded/{parent-id}/{timestamp}_{id}.jsonl
+ *
+ * The session header includes parentSession reference.
+ */
+ static createEmbedded(
+ parentSessionId: string,
+ cwd: string,
+ options?: {
+ sessionFile?: string;
+ },
+ ): SessionManager {
+ // Determine the session directory
+ const embeddedDir = join(getDefaultAgentDir(), "sessions", "embedded", parentSessionId);
+
+ // If a specific session file is provided, use its directory
+ if (options?.sessionFile) {
+ const resolvedPath = resolve(options.sessionFile);
+ const dir = dirname(resolvedPath);
+ // Create manager - newSession will be called with parentSession option
+ // Then we override the generated sessionFile path
+ const manager = new SessionManager(cwd, dir, undefined, true, {
+ parentSession: parentSessionId,
+ });
+ // Override the auto-generated path with the specified one
+ // Note: The header with parentSession is already in fileEntries from newSession()
+ // It will be written when the session is flushed or entries are appended
+ manager.sessionFile = resolvedPath;
+ return manager;
+ }
+
+ // Create the manager with persistence enabled, passing parentSession to newSession
+ return new SessionManager(cwd, embeddedDir, undefined, true, { parentSession: parentSessionId });
+ }
+
/**
* List all sessions for a directory.
* @param cwd Working directory (used to compute default session directory)
@@ -1335,4 +1459,64 @@ export class SessionManager {
return [];
}
}
+
+ /**
+ * Clean up old embedded sessions.
+ * Called during startup to remove sessions older than maxAgeDays.
+ */
+ static async cleanupEmbeddedSessions(options?: {
+ maxAgeDays?: number; // Default: 30
+ dryRun?: boolean;
+ }): Promise<{ deleted: string[]; errors: string[] }> {
+ const maxAge = (options?.maxAgeDays ?? 30) * 24 * 60 * 60 * 1000;
+ const cutoff = Date.now() - maxAge;
+ const embeddedDir = join(getDefaultAgentDir(), "sessions", "embedded");
+
+ const deleted: string[] = [];
+ const errors: string[] = [];
+
+ if (!existsSync(embeddedDir)) {
+ return { deleted, errors };
+ }
+
+ // Iterate parent session directories
+ for (const parentDir of readdirSync(embeddedDir)) {
+ const parentPath = join(embeddedDir, parentDir);
+ try {
+ if (!statSync(parentPath).isDirectory()) continue;
+ } catch {
+ continue;
+ }
+
+ // Check each embedded session file
+ for (const file of readdirSync(parentPath)) {
+ if (!file.endsWith(".jsonl")) continue;
+
+ const filePath = join(parentPath, file);
+ try {
+ const fileStat = statSync(filePath);
+ if (fileStat.mtime.getTime() < cutoff) {
+ if (!options?.dryRun) {
+ unlinkSync(filePath);
+ }
+ deleted.push(filePath);
+ }
+ } catch (err) {
+ errors.push(`${filePath}: ${err}`);
+ }
+ }
+
+ // Remove empty parent directories
+ try {
+ const remaining = readdirSync(parentPath);
+ if (remaining.length === 0 && !options?.dryRun) {
+ rmdirSync(parentPath);
+ }
+ } catch {
+ // Ignore errors when removing directories
+ }
+ }
+
+ return { deleted, errors };
+ }
}
diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts
index 62cad798e..bb48b1f5e 100644
--- a/packages/coding-agent/src/index.ts
+++ b/packages/coding-agent/src/index.ts
@@ -148,6 +148,7 @@ export {
CURRENT_SESSION_VERSION,
type CustomEntry,
type CustomMessageEntry,
+ type EmbeddedSessionRefEntry,
type FileEntry,
getLatestCompactionEntry,
type ModelChangeEntry,
@@ -244,6 +245,8 @@ export {
CustomEditor,
CustomMessageComponent,
DynamicBorder,
+ EmbeddedSessionRefComponent,
+ type EmbeddedSessionRefComponentConfig,
ExtensionEditorComponent,
ExtensionInputComponent,
ExtensionSelectorComponent,
@@ -270,6 +273,7 @@ export {
} from "./modes/interactive/components/index.js";
// Theme utilities for custom tools and extensions
export {
+ getEditorTheme,
getLanguageFromPath,
getMarkdownTheme,
getSelectListTheme,
@@ -278,6 +282,7 @@ export {
initTheme,
Theme,
type ThemeColor,
+ theme,
} from "./modes/interactive/theme/theme.js";
export { parseFrontmatter, stripFrontmatter } from "./utils/frontmatter.js";
// Shell utilities
diff --git a/packages/coding-agent/src/modes/interactive/components/embedded-session-ref.ts b/packages/coding-agent/src/modes/interactive/components/embedded-session-ref.ts
new file mode 100644
index 000000000..e4b3f86b8
--- /dev/null
+++ b/packages/coding-agent/src/modes/interactive/components/embedded-session-ref.ts
@@ -0,0 +1,91 @@
+import { Container, Text } from "@mariozechner/pi-tui";
+import type { EmbeddedSessionRefEntry } from "../../../core/session-manager.js";
+import { theme } from "../theme/theme.js";
+
+export interface EmbeddedSessionRefComponentConfig {
+ entry: EmbeddedSessionRefEntry;
+}
+
+/**
+ * Component that renders an embedded session reference in the parent chat.
+ * Shows a collapsible block with summary and metadata.
+ */
+export class EmbeddedSessionRefComponent extends Container {
+ private entry: EmbeddedSessionRefEntry;
+ private expanded = false;
+
+ constructor(config: EmbeddedSessionRefComponentConfig) {
+ super();
+ this.entry = config.entry;
+ this.renderContent();
+ }
+
+ private renderContent(): void {
+ this.clear();
+
+ const { entry } = this;
+ const icon = entry.cancelled ? "○" : "●";
+ const status = entry.cancelled ? "cancelled" : "completed";
+ const duration = this.formatDuration(entry.durationMs);
+
+ // Header line
+ const header = [
+ theme.fg(entry.cancelled ? "dim" : "accent", icon),
+ theme.fg("muted", " Embedded: "),
+ theme.bold(entry.title ?? "Session"),
+ theme.fg("dim", ` (${status}, ${duration}, ${entry.messageCount} messages)`),
+ ].join("");
+
+ this.addChild(new Text(header, 1, 0));
+
+ // Summary (if present) - always show truncated in collapsed view
+ if (entry.summary) {
+ const summaryText = this.expanded ? entry.summary : entry.summary.slice(0, 200);
+ const ellipsis = !this.expanded && entry.summary.length > 200 ? "..." : "";
+ this.addChild(new Text(theme.fg("text", ` ${summaryText}${ellipsis}`), 1, 0));
+ }
+
+ // Files modified (collapsed view)
+ if (entry.filesModified?.length && !this.expanded) {
+ const count = entry.filesModified.length;
+ const preview = entry.filesModified
+ .slice(0, 3)
+ .map((f) => f.split("/").pop())
+ .join(", ");
+ const more = count > 3 ? ` +${count - 3} more` : "";
+ this.addChild(new Text(theme.fg("dim", ` Modified: ${preview}${more}`), 1, 0));
+ }
+
+ // Expanded details
+ if (this.expanded) {
+ if (entry.filesRead?.length) {
+ this.addChild(new Text(theme.fg("dim", ` Read: ${entry.filesRead.join(", ")}`), 1, 0));
+ }
+ if (entry.filesModified?.length) {
+ this.addChild(new Text(theme.fg("dim", ` Modified: ${entry.filesModified.join(", ")}`), 1, 0));
+ }
+ if (entry.tokens) {
+ const tokens = `${entry.tokens.input}/${entry.tokens.output}`;
+ this.addChild(new Text(theme.fg("dim", ` Tokens: ${tokens}`), 1, 0));
+ }
+ }
+ }
+
+ private formatDuration(ms: number): string {
+ const seconds = Math.floor(ms / 1000);
+ if (seconds < 60) return `${seconds}s`;
+ const minutes = Math.floor(seconds / 60);
+ const secs = seconds % 60;
+ return `${minutes}m ${secs}s`;
+ }
+
+ setExpanded(expanded: boolean): void {
+ if (this.expanded === expanded) return;
+ this.expanded = expanded;
+ this.renderContent();
+ }
+
+ invalidate(): void {
+ this.renderContent();
+ }
+}
diff --git a/packages/coding-agent/src/modes/interactive/components/index.ts b/packages/coding-agent/src/modes/interactive/components/index.ts
index 2676e2cd7..8135ac557 100644
--- a/packages/coding-agent/src/modes/interactive/components/index.ts
+++ b/packages/coding-agent/src/modes/interactive/components/index.ts
@@ -9,6 +9,7 @@ export { CustomEditor } from "./custom-editor.js";
export { CustomMessageComponent } from "./custom-message.js";
export { type RenderDiffOptions, renderDiff } from "./diff.js";
export { DynamicBorder } from "./dynamic-border.js";
+export { EmbeddedSessionRefComponent, type EmbeddedSessionRefComponentConfig } from "./embedded-session-ref.js";
export { ExtensionEditorComponent } from "./extension-editor.js";
export { ExtensionInputComponent } from "./extension-input.js";
export { ExtensionSelectorComponent } from "./extension-selector.js";
diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts
index a02a969c4..28d87d689 100644
--- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts
+++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts
@@ -70,6 +70,7 @@ import { CompactionSummaryMessageComponent } from "./components/compaction-summa
import { CustomEditor } from "./components/custom-editor.js";
import { CustomMessageComponent } from "./components/custom-message.js";
import { DynamicBorder } from "./components/dynamic-border.js";
+import { EmbeddedSessionRefComponent } from "./components/embedded-session-ref.js";
import { ExtensionEditorComponent } from "./components/extension-editor.js";
import { ExtensionInputComponent } from "./components/extension-input.js";
import { ExtensionSelectorComponent } from "./components/extension-selector.js";
@@ -664,6 +665,7 @@ export class InteractiveMode {
// ExtensionContextActions - for ctx.* in event handlers
{
getModel: () => this.session.model,
+ getSession: () => this.session,
isIdle: () => !this.session.isStreaming,
abort: () => this.session.abort(),
hasPendingMessages: () => this.session.pendingMessageCount > 0,
@@ -786,6 +788,7 @@ export class InteractiveMode {
sessionManager: this.sessionManager,
modelRegistry: this.session.modelRegistry,
model: this.session.model,
+ session: this.session,
isIdle: () => !this.session.isStreaming,
abort: () => this.session.abort(),
hasPendingMessages: () => this.session.pendingMessageCount > 0,
@@ -1322,6 +1325,21 @@ export class InteractiveMode {
this.ui.requestRender();
}
+ /**
+ * Render embedded_session_ref entries from the current path (root to leaf).
+ * Called during initial render and after resuming a session.
+ */
+ private renderEmbeddedSessionRefs(): void {
+ const entries = this.session.sessionManager.getEntriesInPath();
+ for (const entry of entries) {
+ if (entry.type === "embedded_session_ref") {
+ const component = new EmbeddedSessionRefComponent({ entry });
+ this.chatContainer.addChild(component);
+ this.chatContainer.addChild(new Spacer(1));
+ }
+ }
+ }
+
// =========================================================================
// Key Handlers
// =========================================================================
@@ -2024,6 +2042,9 @@ export class InteractiveMode {
populateHistory: true,
});
+ // Render embedded session references (not in LLM context, just for display)
+ this.renderEmbeddedSessionRefs();
+
// Show compaction info if session was compacted
const allEntries = this.sessionManager.getEntries();
const compactionCount = allEntries.filter((e) => e.type === "compaction").length;
@@ -2046,6 +2067,8 @@ export class InteractiveMode {
this.chatContainer.clear();
const context = this.sessionManager.buildSessionContext();
this.renderSessionContext(context);
+ // Render embedded session references (not in LLM context, just for display)
+ this.renderEmbeddedSessionRefs();
}
// =========================================================================
diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts
index 2a9a52772..fc51b6236 100644
--- a/packages/coding-agent/src/modes/print-mode.ts
+++ b/packages/coding-agent/src/modes/print-mode.ts
@@ -75,6 +75,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
// ExtensionContextActions
{
getModel: () => session.model,
+ getSession: () => session,
isIdle: () => !session.isStreaming,
abort: () => session.abort(),
hasPendingMessages: () => session.pendingMessageCount > 0,
diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts
index 50c4ed3ce..fcf5c4b89 100644
--- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts
+++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts
@@ -288,6 +288,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
// ExtensionContextActions
{
getModel: () => session.agent.state.model,
+ getSession: () => session,
isIdle: () => !session.isStreaming,
abort: () => session.abort(),
hasPendingMessages: () => session.pendingMessageCount > 0,
diff --git a/packages/coding-agent/test/compaction-extensions.test.ts b/packages/coding-agent/test/compaction-extensions.test.ts
index 957985b5e..b45a365d5 100644
--- a/packages/coding-agent/test/compaction-extensions.test.ts
+++ b/packages/coding-agent/test/compaction-extensions.test.ts
@@ -120,6 +120,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
// ExtensionContextActions
{
getModel: () => session.model,
+ getSession: () => session,
isIdle: () => !session.isStreaming,
abort: () => session.abort(),
hasPendingMessages: () => session.pendingMessageCount > 0,
[Showing lines 154-1671 of 1671 (50.0KB limit). Full output: /var/folders/49/l4171l3n219_xwq30fmyzr8w0000gn/T/pi-bash-316b8c304cee1c53.log]Let me see if there's more diff content, especially the beginning of the file. Also need to check for linked issues, CHANGELOG entries, and read the relevant code files.
Let me get the full diff and check for linked issues and CHANGELOG entries:
>cd /Users/badlogic/workspaces/pi-mono && gh pr diff 789 2>&1 | head -500diff --git a/packages/coding-agent/examples/extensions/embedded-sessions/README.md b/packages/coding-agent/examples/extensions/embedded-sessions/README.md
new file mode 100644
index 000000000..1e7900eaf
--- /dev/null
+++ b/packages/coding-agent/examples/extensions/embedded-sessions/README.md
@@ -0,0 +1,119 @@
+# Embedded Sessions Extension
+
+Child agent sessions that run in an overlay within the parent session. Useful for focused subtasks, exploration, or read-only review without polluting the main conversation.
+
+## Installation
+
+Copy this directory to your extensions folder:
+
+```bash
+cp -r embedded-sessions ~/.pi/agent/extensions/
+```
+
+Or symlink for development:
+
+```bash
+ln -s /path/to/pi-mono/packages/coding-agent/examples/extensions/embedded-sessions ~/.pi/agent/extensions/
+```
+
+## Commands
+
+| Command | Description |
+|---------|-------------|
+| `/embed [message]` | Open embedded session with optional initial message |
+| `/embed-context` | Session with recent parent conversation forked in |
+
+## Keybindings
+
+Inside the embedded session overlay:
+
+| Key | Action |
+|-----|--------|
+| Enter | Send message |
+| Escape | Abort (if streaming) or cancel session |
+| `/model` | Switch model |
+| `/done` | Complete session (generates summary) |
+| `/compact` | Compact session history |
+
+## Features
+
+- **Tool inheritance**: Inherits parent's tools by default
+- **Tool exclusion**: Exclude specific tools (e.g., `["write", "edit"]` for read-only)
+- **Parent context**: Optionally fork recent conversation history
+- **Persistence**: Sessions saved to `~/.pi/agent/sessions/embedded/{parent-id}/`
+- **Summary**: Extracts summary from last assistant response on `/done`
+- **File tracking**: Tracks files read and modified during session
+- **Session refs**: Reference stored in parent session showing completion status, duration, files, tokens
+
+## Programmatic Usage
+
+Extensions can create embedded sessions directly:
+
+```typescript
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import { EmbeddedSessionComponent } from "./embedded-sessions/embedded-session-component.js";
+
+export default function myExtension(pi: ExtensionAPI) {
+ pi.registerCommand("my-embed", {
+ description: "Custom embedded session",
+ handler: async (args, ctx) => {
+ const result = await ctx.ui.custom(
+ async (tui, _theme, keybindings, done) => {
+ return EmbeddedSessionComponent.create({
+ tui,
+ parentSession: ctx.session,
+ options: {
+ title: "My Task",
+ initialMessage: args.trim() || undefined,
+ excludeTools: ["write"], // read-only
+ },
+ keybindings,
+ onClose: done,
+ });
+ },
+ { overlay: true },
+ );
+
+ if (!result.cancelled) {
+ ctx.ui.notify(`Completed: ${result.messageCount} messages`, "info");
+ }
+ },
+ });
+}
+```
+
+## Options
+
+```typescript
+interface EmbeddedSessionOptions {
+ title?: string; // Overlay title (default: "Embedded Session")
+ model?: Model; // Override model (default: inherit)
+ thinkingLevel?: ThinkingLevel; // Override thinking (default: inherit)
+ inheritTools?: boolean; // Include parent tools (default: true)
+ additionalTools?: AgentTool[]; // Extra tools for this session
+ excludeTools?: string[]; // Tools to exclude (e.g., ["write", "edit"])
+ initialMessage?: string; // Auto-send on open
+ includeParentContext?: boolean; // Fork parent messages (default: false)
+ parentContextDepth?: number; // How many exchanges to fork (default: 5)
+ sessionFile?: string | false; // Path, or false for in-memory
+ generateSummary?: boolean; // Generate summary on close (default: true)
+ width?: number | `${number}%`; // Overlay width (default: "90%")
+ maxHeight?: number | `${number}%`; // Overlay height (default: "85%")
+}
+```
+
+## Result
+
+```typescript
+interface EmbeddedSessionResult {
+ cancelled: boolean; // true if Escape, false if /done
+ summary?: string; // Last assistant response excerpt
+ sessionId: string;
+ sessionFile?: string; // undefined for in-memory
+ durationMs: number;
+ filesRead: string[];
+ filesModified: string[];
+ messageCount: number;
+ tokens: { input, output, cacheRead, cacheWrite };
+}
+```
diff --git a/packages/coding-agent/examples/extensions/embedded-sessions/embedded-session-component.ts b/packages/coding-agent/examples/extensions/embedded-sessions/embedded-session-component.ts
new file mode 100644
index 000000000..f23a1d9ca
--- /dev/null
+++ b/packages/coding-agent/examples/extensions/embedded-sessions/embedded-session-component.ts
@@ -0,0 +1,671 @@
+/**
+ * EmbeddedSessionComponent - Overlay UI for embedded sessions.
+ */
+
+import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
+import { Agent } from "@mariozechner/pi-agent-core";
+import type { AssistantMessage, Model, TextContent } from "@mariozechner/pi-ai";
+import {
+ AgentSession,
+ type AgentSessionEvent,
+ AssistantMessageComponent,
+ CompactionSummaryMessageComponent,
+ CustomEditor,
+ getEditorTheme,
+ type KeybindingsManager,
+ ModelSelectorComponent,
+ SessionManager,
+ type ToolDefinition,
+ ToolExecutionComponent,
+ theme,
+ UserMessageComponent,
+} from "@mariozechner/pi-coding-agent";
+import type { CompactionSummaryMessage } from "@mariozechner/pi-coding-agent/core/messages.js";
+import { Container, Loader, Spacer, Text, type TUI, visibleWidth } from "@mariozechner/pi-tui";
+import type { EmbeddedSessionOptions, EmbeddedSessionResult } from "./types.js";
+
+export interface EmbeddedSessionComponentConfig {
+ tui: TUI;
+ parentSession: AgentSession;
+ options: EmbeddedSessionOptions;
+ keybindings: KeybindingsManager;
+ onClose: (result: EmbeddedSessionResult) => void;
+ getToolDefinition?: (name: string) => ToolDefinition | undefined;
+}
+
+/**
+ * Component that renders an embedded session in an overlay.
+ */
+export class EmbeddedSessionComponent extends Container {
+ private tui: TUI;
+ private parentSession: AgentSession;
+ private embeddedSession!: AgentSession;
+ private options: EmbeddedSessionOptions;
+ private keybindings: KeybindingsManager;
+ private onCloseCallback: (result: EmbeddedSessionResult) => void;
+ private getToolDefinitionFn: (name: string) => ToolDefinition | undefined;
+ private cwd: string;
+
+ // UI Components
+ private chatContainer!: Container;
+ private editor!: CustomEditor;
+ private statusLine!: Container;
+ private loadingIndicator: Loader | undefined;
+
+ // Overlay dimensions - use options if provided, otherwise defaults
+ get width(): number {
+ const opt = this.options.width;
+ if (typeof opt === "number") return opt;
+ if (typeof opt === "string" && opt.endsWith("%")) {
+ const pct = parseInt(opt, 10);
+ if (!Number.isNaN(pct) && pct > 0) {
+ return Math.floor(this.tui.terminal.columns * (pct / 100));
+ }
+ }
+ return Math.floor(this.tui.terminal.columns * 0.9);
+ }
+ get maxHeight(): number {
+ const opt = this.options.maxHeight;
+ if (typeof opt === "number") return opt;
+ if (typeof opt === "string" && opt.endsWith("%")) {
+ const pct = parseInt(opt, 10);
+ if (!Number.isNaN(pct) && pct > 0) {
+ return Math.floor(this.tui.terminal.rows * (pct / 100));
+ }
+ }
+ return Math.floor(this.tui.terminal.rows * 0.85);
+ }
+
+ // State
+ private startTime: number;
+ private filesRead = new Set<string>();
+ private filesModified = new Set<string>();
+ private closed = false;
+ private unsubscribe?: () => void;
+
+ // Streaming state
+ private streamingComponent: AssistantMessageComponent | undefined;
+ private pendingTools = new Map<string, ToolExecutionComponent>();
+ private toolArgsCache = new Map<string, Record<string, unknown>>();
+ private toolOutputExpanded = false;
+
+ private constructor(config: EmbeddedSessionComponentConfig) {
+ super();
+ this.tui = config.tui;
+ this.parentSession = config.parentSession;
+ this.options = config.options;
+ this.keybindings = config.keybindings;
+ this.onCloseCallback = config.onClose;
+ this.getToolDefinitionFn = config.getToolDefinition ?? (() => undefined);
+ this.cwd = config.parentSession.sessionManager.getCwd();
+ this.startTime = Date.now();
+ }
+
+ static async create(config: EmbeddedSessionComponentConfig): Promise<EmbeddedSessionComponent> {
+ const component = new EmbeddedSessionComponent(config);
+ await component.initialize();
+ return component;
+ }
+
+ private async initialize(): Promise<void> {
+ // 1. Create SessionManager
+ const sessionManager = this.createSessionManager();
+
+ // 2. Create tools
+ const tools = this.createTools();
+
+ // 3. Get system prompt from parent
+ const systemPrompt = this.parentSession.agent.state.systemPrompt;
+
+ // 4. Get model
+ const model = this.options.model ?? this.parentSession.model;
+ if (!model) {
+ throw new Error("Cannot create embedded session: no model specified and parent has no model");
+ }
+
+ // 5. Get initial messages
+ const initialMessages = this.buildInitialMessages();
+
+ // 6. Create Agent
+ const agent = new Agent({
+ initialState: {
+ model,
+ systemPrompt,
+ tools,
+ messages: initialMessages,
+ thinkingLevel: this.options.thinkingLevel ?? this.parentSession.thinkingLevel,
+ },
+ getApiKey: (provider) => this.parentSession.modelRegistry.getApiKeyForProvider(provider),
+ });
+
+ // 7. Create AgentSession
+ this.embeddedSession = new AgentSession({
+ agent,
+ sessionManager,
+ settingsManager: this.parentSession.settingsManager,
+ modelRegistry: this.parentSession.modelRegistry,
+ promptTemplates: [...this.parentSession.promptTemplates],
+ skills: [...this.parentSession.skills],
+ });
+
+ // 8. Build UI
+ this.buildUI();
+
+ // 9. Subscribe to events
+ this.unsubscribe = this.embeddedSession.subscribe(this.handleEvent);
+
+ // 10. Send initial message if provided
+ if (this.options.initialMessage) {
+ this.embeddedSession.prompt(this.options.initialMessage).catch((err) => {
+ this.showError(err.message);
+ });
+ }
+ }
+
+ private createSessionManager(): SessionManager {
+ const parentId = this.parentSession.sessionManager.getSessionId();
+ const cwd = this.parentSession.sessionManager.getCwd();
+
+ if (this.options.sessionFile === false) {
+ return SessionManager.inMemory(cwd);
+ }
+
+ return SessionManager.createEmbedded(parentId, cwd, {
+ sessionFile: typeof this.options.sessionFile === "string" ? this.options.sessionFile : undefined,
+ });
+ }
+
+ private createTools(): AgentTool[] {
+ if (this.options.inheritTools === false) {
+ return this.options.additionalTools ?? [];
+ }
+
+ let tools = [...this.parentSession.agent.state.tools];
+
+ if (this.options.excludeTools?.length) {
+ const excluded = new Set(this.options.excludeTools);
+ tools = tools.filter((t) => !excluded.has(t.name));
+ }
+
+ if (this.options.additionalTools?.length) {
+ tools.push(...this.options.additionalTools);
+ }
+
+ return tools;
+ }
+
+ private buildInitialMessages(): AgentMessage[] {
+ if (!this.options.includeParentContext) {
+ return [];
+ }
+
+ const depth = this.options.parentContextDepth ?? 5;
+ const parentMessages = this.parentSession.agent.state.messages;
+ const totalUserMessages = parentMessages.filter((m) => m.role === "user").length;
+ const startAfterExchange = totalUserMessages - depth;
+
+ const relevantMessages: AgentMessage[] = [];
+ let userMessageCount = 0;
+
+ for (const msg of parentMessages) {
+ if (msg.role === "user") {
+ userMessageCount++;
+ }
+ if (userMessageCount > startAfterExchange) {
+ if (msg.role === "user" || msg.role === "assistant") {
+ relevantMessages.push(JSON.parse(JSON.stringify(msg)));
+ }
+ }
+ }
+
+ return relevantMessages;
+ }
+
+ private buildUI(): void {
+ this.chatContainer = new Container();
+ this.addChild(this.chatContainer);
+
+ this.editor = new CustomEditor(this.tui, getEditorTheme(), this.keybindings);
+ this.editor.onSubmit = (text) => this.handleSubmit(text);
+ this.editor.onEscape = () => {
+ if (this.embeddedSession.isStreaming) {
+ this.embeddedSession.abort();
+ } else {
+ this.close(true);
+ }
+ };
+ this.addChild(this.editor as any);
+
+ this.statusLine = new Container();
+ this.updateStatusLine();
+ this.addChild(this.statusLine);
+ }
+
+ private updateStatusLine(): void {
+ this.statusLine.clear();
+ const stats = this.embeddedSession.getSessionStats();
+ const tokens = `${this.formatTokens(stats.tokens.input)}in/${this.formatTokens(stats.tokens.output)}out`;
+
+ const hints = [
+ theme.fg("muted", `tokens: ${tokens}`),
+ theme.fg("dim", "│"),
+ theme.fg("dim", "Enter") + theme.fg("muted", " send"),
+ theme.fg("dim", "/model") + theme.fg("muted", " switch"),
+ theme.fg("dim", "/done") + theme.fg("muted", " complete"),
+ theme.fg("dim", "Esc") + theme.fg("muted", " cancel"),
+ ].join(" ");
+
+ this.statusLine.addChild(new Text(hints, 0, 0));
+ }
+
+ private formatTokens(n: number): string {
+ return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
+ }
+
+ private showModelSelector(initialSearchInput?: string): void {
+ const selector = new ModelSelectorComponent(
+ this.tui,
+ this.embeddedSession.model,
+ this.embeddedSession.settingsManager,
+ this.embeddedSession.modelRegistry,
+ [],
+ async (model: Model<any>) => {
+ try {
+ await this.embeddedSession.setModel(model);
+ this.tui.hideOverlay();
+ this.tui.setFocus(this.editor);
+ this.updateStatusLine();
+ this.tui.requestRender();
+ } catch (error) {
+ this.tui.hideOverlay();
+ this.tui.setFocus(this.editor);
+ this.showError(error instanceof Error ? error.message : String(error));
+ }
+ },
+ () => {
+ this.tui.hideOverlay();
+ this.tui.setFocus(this.editor);
+ this.tui.requestRender();
+ },
+ initialSearchInput,
+ );
+ this.tui.showOverlay(selector, { anchor: "center" });
+ this.tui.setFocus(selector);
+ }
+
+ handleInput(data: string): void {
+ this.editor.handleInput(data);
+ }
+
+ private handleSubmit(text: string): void {
+ const trimmed = text.trim();
+ if (!trimmed) return;
+
+ this.editor.setText("");
+
+ if (trimmed === "/done" || trimmed === "/close") {
+ this.close(false);
+ return;
+ }
+
+ if (trimmed === "/compact") {
+ this.embeddedSession.compact().catch((err) => this.showError(err.message));
+ return;
+ }
+
+ if (trimmed === "/model" || trimmed.startsWith("/model ")) {
+ const searchTerm = trimmed.startsWith("/model ") ? trimmed.slice(7).trim() : undefined;
+ this.showModelSelector(searchTerm);
+ return;
+ }
+
+ const behavior = this.embeddedSession.isStreaming ? "followUp" : undefined;
+ this.embeddedSession.prompt(trimmed, { streamingBehavior: behavior }).catch((err) => {
+ this.showError(err.message);
+ });
+ }
+
+ private handleEvent = (event: AgentSessionEvent): void => {
+ switch (event.type) {
+ case "agent_start":
+ this.showLoading();
+ break;
+ case "agent_end":
+ this.hideLoading();
+ this.updateStatusLine();
+ break;
+ case "message_start":
+ if (event.message.role === "user") {
+ this.renderUserMessage(event.message);
+ } else if (event.message.role === "assistant") {
+ this.startStreamingAssistant(event.message as AssistantMessage);
+ } else if (event.message.role === "compactionSummary") {
+ this.renderCompactionSummary(event.message as CompactionSummaryMessage);
+ }
+ break;
+ case "message_update":
+ if (event.message.role === "assistant") {
+ this.updateStreamingAssistant(event.message as AssistantMessage);
+ }
+ break;
+ case "message_end":
+ if (event.message.role === "assistant") {
+ this.endStreamingAssistant(event.message as AssistantMessage);
+ }
+ break;
+ case "tool_execution_start":
+ this.handleToolStart(event);
+ break;
+ case "tool_execution_update":
+ this.handleToolUpdate(event);
+ break;
+ case "tool_execution_end":
+ this.handleToolEnd(event);
+ break;
+ }
+ this.tui.requestRender();
+ };
+
+ private renderUserMessage(message: AgentMessage): void {>cd /Users/badlogic/workspaces/pi-mono && cat packages/coding-agent/CHANGELOG.md | head -100# Changelog
## [Unreleased]
### Added
- Added `shellCommandPrefix` setting to prepend commands to every bash execution, enabling alias expansion in non-interactive shells (e.g., `"shellCommandPrefix": "shopt -s expand_aliases"`) ([#790](https://github.com/badlogic/pi-mono/pull/790) by [@richardgill](https://github.com/richardgill))
- Added bash-style argument slicing for prompt templates ([#770](https://github.com/badlogic/pi-mono/pull/770) by [@airtonix](https://github.com/airtonix))
- Extension commands can provide argument auto-completions via `getArgumentCompletions` in `pi.registerCommand()` ([#775](https://github.com/badlogic/pi-mono/pull/775) by [@ribelo](https://github.com/ribelo))
- Bash tool now displays the timeout value in the UI when a timeout is set ([#780](https://github.com/badlogic/pi-mono/pull/780) by [@dannote](https://github.com/dannote))
- Export `getShellConfig` for extensions to detect user's shell environment ([#766](https://github.com/badlogic/pi-mono/pull/766) by [@dannote](https://github.com/dannote))
- Added `thinkingText` and `selectedBg` to theme schema ([#763](https://github.com/badlogic/pi-mono/pull/763) by [@scutifer](https://github.com/scutifer))
- `navigateTree()` now supports `replaceInstructions` option to replace the default summarization prompt entirely, and `label` option to attach a label to the branch summary entry ([#787](https://github.com/badlogic/pi-mono/pull/787) by [@mitsuhiko](https://github.com/mitsuhiko))
### Fixed
- Fixed `--no-extensions` flag not preventing extension discovery ([#776](https://github.com/badlogic/pi-mono/issues/776))
- Fixed extension messages rendering twice on startup when `pi.sendMessage({ display: true })` is called during `session_start` ([#765](https://github.com/badlogic/pi-mono/pull/765) by [@dannote](https://github.com/dannote))
- Fixed `PI_CODING_AGENT_DIR` env var not expanding tilde (`~`) to home directory ([#778](https://github.com/badlogic/pi-mono/pull/778) by [@aliou](https://github.com/aliou))
- Fixed session picker hint text overflow ([#764](https://github.com/badlogic/pi-mono/issues/764))
- Fixed Kitty keyboard protocol shifted symbol keys (e.g., `@`, `?`) not working in editor ([#779](https://github.com/badlogic/pi-mono/pull/779) by [@iamd3vil](https://github.com/iamd3vil))
- Fixed Bedrock tool call IDs causing API errors from invalid characters ([#781](https://github.com/badlogic/pi-mono/pull/781) by [@pjtf93](https://github.com/pjtf93))
### Changed
- Hardware cursor is now disabled by default for better terminal compatibility. Set `PI_HARDWARE_CURSOR=1` to enable (replaces `PI_NO_HARDWARE_CURSOR=1` which disabled it).
## [0.47.0] - 2026-01-16
### Breaking Changes
- Extensions using `Editor` directly must now pass `TUI` as the first constructor argument: `new Editor(tui, theme)`. The `tui` parameter is available in extension factory functions. ([#732](https://github.com/badlogic/pi-mono/issues/732))
### Added
- **OpenAI Codex official support**: Full compatibility with OpenAI's Codex CLI models (`gpt-5.1`, `gpt-5.2`, `gpt-5.1-codex-mini`, `gpt-5.2-codex`). Features include static system prompt for OpenAI allowlisting, prompt caching via session ID, and reasoning signature retention across turns. Set `OPENAI_API_KEY` and use `--provider openai-codex` or select a Codex model. ([#737](https://github.com/badlogic/pi-mono/pull/737))
- `pi-internal://` URL scheme in read tool for accessing internal documentation. The model can read files from the coding-agent package (README, docs, examples) to learn about extending pi.
- New `input` event in extension system for intercepting, transforming, or handling user input before the agent processes it. Supports three result types: `continue` (pass through), `transform` (modify text/images), `handled` (respond without LLM). Handlers chain transforms and short-circuit on handled. ([#761](https://github.com/badlogic/pi-mono/pull/761) by [@nicobailon](https://github.com/nicobailon))
- Extension example: `input-transform.ts` demonstrating input interception patterns (quick mode, instant commands, source routing) ([#761](https://github.com/badlogic/pi-mono/pull/761) by [@nicobailon](https://github.com/nicobailon))
- Custom tool HTML export: extensions with `renderCall`/`renderResult` now render in `/share` and `/export` output with ANSI-to-HTML color conversion ([#702](https://github.com/badlogic/pi-mono/pull/702) by [@aliou](https://github.com/aliou))
- Direct filter shortcuts in Tree mode: Ctrl+D (default), Ctrl+T (no-tools), Ctrl+U (user-only), Ctrl+L (labeled-only), Ctrl+A (all) ([#747](https://github.com/badlogic/pi-mono/pull/747) by [@kaofelix](https://github.com/kaofelix))
### Changed
- Skill commands (`/skill:name`) are now expanded in AgentSession instead of interactive mode. This enables skill commands in RPC and print modes, and allows the `input` event to intercept `/skill:name` before expansion.
### Fixed
- Editor no longer corrupts terminal display when loading large prompts via `setEditorText`. Content now scrolls vertically with indicators showing lines above/below the viewport. ([#732](https://github.com/badlogic/pi-mono/issues/732))
- Piped stdin now works correctly: `echo foo | pi` is equivalent to `pi -p foo`. When stdin is piped, print mode is automatically enabled since interactive mode requires a TTY ([#708](https://github.com/badlogic/pi-mono/issues/708))
- Session tree now preserves branch connectors and indentation when filters hide intermediate entries so descendants attach to the nearest visible ancestor and sibling branches align. Fixed in both TUI and HTML export ([#739](https://github.com/badlogic/pi-mono/pull/739) by [@w-winter](https://github.com/w-winter))
- Added `upstream connect`, `connection refused`, and `reset before headers` patterns to auto-retry error detection ([#733](https://github.com/badlogic/pi-mono/issues/733))
- Multi-line YAML frontmatter in skills and prompt templates now parses correctly. Centralized frontmatter parsing using the `yaml` library. ([#728](https://github.com/badlogic/pi-mono/pull/728) by [@richardgill](https://github.com/richardgill))
- `ctx.shutdown()` now waits for pending UI renders to complete before exiting, ensuring notifications and final output are visible ([#756](https://github.com/badlogic/pi-mono/issues/756))
- OpenAI Codex provider now retries on transient errors (429, 5xx, connection failures) with exponential backoff ([#733](https://github.com/badlogic/pi-mono/issues/733))
## [0.46.0] - 2026-01-15
### Fixed
- Scoped models (`--models` or `enabledModels`) now remember the last selected model across sessions instead of always starting with the first model in the scope ([#736](https://github.com/badlogic/pi-mono/pull/736) by [@ogulcancelik](https://github.com/ogulcancelik))
- Show `bun install` instead of `npm install` in update notification when running under Bun ([#714](https://github.com/badlogic/pi-mono/pull/714) by [@dannote](https://github.com/dannote))
- `/skill` prompts now include the skill path ([#711](https://github.com/badlogic/pi-mono/pull/711) by [@jblwilliams](https://github.com/jblwilliams))
- Use configurable `expandTools` keybinding instead of hardcoded Ctrl+O ([#717](https://github.com/badlogic/pi-mono/pull/717) by [@dannote](https://github.com/dannote))
- Compaction turn prefix summaries now merge correctly ([#738](https://github.com/badlogic/pi-mono/pull/738) by [@vsabavat](https://github.com/vsabavat))
- Avoid unsigned Gemini 3 tool calls ([#741](https://github.com/badlogic/pi-mono/pull/741) by [@roshanasingh4](https://github.com/roshanasingh4))
- Fixed signature support for non-Anthropic models in Amazon Bedrock provider ([#727](https://github.com/badlogic/pi-mono/pull/727) by [@unexge](https://github.com/unexge))
- Keyboard shortcuts (Ctrl+C, Ctrl+D, etc.) now work on non-Latin keyboard layouts (Russian, Ukrainian, Bulgarian, etc.) in terminals supporting Kitty keyboard protocol with alternate key reporting ([#718](https://github.com/badlogic/pi-mono/pull/718) by [@dannote](https://github.com/dannote))
### Added
- Edit tool now uses fuzzy matching as fallback when exact match fails, tolerating trailing whitespace, smart quotes, Unicode dashes, and special spaces ([#713](https://github.com/badlogic/pi-mono/pull/713) by [@dannote](https://github.com/dannote))
- Support `APPEND_SYSTEM.md` to append instructions to the system prompt ([#716](https://github.com/badlogic/pi-mono/pull/716) by [@tallshort](https://github.com/tallshort))
- Session picker search: Ctrl+R toggles sorting between fuzzy match (default) and most recent; supports quoted phrase matching and `re:` regex mode ([#731](https://github.com/badlogic/pi-mono/pull/731) by [@ogulcancelik](https://github.com/ogulcancelik))
- Export `getAgentDir` for extensions ([#749](https://github.com/badlogic/pi-mono/pull/749) by [@dannote](https://github.com/dannote))
- Show loaded prompt templates on startup ([#743](https://github.com/badlogic/pi-mono/pull/743) by [@tallshort](https://github.com/tallshort))
- MiniMax China (`minimax-cn`) provider support ([#725](https://github.com/badlogic/pi-mono/pull/725) by [@tallshort](https://github.com/tallshort))
- `gpt-5.2-codex` models for GitHub Copilot and OpenCode Zen providers ([#734](https://github.com/badlogic/pi-mono/pull/734) by [@aadishv](https://github.com/aadishv))
### Changed
- Replaced `wasm-vips` with `@silvia-odwyer/photon-node` for image processing ([#710](https://github.com/badlogic/pi-mono/pull/710) by [@can1357](https://github.com/can1357))
- Extension example: `plan-mode/` shortcut changed from Shift+P to Ctrl+Alt+P to avoid conflict with typing capital P ([#746](https://github.com/badlogic/pi-mono/pull/746) by [@ferologics](https://github.com/ferologics))
- UI keybinding hints now respect configured keybindings across components ([#724](https://github.com/badlogic/pi-mono/pull/724) by [@dannote](https://github.com/dannote))
- CLI process title is now set to `pi` for easier process identification ([#742](https://github.com/badlogic/pi-mono/pull/742) by [@richardgill](https://github.com/richardgill))
## [0.45.7] - 2026-01-13
### Added
- Exported `highlightCode` and `getLanguageFromPath` for extensions ([#703](https://github.com/badlogic/pi-mono/pull/703) by [@dannote](https://github.com/dannote))
## [0.45.6] - 2026-01-13
### Added
- `ctx.ui.custom()` now accepts `overlayOptions` for overlay positioning and sizing (anchor, margins, offsets, percentages, absolute positioning) ([#667](https://github.com/badlogic/pi-mono/pull/667) by [@nicobailon](https://github.com/nicobailon))
- `ctx.ui.custom()` now accepts `onHandle` callback to receive the `OverlayHandle` for controlling overlay visibility ([#667](https://github.com/badlogic/pi-mono/pull/667) by [@nicobailon](https://github.com/nicobailon))
- Extension example: `overlay-qa-tests.ts` with 10 commands for testing overlay positioning, animation, and toggle scenarios ([#667](https://github.com/badlogic/pi-mono/pull/667) by [@nicobailon](https://github.com/nicobailon))
- Extension example: `doom-overlay/` - DOOM game running as an overlay at 35 FPS (auto-downloads WAD on first run) ([#667](https://github.com/badlogic/pi-mono/pull/667) by [@nicobailon](https://github.com/nicobailon))Now let me read the relevant source files to understand the existing code and validate the changes. I need to read:
Let me read these files in full.
Now let me read the relevant source files on main to understand the changes:
Now I need to read the interactive-mode.ts file to see how the session context is being used, and check the index.ts exports. Let me also check the docs for any required documentation updates.
Let me read the interactive-mode.ts and check documentation requirements:
Let me check the current docs directory to see what documentation files exist and if they need to be updated:
Let me check the docs directory and the extension documentation:
>ls -la /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/total 408
drwxr-xr-x@ 11 badlogic staff 352 Jan 16 21:49 .
drwxr-xr-x@ 16 badlogic staff 512 Jan 16 22:17 ..
-rw-r--r--@ 1 badlogic staff 14757 Jan 3 00:47 compaction.md
-rw-r--r--@ 1 badlogic staff 52716 Jan 16 21:49 extensions.md
-rw-r--r--@ 1 badlogic staff 23836 Jan 16 12:47 rpc.md
-rw-r--r--@ 1 badlogic staff 29668 Jan 16 21:49 sdk.md
-rw-r--r--@ 1 badlogic staff 9717 Jan 12 23:33 session.md
-rw-r--r--@ 1 badlogic staff 9389 Jan 12 23:33 skills.md
-rw-r--r--@ 1 badlogic staff 16734 Jan 8 19:37 theme.md
-rw-r--r--@ 1 badlogic staff 6645 Jan 16 21:49 tree.md
-rw-r--r--@ 1 badlogic staff 25785 Jan 16 12:47 tui.mdNow let me check if the PR adds new exports and documentation requirements. Let me also look at the extension context to understand what the PR adds:
From the PR diff, I can see:
ctx.session is added to ExtensionContext (line 328+ in runner.ts)isEmbeddedSession and parentSessionId are added to ExtensionCommandContext (line 355+ in runner.ts)SessionManager.createEmbedded() methodSessionManager.getEntriesInPath() methodEmbeddedSessionRefEntry typeappendEmbeddedSessionRef() methodEmbeddedSessionRefComponentEmbeddedSessionRefEntry, EmbeddedSessionRefComponent, EmbeddedSessionRefComponentConfig, getEditorTheme, themeLet me check what the current index.ts exports look like after the PR and verify the session-manager.ts changes more closely.
Let me also check what the isEmbedded() and getParentSessionId() methods rely on:
>cd /Users/badlogic/workspaces/pi-mono && grep -n "ExtensionContext" packages/coding-agent/docs/extensions.md | head -2041:- [ExtensionContext](#extensioncontext)
164:| `@mariozechner/pi-coding-agent` | Extension types (`ExtensionAPI`, `ExtensionContext`, events) |
628:## ExtensionContext
630:Every handler receives `ctx: ExtensionContext`:
682:Command handlers receive `ExtensionCommandContext`, which extends `ExtensionContext` with session control methods. These are only available in commands because they can deadlock if called from event handlers.Now let me check the ExtensionContext section to see if it needs updating for the new ctx.session property:
Let me see the ExtensionContext section in the docs:
Now I have a full picture of the PR. Let me summarize what I found:
PR Changes:
Core session-manager additions (~188 lines):
SessionManager.createEmbedded(parentId, cwd, options?) - creates embedded session files in ~/.pi/agent/sessions/embedded/{parent-id}/SessionManager.getEntriesInPath() - returns entries from root to current leafSessionManager.isEmbedded() - checks if session has a parentSessionManager.getParentSessionId() - returns parent session IDSessionManager.appendEmbeddedSessionRef() - stores embedded session referenceSessionManager.cleanupEmbeddedSessions() - cleanup utilityEmbeddedSessionRefEntry typeExtension context additions:
ctx.session exposed in ExtensionContext - gives extensions access to AgentSessionctx.isEmbeddedSession in ExtensionCommandContextctx.parentSessionId in ExtensionCommandContextInteractive mode additions:
renderEmbeddedSessionRefs() method to render embedded session refs on resumeEmbeddedSessionRefComponent for UI displayNew exports in index.ts:
EmbeddedSessionRefEntry typeEmbeddedSessionRefComponent and EmbeddedSessionRefComponentConfiggetEditorTheme and themeExample extension (~900 lines):
examples/extensions/embedded-sessions/Issues:
No CHANGELOG entry for this PR
Documentation in docs/extensions.md needs to be updated to document ctx.session, ctx.isEmbeddedSession, and ctx.parentSessionId
The extension context documentation is missing details about the new session-related properties available to extensions.
Now I have all the information needed. Let me compile the review:
PR: https://github.com/badlogic/pi-mono/pull/789
Changelog:
packages/coding-agent/CHANGELOG.md. A changelog entry is required before merge. I will add it if you decide to merge.Documentation:
packages/coding-agent/docs/extensions.md needs updating:
ExtensionContext section should document the new ctx.session propertyExtensionCommandContext section should document ctx.isEmbeddedSession and ctx.parentSessionIdexamples/extensions/embedded-sessions/ has its own README.md which is comprehensiveGood:
session-manager.ts, extension context in runner.ts/types.ts, UI in embedded-session-ref.ts, example in a separate directoryEmbeddedSessionRefEntry follows the existing pattern of session entries (like CustomEntry, LabelEntry)getEntriesInPath() is a useful utility that doesn't affect existing code pathsSessionManager.createEmbedded() properly reuses existing infrastructure with inMemory() and newSession() patternscleanupEmbeddedSessions() includes a dryRun option for safetynewSessionOptions is backward-compatible since the param is optional and only used internallyBad:
ctx.session getter in ExtensionContext throws on access if getSessionFn hasn't been set yet (line 338 in runner.ts: "session not available yet"). This could cause runtime crashes if accessed during extension loading before initialize() is called.renderEmbeddedSessionRefs() is called twice: once after renderInitialMessages() in both loadSession() and rerenderChat() (lines 2045 and 2067). If the session has embedded refs, they would be rendered twice.appendEmbeddedSessionRef doesn't track read/modified files in the LLM context (correctly), but the comment says "for UI display only" while the token data is stored. The naming/purpose could be clearer.test/compaction-extensions.test.ts change only adds getSession to the mock, but there are no tests for createEmbedded(), getEntriesInPath(), isEmbedded(), getParentSessionId(), or cleanupEmbeddedSessions().Ugly:
renderEmbeddedSessionRefs() iterates ALL entries in path on every call (including after branching), which could be slow for deeply nested sessions with many embedded refs. This is called on every re-render in rerenderChat().cleanupEmbeddedSessions() method is static and defined but never called anywhere in the codebase. Orphan code that may never run.Questions or Assumptions:
cleanupEmbeddedSessions() intended to be called during pi startup or manually? If startup, that integration is missing.EmbeddedSessionRefComponent shows collapsed/expanded views but setExpanded() is called but the component doesn't seem to have any UI affordance to toggle expansion. Is this intentional (controlled externally)?[...this.parentSession.promptTemplates], [...this.parentSession.skills]).Change summary:
SessionManager.createEmbedded() creates session files in ~/.pi/agent/sessions/embedded/{parent-id}/SessionManager.getEntriesInPath() returns path from root to leafctx.session exposed in ExtensionContext for AgentSession accessctx.isEmbeddedSession / ctx.parentSessionId in ExtensionCommandContextEmbeddedSessionRefEntry type and appendEmbeddedSessionRef() for storing session referencesEmbeddedSessionRefComponent renders references in parent chattheme and getEditorTheme for extension stylingexamples/extensions/embedded-sessions/Tests:
test/compaction-extensions.test.ts (adds getSession)createEmbedded, getEntriesInPath, isEmbedded, getParentSessionId, appendEmbeddedSessionRef, cleanupEmbeddedSessions)