Reviewed GitHub issue and related code.
UnderstandCreated minimal test extension.
BuildTested extension and confirmed fix.
VerifyAdded changelog entry, committed, and pushed.
EditAnalyze GitHub issue(s): https://github.com/badlogic/pi-mono/issues/968
For each issue:
Read the issue in full, including all comments and linked issues/PRs.
For bugs:
For feature requests:
Do NOT implement unless explicitly asked. Analyze and propose only.
The user wants me to analyze GitHub issue #968. Let me fetch the issue details first.
>gh issue view 968 --repo badlogic/pi-mono --json title,body,comments,labels,state{"body":"### What happened?\n\nWanted to try out a simple extension that `pi` sounds like it could do where open code couldn't, and I got it to work but it feels like I'm hacking something\n\nBasically I want to try compacting not by summarising, but by asking the LLM to create the convo from scratch, message-by-message, just shorter. I have a mega-hacky way to do that, but when creating a newSession to put those messages in, the UI doesn't refresh cleanly and the LLM doesn't seem to get the real context.\nI end up calling `navigateTree` twice to fix it (for some reason).\n\nFull extension code attached if you want to see it.\n\n[message-compactor-command.ts](https://github.com/user-attachments/files/24867865/message-compactor-command.ts)\n\n### Steps to reproduce\n\nRun the above command without the \"double-navigate\" hack and see weirdness. Navigating once fixes the UI but not the LLM context it seems.\n\n### Expected behavior\n\nI'd expect this to just work, beyond potentially the wackiness of faking AI messages.\n\n### Version\n\n0.50.0","comments":[],"labels":[{"id":"LA_kwDOPbFNk88AAAACHWTBxw","name":"bug","description":"Something isn't working","color":"d73a4a"}],"state":"OPEN","title":"Creating new sessions in the UI with appended messages seems a little broken"}Let me download the attached extension file to understand what the user is trying to do. The issue is about creating new sessions with messages from an extension and having UI/context issues.
Let me first look at the extension code, then understand how sessions work in the coding-agent.
Let me download and examine the attached extension file:
>curl -sL "https://github.com/user-attachments/files/24867865/message-compactor-command.ts" -o /tmp/message-compactor-command.ts && cat /tmp/message-compactor-command.ts/**
* Message Compactor Command Extension
*
* This extension provides a /compact-messages command that creates a new session,
* forks with the beginning of the conversation, and summarizes each message
* to create a more concise version of the conversation.
*/
import {
buildSessionContext,
convertToLlm,
serializeConversation,
type ExtensionAPI,
type SessionEntry,
} from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { complete, getModel } from "@mariozechner/pi-ai";
export default function (pi: ExtensionAPI) {
pi.registerCommand("compact-messages", {
description: "Create a new session with summarized messages",
handler: async (args, ctx) => {
if (!ctx.hasUI) {
ctx.ui.notify("compact-messages requires interactive mode", "error");
return;
}
if (!ctx.model) {
ctx.ui.notify("No model selected", "error");
return;
}
// Get current session information
const currentSessionFile = ctx.sessionManager.getSessionFile();
const branch = ctx.sessionManager.getBranch();
if (branch.length === 0) {
ctx.ui.notify("No conversation to compact", "error");
return;
}
interface TextContent {
type: "text";
text: string;
}
interface ImageContent {
type: "image";
data: string; // base64 encoded
mimeType: string; // e.g., "image/jpeg", "image/png"
}
interface ThinkingContent {
type: "thinking";
thinking: string;
}
interface ToolCall {
type: "toolCall";
id: string;
name: string;
arguments: Record<string, any>;
}
let combined = "";
let actual_messages = [];
for (let entry of branch) {
if (!("message" in entry)) continue;
let msgs = convertToLlm([entry.message]);
// Messages may be combinatory, we'll uncombine.
for (let mess of msgs)
for (let ct of mess.content) {
if (typeof ct === "string") {
actual_messages.push(`[${entry.message.role}]: ${ct}`);
} else if (ct.type == "text") {
actual_messages.push(`[${entry.message.role}]: ${ct.text}`);
} else if (ct.type == "thinking") {
actual_messages.push(`[${entry.message.role} thinking]: ${ct.thinking}`);
} else if (ct.type == "toolCall") {
actual_messages.push(`[tool call]: ${ct.name}`);
}
}
}
combined = actual_messages.join("\n\n");
if (combined.length === 0) {
ctx.ui.notify("No messages to compact", "error");
return;
}
ctx.ui.notify(`Compacting ${combined.length} chars into a new session...`, "info");
// Get API key for the model
const apiKey = await ctx.modelRegistry.getApiKey(ctx.model);
if (!apiKey) {
ctx.ui.notify(`No API key for ${ctx.model.provider}`, "error");
return;
}
// Build prompt for summarizing each message
const summaryMessages = [
{
role: "user" as const,
content: [
{
type: "text" as const,
text: `Here is a conversation between a user and an LLM:
${combined}
Rewrite this conversation to make it much shorter:
1. Analyze the overall flow and key decisions
2. Cut down each message to keep only the essential meaning.
3. Keep it coherent and logical.
You may freely change the style or potentially shits sections around, skip un-necessary bits.
Return only the rewritte conversation, with the appropriate [user] / [assistant] / [thinking] / [tool call] tags as required.`,
},
],
timestamp: Date.now(),
},
];
try {
// Request summary from LLM
const response = await complete(ctx.model, { messages: summaryMessages }, { apiKey, maxTokens: 32000 });
const summary = response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n");
if (!summary.trim()) {
ctx.ui.notify("Compaction summary was empty", "error");
return;
}
/*export interface UserMessage {
role: "user";
content: string | (TextContent | ImageContent)[];
timestamp: number;
}
export interface AssistantMessage {
role: "assistant";
content: (TextContent | ThinkingContent | ToolCall)[];
api: Api;
provider: Provider;
model: string;
usage: Usage;
stopReason: StopReason;
errorMessage?: string;
timestamp: number;
}
export interface ToolResultMessage<TDetails = any> {
role: "toolResult";
toolCallId: string;
toolName: string;
content: (TextContent | ImageContent)[];
details?: TDetails;
isError: boolean;
timestamp: number;
}*/
function splitByRoles(text: string) {
return text
.split(/(?=\[[^\]]+\]:)/g)
.map((s) => s.trim())
.filter(Boolean);
}
// Create new session with parent tracking
let new_id;
const newSessionResult = await ctx.newSession({
parentSession: currentSessionFile,
setup: async (newSessionManager) => {
//await ctx.ui.editor("totoro", summary);
// Let's split
const split = splitByRoles(summary);
let bufferedThinking = [];
for (const msg of split) {
if (msg.indexOf("[user") !== -1) {
new_id = newSessionManager.appendMessage({
role: "user",
content: [
{
type: "text" as const,
text: msg.slice(msg.indexOf("]") + 1),
},
],
timestamp: Date.now(),
});
} else if (msg.indexOf("think") !== -1) {
bufferedThinking.push(msg.slice(msg.indexOf("]") + 1));
} else if (true) {
new_id = newSessionManager.appendMessage({
role: "assistant",
content: [
...bufferedThinking.map((x) => ({ type: "thinking" as const, thinking: x })),
{ type: "text", text: msg.slice(msg.indexOf("]") + 1) },
],
api: ctx.model!.api,
provider: ctx.model!.provider,
model: ctx.model!.id,
stopReason: "stop",
timestamp: Date.now(),
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
},
});
bufferedThinking = [];
}
}
},
});
if (newSessionResult.cancelled) {
ctx.ui.notify("New session creation was cancelled", "info");
return;
}
if (new_id) {
await ctx.navigateTree(ctx.sessionManager.getTree()[0].entry.id);
await ctx.navigateTree(new_id);
ctx.ui.setEditorText("");
}
ctx.ui.notify(`Created new session with messages compacted to summary.`, "info");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
ctx.ui.notify(`Compaction failed: ${message}`, "error");
}
},
});
}Now I understand the issue. The user is creating a new session with messages via ctx.newSession() with a setup callback that appends messages. After the session is created, they have to call navigateTree twice to get the UI and LLM context in sync.
Let me look at the relevant code to understand:
newSession worksnavigateTree worksLet me look at the extension API and the session management code.
Now let me examine the relevant code. I'll look at how newSession and navigateTree work:
>rg -l "newSession|navigateTree" packages/coding-agent/src --type tspackages/coding-agent/src/modes/rpc/rpc-mode.ts
packages/coding-agent/src/modes/rpc/rpc-client.ts
packages/coding-agent/src/modes/print-mode.ts
packages/coding-agent/src/core/session-manager.ts
packages/coding-agent/src/core/extensions/runner.ts
packages/coding-agent/src/core/agent-session.ts
packages/coding-agent/src/modes/interactive/interactive-mode.ts
packages/coding-agent/src/core/extensions/types.ts>rg -l "ExtensionAPI" packages/coding-agent/src --type tspackages/coding-agent/src/index.ts
packages/coding-agent/src/core/sdk.ts
packages/coding-agent/src/core/index.ts
packages/coding-agent/src/core/extensions/index.ts
packages/coding-agent/src/core/extensions/types.ts
packages/coding-agent/src/core/extensions/loader.tsLet me read the core files related to extensions and session management to understand the issue.
Now I need to look at how newSession and navigateTree are implemented in the interactive mode. The issue is about creating a new session with messages and having the UI/LLM context not properly updated.
Let me look at the interactive mode implementation.
Let me continue reading the interactive-mode.ts file to see the implementation of newSession and navigateTree handlers.
Let me continue reading the file and also look at the agent-session.ts file:
Let me continue reading the agent-session.ts file to find the implementation of navigateTree:
Now I understand the code flow. Let me analyze the issue:
The user is creating an extension that:
ctx.newSession()setup callback, appends messages to the session managernavigateTree twice to get things working:
Looking at the code in interactive-mode.ts (around line 875-905), here's the newSession handler:
newSession: async (options) => { if (this.loadingAnimation) { this.loadingAnimation.stop(); this.loadingAnimation = undefined; } this.statusContainer.clear();
const success = await this.session.newSession({ parentSession: options?.parentSession }); if (!success) { return { cancelled: true }; }
if (options?.setup) { await options.setup(this.sessionManager); // <-- MESSAGES ARE APPENDED HERE }
// Clear UI state this.chatContainer.clear(); this.pendingMessagesContainer.clear(); this.compactionQueuedMessages = []; this.streamingComponent = undefined; this.streamingMessage = undefined; this.pendingTools.clear();
// Show new session message this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1)); this.ui.requestRender();
return { cancelled: false };};And in agent-session.ts, the newSession method (around line 585):
async newSession(options?: NewSessionOptions): Promise<boolean> { // ...snip... this._disconnectFromAgent(); await this.abort(); this.agent.reset(); // <-- AGENT STATE IS RESET this.sessionManager.newSession(options); this.agent.sessionId = this.sessionManager.getSessionId(); this._steeringMessages = []; this._followUpMessages = []; this._pendingNextTurnMessages = []; this._reconnectToAgent(); // ... return true;}The problem is the ordering: agent.reset() clears the agent's internal state before the setup callback runs, so when messages are appended via sessionManager, the agent doesn't know about them. The agent needs to be reconnected after the setup callback completes so it can pick up the newly added messages.
Looking at navigateTree, it explicitly syncs the agent state by calling agent.replaceMessages() with the session context. This is why calling navigateTree twice works—the first call rebuilds the agent's message state from the session manager, and the second call operates on the now-synchronized state. The initial newSession flow is missing this synchronization step after setup runs. The user calls navigateTree twice—first to the root entry, then to new_id. Looking at the navigateTree handler, it clears the chat container and re-renders the UI, but the real issue emerges when examining the flow: after newSession with setup, the agent's messages aren't synchronized via agent.replaceMessages(). The session manager builds the context and syncs messages to the agent during navigateTree, but this synchronization step appears to be missing in the initial setup phase.
The newSession handler needs to explicitly sync the session context to the agent and render the initial messages to the UI after setup completes, rather than just clearing the interface and displaying a status message. If setup was provided, display a confirmation message in the chat container and request a UI render. Return success status.
The agent and session manager states need to stay synchronized, with the session manager as the authoritative source. After setup completes, build the context from the session manager and sync those messages to the agent. When checking the extension code, appendMessage returns the entry ID that gets assigned to new_id.
The navigation sequence first goes to the root node, then to the newly appended message ID—this is a temporary workaround. The proper solution involves fixing newSession to ensure state consistency once setup() finishes.
Now I have a complete picture of the issue. Let me summarize my analysis:
When an extension creates a new session using ctx.newSession() with a setup callback that appends messages, the UI and LLM context don't properly reflect those messages. The user has to call navigateTree twice as a workaround.
In packages/coding-agent/src/modes/interactive/interactive-mode.ts, the newSession handler has a fundamental flaw:
// lines ~875-905newSession: async (options) => { // ... const success = await this.session.newSession({ parentSession: options?.parentSession }); if (options?.setup) { await options.setup(this.sessionManager); // Messages are appended to SessionManager }
// BUT: Agent state is NOT synced after setup() // The agent was reset in session.newSession(), and messages added via setup() // are only in the SessionManager, not in the Agent's state.
this.chatContainer.clear(); // ...renders "✓ New session started" instead of the actual messages this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1)); return { cancelled: false };};The issue is that:
this.session.agent.replaceMessages() is never called after setup() runs, so the Agent has no knowledge of the appended messagesrenderInitialMessages() is never called, so the chat container just shows "New session started" instead of the actual messagesThe navigateTree workaround works because it:
sessionManager.buildSessionContext() to get messagesagent.replaceMessages(sessionContext.messages) to sync the agentrenderInitialMessages() to render the UIIn packages/coding-agent/src/modes/interactive/interactive-mode.ts, modify the newSession handler:
Before:
if (options?.setup) { await options.setup(this.sessionManager);}
this.chatContainer.clear();// ...this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1));After:
if (options?.setup) { await options.setup(this.sessionManager); // Sync agent state with session manager after setup const sessionContext = this.sessionManager.buildSessionContext(); this.session.agent.replaceMessages(sessionContext.messages);}
this.chatContainer.clear();this.pendingMessagesContainer.clear();this.compactionQueuedMessages = [];this.streamingComponent = undefined;this.streamingMessage = undefined;this.pendingTools.clear();
// Render messages if any were added via setup, otherwise show simple confirmationconst hasMessages = this.sessionManager.getBranch().some(e => e.type === 'message');if (hasMessages) { this.renderInitialMessages();} else { this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1));}
this.ui.requestRender();packages/coding-agent/src/modes/interactive/interactive-mode.ts (lines ~875-910)The sessionManager.appendMessage() returns an entry ID, but doesn't update the leaf pointer. After appending multiple messages, the session tree structure may need the leaf to be set to the last entry. This might require also calling sessionManager.branch(lastEntryId) or similar.
The RPC mode (packages/coding-agent/src/modes/rpc/rpc-mode.ts) may have a similar issue if it exposes newSession with setup callbacks.
what. why doesn't this go through agent-session.ts which should handle all this crap, and interactive-mode and rpc mode just do the UI/RPC parts? also, why would appendMessage on session manager not push the leaf??
The user is making two excellent points:
Architecture concern: Why is the newSession handler in interactive-mode.ts doing agent state management? This should be handled in agent-session.ts, and the modes should only handle their specific I/O (UI for interactive, RPC protocol for RPC).
SessionManager.appendMessage not updating leaf: Why doesn't appendMessage on the session manager automatically update the leaf pointer? That seems like a fundamental expectation - when you append a message, it should become the new leaf.
Let me re-examine the code to understand the current architecture and why these issues exist.
Looking at agent-session.ts:
newSession() calls this.sessionManager.newSession(options) and this.agent.reset()setup callback at all!Looking at the extension types in types.ts:
newSession(options?: { parentSession?: string; setup?: (sessionManager: SessionManager) => Promise<void>;}): Promise<{ cancelled: boolean }>;The setup callback is defined in the extension API types, but it's handled by the interactive mode, not by agent-session.ts.
And for SessionManager.appendMessage(), let me check the session-manager.ts file to see if it updates the leaf.
You're absolutely right. Let me check the actual architecture:
>rg "appendMessage" packages/coding-agent/src/core/session-manager.ts -A 10const appendMessage = (entry: SessionEntry) => {
if (entry.type === "message") {
messages.push(entry.message);
} else if (entry.type === "custom_message") {
messages.push(
createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp),
);
} else if (entry.type === "branch_summary" && entry.summary) {
messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));
}
};
--
appendMessage(entry);
}
}
// Emit messages after compaction
for (let i = compactionIdx + 1; i < path.length; i++) {
const entry = path[i];
appendMessage(entry);
}
} else {
// No compaction - emit all messages, handle branch summaries and custom messages
for (const entry of path) {
appendMessage(entry);
}
}
return { messages, thinkingLevel, model };
}
/**
* Compute the default session directory for a cwd.
* Encodes cwd into a safe directory name under ~/.pi/agent/sessions/.
*/
--
appendMessage(message: Message | CustomMessage | BashExecutionMessage): string {
const entry: SessionMessageEntry = {
type: "message",
id: generateId(this.byId),
parentId: this.leafId,
timestamp: new Date().toISOString(),
message,
};
this._appendEntry(entry);
return entry.id;
}Let me look at _appendEntry to see if it updates the leaf:
>rg "_appendEntry" packages/coding-agent/src/core/session-manager.ts -A 15private _appendEntry(entry: SessionEntry): void {
this.fileEntries.push(entry);
this.byId.set(entry.id, entry);
this.leafId = entry.id;
this._persist(entry);
}
/** Append a message as child of current leaf, then advance leaf. Returns entry id.
* Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly.
* Reason: we want these to be top-level entries in the session, not message session entries,
* so it is easier to find them.
* These need to be appended via appendCompaction() and appendBranchSummary() methods.
*/
appendMessage(message: Message | CustomMessage | BashExecutionMessage): string {
const entry: SessionMessageEntry = {
type: "message",
--
this._appendEntry(entry);
return entry.id;
}
/** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */
appendThinkingLevelChange(thinkingLevel: string): string {
const entry: ThinkingLevelChangeEntry = {
type: "thinking_level_change",
id: generateId(this.byId),
parentId: this.leafId,
timestamp: new Date().toISOString(),
thinkingLevel,
};
this._appendEntry(entry);
return entry.id;
}
/** Append a model change as child of current leaf, then advance leaf. Returns entry id. */
appendModelChange(provider: string, modelId: string): string {
const entry: ModelChangeEntry = {
type: "model_change",
id: generateId(this.byId),
parentId: this.leafId,
timestamp: new Date().toISOString(),
provider,
modelId,
};
this._appendEntry(entry);
return entry.id;
}
/** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */
appendCompaction<T = unknown>(
summary: string,
firstKeptEntryId: string,
tokensBefore: number,
details?: T,
fromHook?: boolean,
): string {
const entry: CompactionEntry<T> = {
type: "compaction",
id: generateId(this.byId),
parentId: this.leafId,
--
this._appendEntry(entry);
return entry.id;
}
/** Append a custom entry (for extensions) as child of current leaf, then advance leaf. Returns entry id. */
appendCustomEntry(customType: string, data?: unknown): string {
const entry: CustomEntry = {
type: "custom",
customType,
data,
id: generateId(this.byId),
parentId: this.leafId,
timestamp: new Date().toISOString(),
};
this._appendEntry(entry);
return entry.id;
}
/** Append a session info entry (e.g., display name). Returns entry id. */
appendSessionInfo(name: string): string {
const entry: SessionInfoEntry = {
type: "session_info",
id: generateId(this.byId),
parentId: this.leafId,
timestamp: new Date().toISOString(),
name: name.trim(),
};
this._appendEntry(entry);
return entry.id;
}
/** Get the current session name from the latest session_info entry, if any. */
getSessionName(): string | undefined {
// Walk entries in reverse to find the latest session_info with a name
const entries = this.getEntries();
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (entry.type === "session_info" && entry.name) {
return entry.name;
}
}
return undefined;
}
--
this._appendEntry(entry);
return entry.id;
}
// =========================================================================
// Tree Traversal
// =========================================================================
getLeafId(): string | null {
return this.leafId;
}
getLeafEntry(): SessionEntry | undefined {
return this.leafId ? this.byId.get(this.leafId) : undefined;
}
--
this._appendEntry(entry);
if (label) {
this.labelsById.set(targetId, label);
} else {
this.labelsById.delete(targetId);
}
return entry.id;
}
/**
* Walk from entry to root, returning all entries in path order.
* Includes all entry types (messages, compaction, model changes, etc.).
* Use buildSessionContext() to get the resolved messages for the LLM.
*/
getBranch(fromId?: string): SessionEntry[] {
const path: SessionEntry[] = [];
--
this._appendEntry(entry);
return entry.id;
}
/**
* Create a new session file containing only the path from root to the specified leaf.
* Useful for extracting a single conversation path from a branched session.
* Returns the new session file path, or undefined if not persisting.
*/
createBranchedSession(leafId: string): string | undefined {
const path = this.getBranch(leafId);
if (path.length === 0) {
throw new Error(`Entry ${leafId} not found`);
}
// Filter out LabelEntry from path - we'll recreate them from the resolved mapSo _appendEntry DOES update the leaf:
private _appendEntry(entry: SessionEntry): void { this.fileEntries.push(entry); this.byId.set(entry.id, entry); this.leafId = entry.id; // <-- LEAF IS UPDATED this._persist(entry);}So appendMessage correctly updates the leaf. The issue is purely that after the setup callback runs in the interactive mode's newSession handler, the agent's state is not synced.
Now let me check if newSession in agent-session.ts even supports a setup callback:
>rg "async newSession" packages/coding-agent/src/core/agent-session.ts -A 30async newSession(options?: NewSessionOptions): Promise<boolean> {
const previousSessionFile = this.sessionFile;
// Emit session_before_switch event with reason "new" (can be cancelled)
if (this._extensionRunner?.hasHandlers("session_before_switch")) {
const result = (await this._extensionRunner.emit({
type: "session_before_switch",
reason: "new",
})) as SessionBeforeSwitchResult | undefined;
if (result?.cancel) {
return false;
}
}
this._disconnectFromAgent();
await this.abort();
this.agent.reset();
this.sessionManager.newSession(options);
this.agent.sessionId = this.sessionManager.getSessionId();
this._steeringMessages = [];
this._followUpMessages = [];
this._pendingNextTurnMessages = [];
this._reconnectToAgent();
// Emit session_switch event with reason "new" to extensions
if (this._extensionRunner) {
await this._extensionRunner.emit({
type: "session_switch",
reason: "new",
previousSessionFile,I see. agent-session.ts has a newSession(options?: NewSessionOptions) method, but let me check what NewSessionOptions includes:
>rg "NewSessionOptions" packages/coding-agent/src/core/session-manager.ts -B 2 -A 10}
export interface NewSessionOptions {
parentSession?: string;
}
export interface SessionEntryBase {
type: string;
id: string;
parentId: string | null;
timestamp: string;
}
--
}
newSession(options?: NewSessionOptions): string | undefined {
this.sessionId = randomUUID();
const timestamp = new Date().toISOString();
const header: SessionHeader = {
type: "session",
version: CURRENT_SESSION_VERSION,
id: this.sessionId,
timestamp,
cwd: this.cwd,
parentSession: options?.parentSession,
};Now I understand the full picture:
NewSessionOptions in session-manager.ts only has parentSession?: stringsetup callback is defined in the extension API types (types.ts) for ExtensionCommandContext.newSession()AgentSession.newSession() doesn't have a setup callback - it's handled entirely in interactive-mode.tsThis is the architectural issue the user is pointing out: The setup callback should be handled in agent-session.ts, not in the mode-specific code.
The user is right - agent-session.ts should:
setup callback in its newSession methodsetup(this.sessionManager)agent.replaceMessages() to sync the agent stateThen interactive-mode and rpc-mode would just:
session.newSession({ setup: ... })Let me also verify that appendMessage does update the leaf correctly (which it does based on the code above), so that part is fine.
You're completely right. Let me trace the architecture issue:
>rg "newSession.*setup" packages/coding-agent/src --type ts -B 2 -A 5(no output)
Command exited with code 1Let me check the extension types for the newSession signature:
>rg "newSession" packages/coding-agent/src/core/extensions/types.ts -B 2 -A 8/** Start a new session, optionally with initialization. */
newSession(options?: {
parentSession?: string;
setup?: (sessionManager: SessionManager) => Promise<void>;
}): Promise<{ cancelled: boolean }>;
/** Fork from a specific entry, creating a new session file. */
fork(entryId: string): Promise<{ cancelled: boolean }>;
/** Navigate to a different point in the session tree. */
--
export interface ExtensionCommandContextActions {
waitForIdle: () => Promise<void>;
newSession: (options?: {
parentSession?: string;
setup?: (sessionManager: SessionManager) => Promise<void>;
}) => Promise<{ cancelled: boolean }>;
fork: (entryId: string) => Promise<{ cancelled: boolean }>;
navigateTree: (
targetId: string,
options?: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string },
) => Promise<{ cancelled: boolean }>;So the setup callback is defined in:
ExtensionCommandContext.newSession() - what extensions seeExtensionCommandContextActions.newSession - the implementation interfaceBut AgentSession.newSession() doesn't have a setup parameter at all. The setup callback is entirely handled in interactive-mode.ts line ~875-905.
This is exactly the architectural issue the user is pointing out:
AgentSession should handle setup and agent state syncInteractiveMode should just do UI renderingRPCMode should just do RPC protocolAlso, looking back at _appendEntry:
private _appendEntry(entry: SessionEntry): void { this.fileEntries.push(entry); this.byId.set(entry.id, entry); this.leafId = entry.id; // <-- LEAF IS UPDATED this._persist(entry);}The leaf IS updated when you appendMessage. So that part is fine.
The only missing piece is syncing agent state after setup. The fix should be:
setup callback to AgentSession.newSession()setup() runs, call this.agent.replaceMessages(this.sessionManager.buildSessionContext().messages)So the issue is clear:
appendMessage DOES update the leaf - that's fine:
private _appendEntry(entry: SessionEntry): void { this.fileEntries.push(entry); this.byId.set(entry.id, entry); this.leafId = entry.id; // ✓ Leaf is updated this._persist(entry);}The setup callback is defined in extension types but handled in interactive-mode, not agent-session:
ExtensionCommandContext.newSession({ setup }) is what extensions callAgentSession.newSession() has NO setup parametersetup locally and never syncs agent stateMove setup handling to AgentSession.newSession():
File: packages/coding-agent/src/core/agent-session.ts
// Change NewSessionOptions or add setup parameterasync newSession(options?: { parentSession?: string; setup?: (sessionManager: SessionManager) => Promise<void>;}): Promise<boolean> { // ... existing event emission and abort logic ... this._disconnectFromAgent(); await this.abort(); this.agent.reset(); this.sessionManager.newSession({ parentSession: options?.parentSession }); this.agent.sessionId = this.sessionManager.getSessionId(); this._steeringMessages = []; this._followUpMessages = []; this._pendingNextTurnMessages = []; // NEW: Run setup if provided if (options?.setup) { await options.setup(this.sessionManager); // Sync agent state with session manager const sessionContext = this.sessionManager.buildSessionContext(); this.agent.replaceMessages(sessionContext.messages); } this._reconnectToAgent(); // ... rest of method ...}File: packages/coding-agent/src/modes/interactive/interactive-mode.ts
Simplify the handler to just do UI:
newSession: async (options) => { // Clear UI loading state if (this.loadingAnimation) { this.loadingAnimation.stop(); this.loadingAnimation = undefined; } this.statusContainer.clear();
// Delegate to AgentSession (handles setup + agent sync) const success = await this.session.newSession(options); if (!success) { return { cancelled: true }; }
// UI cleanup this.chatContainer.clear(); this.pendingMessagesContainer.clear(); this.compactionQueuedMessages = []; this.streamingComponent = undefined; this.streamingMessage = undefined; this.pendingTools.clear();
// Render messages (works whether setup added messages or not) this.renderInitialMessages(); this.ui.requestRender();
return { cancelled: false };},Affected Files:
packages/coding-agent/src/core/agent-session.ts - Add setup parameter, sync agent after setuppackages/coding-agent/src/core/session-manager.ts - No changes needed (already exports NewSessionOptions, may need to extend it or keep separate)packages/coding-agent/src/modes/interactive/interactive-mode.ts - Remove local setup handling, just call renderInitialMessages()packages/coding-agent/src/modes/rpc/rpc-mode.ts - Check if it has similar issuesimplemnent
The user wants me to implement the fix. Let me:
packages/coding-agent/src/core/agent-session.ts - Add setup parameter to newSession()packages/coding-agent/src/modes/interactive/interactive-mode.ts - Simplify the handlerLet me start by reading the current implementation more carefully.
>rg -n "async newSession" packages/coding-agent/src/core/agent-session.ts1055: async newSession(options?: NewSessionOptions): Promise<boolean> {>rg -n "newSession:" packages/coding-agent/src/modes/interactive/interactive-mode.ts991: newSession: async (options) => {Let me read the exact sections I need to modify.
Now I need to check what NewSessionOptions is imported from and how to extend it properly. Let me also check the imports in agent-session.ts.
>rg "NewSessionOptions" packages/coding-agent/src/core/agent-session.tsimport type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager.js";
async newSession(options?: NewSessionOptions): Promise<boolean> {>rg "^import.*session-manager" packages/coding-agent/src/core/agent-session.tsimport type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager.js";So NewSessionOptions is imported from session-manager.js and it only has parentSession. I need to:
NewSessionOptions with the setup callback (or just define the full options inline)newSession() to handle the setup callbackLet me make these changes.
Now let me implement the fix:
Now I need to update interactive-mode.ts to simplify the handler - it should just pass through to AgentSession and handle UI rendering.
Now let me check if there's an RPC mode that needs similar updates:
Now let me check RPC mode for similar issues:
>rg "newSession" packages/coding-agent/src/modes/rpc/rpc-mode.ts -B 2 -A 20commandContextActions: {
waitForIdle: () => session.agent.waitForIdle(),
newSession: async (options) => {
const success = await session.newSession({ parentSession: options?.parentSession });
if (success && options?.setup) {
await options.setup(session.sessionManager);
}
return { cancelled: !success };
},
fork: async (entryId) => {
const result = await session.fork(entryId);
return { cancelled: result.cancelled };
},
navigateTree: async (targetId, options) => {
const result = await session.navigateTree(targetId, {
summarize: options?.summarize,
customInstructions: options?.customInstructions,
replaceInstructions: options?.replaceInstructions,
label: options?.label,
});
return { cancelled: result.cancelled };
},
},
shutdownHandler: () => {
--
case "new_session": {
const options = command.parentSession ? { parentSession: command.parentSession } : undefined;
const cancelled = !(await session.newSession(options));
return success(id, "new_session", { cancelled });
}
// =================================================================
// State
// =================================================================
case "get_state": {
const state: RpcSessionState = {
model: session.model,
thinkingLevel: session.thinkingLevel,
isStreaming: session.isStreaming,
isCompacting: session.isCompacting,
steeringMode: session.steeringMode,
followUpMode: session.followUpMode,
sessionFile: session.sessionFile,
sessionId: session.sessionId,
autoCompactionEnabled: session.autoCompactionEnabled,
messageCount: session.messages.length,
pendingMessageCount: session.pendingMessageCount,RPC mode has the same issue! It calls session.newSession({ parentSession: options?.parentSession }) and then manually handles setup afterwards. Now that I've moved setup handling into AgentSession.newSession(), I need to fix the RPC mode to just pass through the options.
RPC mode has the same issue. Let me fix it:
Now let me run the type check to make sure everything compiles:
Now let's run the check:
>cd /Users/badlogic/workspaces/pi-mono && npm run check 2>&1> pi-monorepo@0.0.3 check
> biome check --write . && tsgo --noEmit && cd packages/web-ui && npm run check
packages/coding-agent/src/core/agent-session.ts:68:52 lint/correctness/noUnusedImports FIXABLE ━━━━━━━━━━
! Several of these imports are unused.
66 │ import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.js";
67 │ import type { ResourceLoader } from "./resource-loader.js";
> 68 │ import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager.js";
│ ^^^^^^^^^^^^^^^^^
69 │ import type { SettingsManager } from "./settings-manager.js";
70 │ import { buildSystemPrompt } from "./system-prompt.js";
i Unused imports might be the result of an incomplete refactoring.
i Unsafe fix: Remove the unused imports.
68 │ import·type·{·BranchSummaryEntry,·CompactionEntry,·NewSessionOptions,·SessionManager·}·from·"./session-manager.js";
│ -------------------
Skipped 1 suggested fixes.
If you wish to apply the suggested (unsafe) fixes, use the command biome check --write --unsafe
Checked 440 files in 271ms. No fixes applied.
Found 1 warning.
> @mariozechner/pi-web-ui@0.50.0 check
> biome check --write . && tsc --noEmit && cd example && biome check --write . && tsc --noEmit
Checked 73 files in 36ms. No fixes applied.
Checked 3 files in 8ms. No fixes applied.Good, it compiles. There's an unused import warning for NewSessionOptions since I no longer use that type. Let me remove it to clean up the code.
Let me clean up the unused import:
>cd /Users/badlogic/workspaces/pi-mono && npx biome check --write --unsafe packages/coding-agent/src/core/agent-session.ts 2>&1Checked 1 file in 51ms. Fixed 1 file.Let me verify the fix was applied correctly:
>cd /Users/badlogic/workspaces/pi-mono && npm run check 2>&1> pi-monorepo@0.0.3 check
> biome check --write . && tsgo --noEmit && cd packages/web-ui && npm run check
Checked 440 files in 376ms. No fixes applied.
> @mariozechner/pi-web-ui@0.50.0 check
> biome check --write . && tsc --noEmit && cd example && biome check --write . && tsc --noEmit
Checked 73 files in 45ms. No fixes applied.
Checked 3 files in 8ms. No fixes applied.All checks pass. Let me now update the changelog and summarize what was done.
Now let me update the changelog:
I need to add the fix to the unreleased section.
Done. Let me provide a summary of what was done.
Done. Here's the summary:
packages/coding-agent/src/core/agent-session.ts
newSession() to accept a setup callback parametersetup() runs, syncs agent state via agent.replaceMessages(sessionManager.buildSessionContext().messages)NewSessionOptions importpackages/coding-agent/src/modes/interactive/interactive-mode.ts
newSession handler to pass through options to AgentSessionrenderInitialMessages() instead of hardcoding "✓ New session started" textpackages/coding-agent/src/modes/rpc/rpc-mode.ts
setup handling, now passes options through to AgentSessionpackages/coding-agent/CHANGELOG.md
[Unreleased]The fix ensures that when an extension calls ctx.newSession({ setup }) and appends messages in the setup callback, both the agent state and UI are properly synced without needing workarounds like calling navigateTree twice.
ok, how can i test this?
The user wants to test the fix. The best way would be to use the extension from the issue. Let me think about how to set this up:
ctx.newSession({ setup }) to append messagesLet me create a minimal test extension based on the user's extension from the issue, but simplified.
Create a minimal test extension:
Now I need to explain how to test this:
Now run pi with the test extension:
cd /Users/badlogic/workspaces/pi-mono./pi-test.sh --extension /tmp/test-new-session-extension.tsThen test:
Run the command: Type /test-new-session and press Enter
Check UI: You should see the 4 messages (2 user, 2 assistant) rendered in the chat immediately, without needing any workarounds
Check LLM context: Type "What did we discuss?" and send. The LLM should know about the 2+2 and 3+3 conversation because the agent state was properly synced
Before the fix: The UI would show "✓ New session started" and the LLM wouldn't know about the messages (user had to call navigateTree twice as a workaround)
After the fix: Messages render immediately and LLM has full context
works perfectly fine, add changelog entry, commit and push
The user confirmed the fix works. I need to:
Let me check the current state of the changelog and then commit.
>cd /Users/badlogic/workspaces/pi-mono && git statusOn branch main
Your branch is up to date with 'origin/main'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: packages/coding-agent/CHANGELOG.md
modified: packages/coding-agent/src/core/agent-session.ts
modified: packages/coding-agent/src/modes/interactive/interactive-mode.ts
modified: packages/coding-agent/src/modes/rpc/rpc-mode.ts
no changes added to commit (use "git add" and/or "git commit -a")I already added the changelog entry earlier. Let me verify it and then commit:
>cd /Users/badlogic/workspaces/pi-mono && head -15 packages/coding-agent/CHANGELOG.md# Changelog
## [Unreleased]
### Fixed
- Git extension updates now handle force-pushed remotes gracefully instead of failing ([#961](https://github.com/badlogic/pi-mono/pull/961) by [@aliou](https://github.com/aliou))
- Extension `ctx.newSession({ setup })` now properly syncs agent state and renders messages after setup callback runs ([#968](https://github.com/badlogic/pi-mono/issues/968))
## [0.50.0] - 2026-01-26
### New Features
- Pi packages for bundling and installing extensions, skills, prompts, and themes. See [docs/packages.md](docs/packages.md).
- Hot reload (`/reload`) of resources including AGENTS.md, SYSTEM.md, APPEND_SYSTEM.md, prompt templates, skills, themes, and extensions. See [README.md#commands](README.md#commands) and [README.md#context-files](README.md#context-files).Changelog entry is already there. Now I'll commit and push the specific files I modified.
>cd /Users/badlogic/workspaces/pi-mono && git add packages/coding-agent/CHANGELOG.md packages/coding-agent/src/core/agent-session.ts packages/coding-agent/src/modes/interactive/interactive-mode.ts packages/coding-agent/src/modes/rpc/rpc-mode.ts && git commit -m "fix(coding-agent): sync agent state after newSession setup callback
Move setup callback handling from interactive/rpc modes into AgentSession.newSession().
After setup() runs, sync agent state via replaceMessages() so the LLM has context
and the UI renders the messages properly.
fixes #968"Running formatting, linting, and type checking...
> pi-monorepo@0.0.3 check
> biome check --write . && tsgo --noEmit && cd packages/web-ui && npm run check
Checked 440 files in 295ms. No fixes applied.
> @mariozechner/pi-web-ui@0.50.0 check
> biome check --write . && tsc --noEmit && cd example && biome check --write . && tsc --noEmit
Checked 73 files in 38ms. No fixes applied.
Checked 3 files in 7ms. No fixes applied.
✅ All pre-commit checks passed!
[main f86e3c31] fix(coding-agent): sync agent state after newSession setup callback
4 files changed, 25 insertions(+), 15 deletions(-)Committed. Now push.
>cd /Users/badlogic/workspaces/pi-mono && git pushTo https://github.com/badlogic/pi-mono.git
0134a2a7..f86e3c31 main -> mainDone! Pushed successfully.
Done. Committed and pushed:
fix(coding-agent): sync agent state after newSession setup callbacknewSession: async (options) => { if (this.loadingAnimation) { this.loadingAnimation.stop(); this.loadingAnimation = undefined; } this.statusContainer.clear();
const success = await this.session.newSession({ parentSession: options?.parentSession }); if (!success) { return { cancelled: true }; }
if (options?.setup) { await options.setup(this.sessionManager); // <-- MESSAGES ARE APPENDED HERE }
// Clear UI state this.chatContainer.clear(); this.pendingMessagesContainer.clear(); this.compactionQueuedMessages = []; this.streamingComponent = undefined; this.streamingMessage = undefined; this.pendingTools.clear();
// Show new session message this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1)); this.ui.requestRender();
return { cancelled: false };};async newSession(options?: NewSessionOptions): Promise<boolean> { // ...snip... this._disconnectFromAgent(); await this.abort(); this.agent.reset(); // <-- AGENT STATE IS RESET this.sessionManager.newSession(options); this.agent.sessionId = this.sessionManager.getSessionId(); this._steeringMessages = []; this._followUpMessages = []; this._pendingNextTurnMessages = []; this._reconnectToAgent(); // ... return true;}// lines ~875-905newSession: async (options) => { // ... const success = await this.session.newSession({ parentSession: options?.parentSession }); if (options?.setup) { await options.setup(this.sessionManager); // Messages are appended to SessionManager }
// BUT: Agent state is NOT synced after setup() // The agent was reset in session.newSession(), and messages added via setup() // are only in the SessionManager, not in the Agent's state.
this.chatContainer.clear(); // ...renders "✓ New session started" instead of the actual messages this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1)); return { cancelled: false };};if (options?.setup) { await options.setup(this.sessionManager);}
this.chatContainer.clear();// ...this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1));if (options?.setup) { await options.setup(this.sessionManager); // Sync agent state with session manager after setup const sessionContext = this.sessionManager.buildSessionContext(); this.session.agent.replaceMessages(sessionContext.messages);}
this.chatContainer.clear();this.pendingMessagesContainer.clear();this.compactionQueuedMessages = [];this.streamingComponent = undefined;this.streamingMessage = undefined;this.pendingTools.clear();
// Render messages if any were added via setup, otherwise show simple confirmationconst hasMessages = this.sessionManager.getBranch().some(e => e.type === 'message');if (hasMessages) { this.renderInitialMessages();} else { this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1));}
this.ui.requestRender();newSession(options?: { parentSession?: string; setup?: (sessionManager: SessionManager) => Promise<void>;}): Promise<{ cancelled: boolean }>;private _appendEntry(entry: SessionEntry): void { this.fileEntries.push(entry); this.byId.set(entry.id, entry); this.leafId = entry.id; // <-- LEAF IS UPDATED this._persist(entry);}private _appendEntry(entry: SessionEntry): void { this.fileEntries.push(entry); this.byId.set(entry.id, entry); this.leafId = entry.id; // <-- LEAF IS UPDATED this._persist(entry);}private _appendEntry(entry: SessionEntry): void { this.fileEntries.push(entry); this.byId.set(entry.id, entry); this.leafId = entry.id; // ✓ Leaf is updated this._persist(entry);}// Change NewSessionOptions or add setup parameterasync newSession(options?: { parentSession?: string; setup?: (sessionManager: SessionManager) => Promise<void>;}): Promise<boolean> { // ... existing event emission and abort logic ... this._disconnectFromAgent(); await this.abort(); this.agent.reset(); this.sessionManager.newSession({ parentSession: options?.parentSession }); this.agent.sessionId = this.sessionManager.getSessionId(); this._steeringMessages = []; this._followUpMessages = []; this._pendingNextTurnMessages = []; // NEW: Run setup if provided if (options?.setup) { await options.setup(this.sessionManager); // Sync agent state with session manager const sessionContext = this.sessionManager.buildSessionContext(); this.agent.replaceMessages(sessionContext.messages); } this._reconnectToAgent(); // ... rest of method ...}newSession: async (options) => { // Clear UI loading state if (this.loadingAnimation) { this.loadingAnimation.stop(); this.loadingAnimation = undefined; } this.statusContainer.clear();
// Delegate to AgentSession (handles setup + agent sync) const success = await this.session.newSession(options); if (!success) { return { cancelled: true }; }
// UI cleanup this.chatContainer.clear(); this.pendingMessagesContainer.clear(); this.compactionQueuedMessages = []; this.streamingComponent = undefined; this.streamingMessage = undefined; this.pendingTools.clear();
// Render messages (works whether setup added messages or not) this.renderInitialMessages(); this.ui.requestRender();
return { cancelled: false };},cd /Users/badlogic/workspaces/pi-mono./pi-test.sh --extension /tmp/test-new-session-extension.ts