The user wants to expand AgentSession to be more comprehensive - a single abstraction that all three modes can use, containing all the shared agent-related logic. This would include:

1. Agent lifecycle (prompt, abort, reset)
2. Session persistence (save messages, model changes, thinking level changes)
3. Model management (get/set model, cycle models, validate API keys)
4. Thinking level management (get/set, cycle)
5. Compaction (manual and auto)
6. Bash execution (for ! commands)
7. Slash command expansion
8. Session branching/switching

The key insight is that if we can make AgentSession TUI-agnostic, it becomes the shared core for all modes. The modes would then just be thin layers:
- Interactive mode: TUI + AgentSession
- Print mode: stdout + AgentSession
- RPC mode: JSON stdin/stdout + AgentSession

Let me think about what should go into AgentSession and what the API would look like...

Things currently scattered across main.ts and tui-renderer.ts that could be in AgentSession:

From tui-renderer.ts:
- subscribeToAgent() - agent event subscription + session saving
- checkAutoCompaction()
- cycleThinkingLevel() - but this has TUI feedback, need to split
- cycleModel() - same, has TUI feedback
- handleClearCommand() - reset logic
- handleCompactCommand() - compaction logic
- executeCompaction() - shared compaction execution
- handleBashCommand() + executeBashCommand() - bash execution
- handleResumeSession() - session switching
- branch logic in showUserMessageSelector callback

From main.ts:
- runRpcMode's checkAutoCompaction
- executeRpcBashCommand
- Model resolution logic
- Thinking level restoration from session

The key is to separate:
1. **State changes** (AgentSession) - the "what"
2. **UI feedback** (modes) - the "how to show it"

I'll design an API that returns actionable results for different modes to handle. Each method will provide clear, structured information about the state change, allowing flexible UI representation across various interfaces.

Good thinking. Let me expand AgentSession to be a comprehensive, TUI-agnostic abstraction that all modes can use.

## Expanded AgentSession Design

```typescript
// src/core/agent-session.ts

import type { Agent, AgentEvent, AgentState, ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { Model, Message } from "@mariozechner/pi-ai";

export interface AgentSessionConfig {
  agent: Agent;
  sessionManager: SessionManager;
  settingsManager: SettingsManager;
  scopedModels?: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
}

export interface BashResult {
  output: string;
  exitCode: number | null;
  cancelled: boolean;
  truncated: boolean;
  fullOutputPath?: string;
}

export interface CompactionResult {
  tokensBefore: number;
  tokensAfter: number;
  summary: string;
}

export interface ModelCycleResult {
  model: Model<any>;
  thinkingLevel: ThinkingLevel;
  isScoped: boolean;  // true if cycling within --models scope
}

export interface PromptOptions {
  expandSlashCommands?: boolean;  // default true
  attachments?: Attachment[];
}

/**
 * Core agent session management - shared between all modes.
 * Handles agent lifecycle, persistence, model/thinking management.
 * TUI-agnostic: returns data, doesn't render anything.
 */
export class AgentSession {
  readonly agent: Agent;
  readonly sessionManager: SessionManager;
  readonly settingsManager: SettingsManager;

  private scopedModels: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
  private fileCommands: FileSlashCommand[];
  private eventListeners: Array<(event: AgentEvent) => void> = [];
  private bashAbortController: AbortController | null = null;
  private compactionAbortController: AbortController | null = null;

  constructor(config: AgentSessionConfig);

  // ─────────────────────────────────────────────────────────────
  // State Access
  // ─────────────────────────────────────────────────────────────

  get state(): AgentState;
  get model(): Model<any> | null;
  get thinkingLevel(): ThinkingLevel;
  get isStreaming(): boolean;
  get messages(): Message[];
  get sessionFile(): string;
  get sessionId(): string;

  // ─────────────────────────────────────────────────────────────
  // Event Subscription
  // ─────────────────────────────────────────────────────────────

  /**
   * Subscribe to agent events. Handles session persistence internally.
   * Returns unsubscribe function.
   */
  subscribe(listener: (event: AgentEvent) => void): () => void;

  // ─────────────────────────────────────────────────────────────
  // Prompting
  // ─────────────────────────────────────────────────────────────

  /**
   * Send a prompt to the agent. Expands slash commands by default.
   * Validates model and API key before sending.
   * Throws if no model or API key.
   */
  prompt(text: string, options?: PromptOptions): Promise<void>;

  /**
   * Queue a message (when agent is streaming).
   */
  queueMessage(text: string): Promise<void>;

  /**
   * Clear queued messages, return them for restoration.
   */
  clearQueue(): string[];

  /**
   * Abort current operation and wait for idle.
   */
  abort(): Promise<void>;

  /**
   * Reset agent and session (start fresh).
   */
  reset(): Promise<void>;

  // ─────────────────────────────────────────────────────────────
  // Model Management
  // ─────────────────────────────────────────────────────────────

  /**
   * Set model directly. Validates API key.
   * Saves to session and settings.
   * Throws if no API key available.
   */
  setModel(model: Model<any>): Promise<void>;

  /**
   * Cycle to next model (uses scoped models if available).
   * Returns the new model info, or null if only one model available.
   */
  cycleModel(): Promise<ModelCycleResult | null>;

  /**
   * Get all available models (with valid API keys).
   */
  getAvailableModels(): Promise<Model<any>[]>;

  // ─────────────────────────────────────────────────────────────
  // Thinking Level Management
  // ─────────────────────────────────────────────────────────────

  /**
   * Set thinking level. Silently uses "off" if model doesn't support it.
   * Saves to session and settings.
   */
  setThinkingLevel(level: ThinkingLevel): void;

  /**
   * Cycle to next thinking level.
   * Returns new level, or null if model doesn't support thinking.
   */
  cycleThinkingLevel(): ThinkingLevel | null;

  /**
   * Check if current model supports thinking.
   */
  supportsThinking(): boolean;

  // ─────────────────────────────────────────────────────────────
  // Compaction
  // ─────────────────────────────────────────────────────────────

  /**
   * Manually compact the session context.
   * Aborts current agent operation first.
   */
  compact(customInstructions?: string): Promise<CompactionResult>;

  /**
   * Cancel in-progress compaction.
   */
  abortCompaction(): void;

  /**
   * Check if auto-compaction should run, and run it if so.
   * Called internally after assistant messages.
   * Returns result if compaction occurred, null otherwise.
   */
  checkAutoCompaction(): Promise<CompactionResult | null>;

  /**
   * Toggle auto-compaction setting.
   */
  setAutoCompactionEnabled(enabled: boolean): void;

  get autoCompactionEnabled(): boolean;

  // ─────────────────────────────────────────────────────────────
  // Bash Execution (for ! commands)
  // ─────────────────────────────────────────────────────────────

  /**
   * Execute a bash command. Adds result to agent context.
   * @param onChunk - Optional streaming callback for output
   */
  executeBash(command: string, onChunk?: (chunk: string) => void): Promise<BashResult>;

  /**
   * Cancel running bash command.
   */
  abortBash(): void;

  /**
   * Check if bash command is running.
   */
  get isBashRunning(): boolean;

  // ─────────────────────────────────────────────────────────────
  // Session Management
  // ─────────────────────────────────────────────────────────────

  /**
   * Switch to a different session file.
   * Aborts current operation, loads messages, restores model/thinking.
   */
  switchSession(sessionPath: string): Promise<void>;

  /**
   * Create a branch from a specific entry index.
   * Returns the text of the selected user message (for editor pre-fill).
   */
  branch(entryIndex: number): string;

  /**
   * Get all user messages from session (for branch selector).
   * Returns entries with index and text.
   */
  getUserMessagesForBranching(): Array<{ entryIndex: number; text: string }>;

  /**
   * Get session stats for /session command.
   */
  getSessionStats(): SessionStats;

  /**
   * Export session to HTML.
   */
  exportToHtml(outputPath?: string): string;

  // ─────────────────────────────────────────────────────────────
  // Clipboard
  // ─────────────────────────────────────────────────────────────

  /**
   * Get last assistant message text for /copy.
   * Returns null if no assistant message exists.
   */
  getLastAssistantText(): string | null;
}

export interface SessionStats {
  sessionFile: string;
  sessionId: string;
  userMessages: number;
  assistantMessages: number;
  toolCalls: number;
  toolResults: number;
  totalMessages: number;
  tokens: {
    input: number;
    output: number;
    cacheRead: number;
    cacheWrite: number;
    total: number;
  };
  cost: number;
}
```

## How Modes Would Use It

### Print Mode (simplest)

```typescript
// src/modes/print-mode.ts

export async function runPrintMode(
  session: AgentSession,
  mode: "text" | "json",
  messages: string[],
  initialMessage?: string,
  initialAttachments?: Attachment[],
): Promise<void> {

  if (mode === "json") {
    // Just pipe events to stdout
    session.subscribe((event) => {
      console.log(JSON.stringify(event));
    });
  }

  // Send messages
  if (initialMessage) {
    await session.prompt(initialMessage, { attachments: initialAttachments });
  }
  for (const message of messages) {
    await session.prompt(message);
  }

  // In text mode, output final response
  if (mode === "text") {
    const lastText = session.getLastAssistantText();
    if (lastText) {
      console.log(lastText);
    }
  }
}
```

### RPC Mode

```typescript
// src/modes/rpc-mode.ts

export async function runRpcMode(session: AgentSession): Promise<never> {
  // Pipe all events to stdout as JSON
  session.subscribe((event) => {
    console.log(JSON.stringify(event));
  });

  // Listen for JSON input
  const rl = readline.createInterface({ input: process.stdin });

  rl.on("line", async (line: string) => {
    try {
      const input = JSON.parse(line);

      switch (input.type) {
        case "prompt":
          await session.prompt(input.message, { attachments: input.attachments });
          break;

        case "abort":
          await session.abort();
          break;

        case "compact":
          const result = await session.compact(input.customInstructions);
          console.log(JSON.stringify({ type: "compaction", ...result }));
          break;

        case "bash":
          const bashResult = await session.executeBash(input.command);
          console.log(JSON.stringify({ type: "bash_end", ...bashResult }));
          break;

        case "setModel":
          // Could add model switching via RPC
          break;

        case "setThinking":
          session.setThinkingLevel(input.level);
          break;
      }
    } catch (error: any) {
      console.log(JSON.stringify({ type: "error", error: error.message }));
    }
  });

  return new Promise(() => {}); // Never resolves
}
```

### Interactive Mode (most complex, but cleaner)

```typescript
// src/modes/interactive-mode.ts

export class InteractiveMode {
  private session: AgentSession;
  private tui: TUI;
  private components: TuiComponents;  // Chat container, editor, footer, etc.

  constructor(session: AgentSession, options: InteractiveModeOptions) {
    this.session = session;
    this.tui = new TUI(new ProcessTerminal());
    this.components = this.createComponents();

    // Subscribe to session events for rendering
    session.subscribe((event) => this.handleEvent(event));
  }

  async run(): Promise<void> {
    await this.init();

    while (true) {
      const input = await this.getEditorInput();
      await this.handleInput(input);
    }
  }

  private async handleInput(text: string): Promise<void> {
    // Slash commands
    if (text === "/thinking") {
      this.showThinkingSelector();
      return;
    }
    if (text === "/model") {
      this.showModelSelector();
      return;
    }
    if (text === "/compact") {
      await this.handleCompact();
      return;
    }
    if (text === "/copy") {
      this.handleCopy();
      return;
    }
    // ... other commands

    // Bash command
    if (text.startsWith("!")) {
      await this.handleBash(text.slice(1));
      return;
    }

    // Regular prompt
    try {
      await this.session.prompt(text);
    } catch (error) {
      this.showError(error.message);
    }
  }

  // ─── Slash Command Handlers ───

  private async handleCompact(): Promise<void> {
    this.showLoader("Compacting...");
    try {
      const result = await this.session.compact();
      this.rebuildChat();
      this.addCompactionComponent(result);
    } catch (error) {
      this.showError(`Compaction failed: ${error.message}`);
    } finally {
      this.hideLoader();
    }
  }

  private handleCopy(): void {
    const text = this.session.getLastAssistantText();
    if (!text) {
      this.showError("No assistant message to copy");
      return;
    }
    copyToClipboard(text);
    this.showMessage("Copied to clipboard");
  }

  private async handleBash(command: string): Promise<void> {
    const component = new BashExecutionComponent(command);
    this.components.chat.addChild(component);

    const result = await this.session.executeBash(command, (chunk) => {
      component.appendOutput(chunk);
      this.tui.requestRender();
    });

    component.setComplete(result);
    this.tui.requestRender();
  }

  // ─── Hotkey Handlers ───

  private handleEscape(): void {
    if (this.session.isStreaming) {
      // Restore queued messages to editor
      const queued = this.session.clearQueue();
      const current = this.components.editor.getText();
      this.components.editor.setText([...queued, current].filter(Boolean).join("\n\n"));
      this.session.abort();
    } else if (this.session.isBashRunning) {
      this.session.abortBash();
    }
  }

  private handleShiftTab(): void {
    const newLevel = this.session.cycleThinkingLevel();
    if (newLevel === null) {
      this.showMessage("Model doesn't support thinking");
    } else {
      this.showMessage(`Thinking: ${newLevel}`);
      this.updateEditorBorder();
    }
  }

  private async handleCtrlP(): Promise<void> {
    const result = await this.session.cycleModel();
    if (result === null) {
      this.showMessage("Only one model available");
    } else {
      const thinkingStr = result.thinkingLevel !== "off" ? ` (thinking: ${result.thinkingLevel})` : "";
      this.showMessage(`Switched to ${result.model.name || result.model.id}${thinkingStr}`);
      this.updateEditorBorder();
    }
  }

  // ─── Event Handling (just rendering) ───

  private handleEvent(event: AgentEvent): void {
    // Pure rendering logic - no session management
    switch (event.type) {
      case "agent_start":
        this.showLoader("Working...");
        break;
      case "message_start":
        if (event.message.role === "user") {
          this.addUserMessage(event.message);
        } else {
          this.startStreamingAssistant();
        }
        break;
      case "message_update":
        this.updateStreamingAssistant(event.message);
        break;
      case "message_end":
        this.finalizeMessage(event.message);
        break;
      case "agent_end":
        this.hideLoader();
        break;
    }
    this.tui.requestRender();
  }
}
```

## What This Achieves

### 1. **Single Source of Truth**
All agent/session logic lives in `AgentSession`:
- No more duplicate `checkAutoCompaction()`
- No more duplicate bash execution
- No more duplicate session saving
- Model/thinking changes always go through one path

### 2. **Clear Separation**
- `AgentSession` = state management, persistence, business logic
- Modes = I/O and presentation

### 3. **Testable**
```typescript
// Can unit test AgentSession without any TUI
const session = new AgentSession({ agent, sessionManager, settingsManager });
await session.setThinkingLevel("high");
expect(session.thinkingLevel).toBe("high");
expect(settingsManager.getDefaultThinkingLevel()).toBe("high");
```

### 4. **Extensible**
Adding a new mode (e.g., LSP server mode, web socket mode) just means:
1. Create `AgentSession`
2. Subscribe to events
3. Implement your I/O layer

### 5. **Reduced main.ts**
Model resolution, thinking restoration, session loading can all be helper functions or part of an `AgentSession.create()` factory:

```typescript
// main.ts
const session = await AgentSession.create({
  args: parsed,
  settingsManager,
  sessionManager,
});

if (mode === "rpc") await runRpcMode(session);
else if (mode === "print") await runPrintMode(session, ...);
else await runInteractiveMode(session, ...);
```

---

## File Structure After Refactor

```
src/
  core/
    agent-session.ts      # The big shared abstraction
    bash-executor.ts      # Low-level bash execution (used by AgentSession)
    setup.ts              # Factory/initialization helpers
  modes/
    interactive/
      interactive-mode.ts # Main class
      components.ts       # TUI component wiring
      selectors.ts        # Model/thinking/theme selector management
    print-mode.ts
    rpc-mode.ts
  main.ts                 # Just arg parsing and mode routing (~200 lines)
```

Does this expanded design address your concerns? Should I start implementing `AgentSession`?


ok, create a branch refactor


The user wants me to create a git branch called "refactor" to start the refactoring work.

bash

cd /Users/badlogic/workspaces/pi-mono && git checkout -b refactor


Switched to a new branch 'refactor'


Done. On branch `refactor` now. Ready to start implementing `AgentSession`. Want me to begin?


will this also allow us to test the agent core?


The user is asking if this refactoring will make the agent core more testable. Let me think about this...

Currently, testing the agent interaction is difficult because:
1. `TuiRenderer` couples TUI rendering with agent logic
2. `main.ts` has mode-specific logic mixed with setup
3. Testing requires mocking a lot of things or running the full TUI

With `AgentSession`:
1. It's TUI-agnostic, so you can instantiate it in tests without any terminal
2. You can mock the `Agent`, `SessionManager`, `SettingsManager` easily
3. You can test state changes, persistence, model cycling, etc. in isolation

But there's a question of what "agent core" means:
- If they mean `AgentSession` itself - yes, very testable
- If they mean the underlying `Agent` from `@mariozechner/pi-agent-core` - that's a separate package, and this refactor doesn't directly affect it, but `AgentSession` wrapping it makes integration testing easier

Let me explain both angles.

Yes, that's a major benefit. Here's what becomes testable:

### 1. `AgentSession` Unit Tests

```typescript
// No TUI, no terminal, just pure logic
describe("AgentSession", () => {
  let mockAgent: MockAgent;
  let mockSessionManager: MockSessionManager;
  let mockSettingsManager: MockSettingsManager;
  let session: AgentSession;

  beforeEach(() => {
    mockAgent = createMockAgent();
    mockSessionManager = createMockSessionManager();
    mockSettingsManager = createMockSettingsManager();
    session = new AgentSession({
      agent: mockAgent,
      sessionManager: mockSessionManager,
      settingsManager: mockSettingsManager
    });
  });

  describe("thinking level", () => {
    it("cycles through levels", () => {
      mockAgent.state.model = { reasoning: true };

      expect(session.cycleThinkingLevel()).toBe("minimal");
      expect(session.cycleThinkingLevel()).toBe("low");
      expect(session.cycleThinkingLevel()).toBe("medium");
      // ...
    });

    it("returns null when model doesn't support thinking", () => {
      mockAgent.state.model = { reasoning: false };
      expect(session.cycleThinkingLevel()).toBeNull();
    });

    it("persists to session and settings", () => {
      session.setThinkingLevel("high");
      expect(mockSessionManager.saveThinkingLevelChange).toHaveBeenCalledWith("high");
      expect(mockSettingsManager.setDefaultThinkingLevel).toHaveBeenCalledWith("high");
    });
  });

  describe("compaction", () => {
    it("auto-compacts when threshold exceeded", async () => {
      mockSettingsManager.getCompactionSettings.mockReturnValue({ enabled: true, threshold: 0.8 });
      // Setup agent state with high token usage...

      const result = await session.checkAutoCompaction();
      expect(result).not.toBeNull();
      expect(mockSessionManager.saveCompaction).toHaveBeenCalled();
    });
  });

  describe("model cycling", () => {
    it("cycles through scoped models with thinking levels", async () => {
      const scopedModels = [
        { model: modelA, thinkingLevel: "high" },
        { model: modelB, thinkingLevel: "off" },
      ];
      session = new AgentSession({ ..., scopedModels });

      const result = await session.cycleModel();
      expect(result.model).toBe(modelB);
      expect(result.thinkingLevel).toBe("off");
    });
  });

  describe("bash execution", () => {
    it("adds result to agent context", async () => {
      const result = await session.executeBash("echo hello");
      expect(mockAgent.appendMessage).toHaveBeenCalledWith(
        expect.objectContaining({ role: "bashExecution", command: "echo hello" })
      );
    });

    it("can be aborted", async () => {
      const promise = session.executeBash("sleep 10");
      session.abortBash();
      const result = await promise;
      expect(result.cancelled).toBe(true);
    });
  });
});
```

### 2. Integration Tests (with real Agent, mocked API)

```typescript
describe("AgentSession integration", () => {
  it("full prompt flow persists correctly", async () => {
    const agent = new Agent({ /* real config with mocked transport */ });
    const sessionManager = new SessionManager(false, tempSessionFile);
    const session = new AgentSession({ agent, sessionManager, ... });

    await session.prompt("Hello");

    // Verify session file has correct entries
    const entries = sessionManager.loadEntries();
    expect(entries).toContainEqual(expect.objectContaining({
      type: "message",
      message: expect.objectContaining({ role: "user" })
    }));
  });
});
```

### 3. Mode Tests (with mocked AgentSession)

```typescript
describe("RPC mode", () => {
  it("handles prompt command", async () => {
    const mockSession = createMockAgentSession();
    const stdin = new MockReadable();
    const stdout = new MockWritable();

    runRpcMode(mockSession, { stdin, stdout });

    stdin.push(JSON.stringify({ type: "prompt", message: "hello" }));

    expect(mockSession.prompt).toHaveBeenCalledWith("hello", expect.anything());
  });
});
```

### What's Still Hard to Test

- The actual `Agent` class from `pi-agent-core` (that's its own package, would need its own test strategy with API mocking)
- TUI rendering (visual testing is always tricky)

### What This Unlocks

| Before | After |
|--------|-------|
| Can't test thinking cycling without TUI | Unit test `session.cycleThinkingLevel()` |
| Can't test compaction logic in isolation | Unit test `session.compact()` and `checkAutoCompaction()` |
| Can't test session persistence without running full app | Unit test that `prompt()` calls `sessionManager.saveMessage()` |
| Can't test model cycling without TUI | Unit test `session.cycleModel()` |
| RPC mode untestable | Mock `AgentSession`, test JSON handling |

So yes, `AgentSession` becomes the testable core that all modes share. Want me to start implementing it?


i would only want e2e tests for AgentSession, anything else is shitty.

alright, createa  full implementation plan in coding-agent/docs/refactor.md, it needs to cover all the things we want to do on a high level. then it needs to have work packages in the best order to implement them, with - [ ] to keep track of state, where each item would describe in detail what to change how (including file paths etc.)


The user wants me to create a comprehensive implementation plan document. They want:

1. High-level overview of the refactoring goals
2. Work packages in optimal implementation order
3. Detailed descriptions of what to change, including file paths
4. Checkbox format for tracking progress

Let me think through the optimal order:

1. First, we need to create the `AgentSession` class -


this plan must be written in such a way, that you can starta  new session, pick up from where you left (also reading diff of current work tree and any files necessary in your contexT)


The user wants a detailed implementation plan that:
1. Is comprehensive and covers everything we discussed
2. Has work packages in optimal order with checkboxes
3. Is detailed enough that a new session can pick up where we left off
4. Should be self-contained - a new session can read this file, check git diff, and continue

Let me create a thorough document that covers:
- Goals and motivation
- Architecture overview
- File structure
- Detailed work packages with:
  - What files to create/modify
  - What code to move/change
  - Dependencies between packages
  - How to verify each step works

The order should be:
1. Create core infrastructure (AgentSession, bash-executor)
2. Migrate modes one by one (print mode first - simplest, then RPC, then interactive)
3. Clean up main.ts
4. Testing considerations

Let me write this out comprehensively.


(no output)


write

# Coding Agent Refactoring Plan

## Status

**Branch:** `refactor`
**Started:** 2024-12-08

To resume work on this refactoring:
1. Read this document fully
2. Run `git diff` to see current work in progress
3. Check the work packages below - find first unchecked item
4. Read any files mentioned in that work package before making changes

---

## Goals

1. **Eliminate code duplication** between the three run modes (interactive, print/json, rpc)
2. **Create a testable core** (`AgentSession`) that encapsulates all agent/session logic
3. **Separate concerns**: TUI rendering vs agent state management vs I/O
4. **Improve naming**: `TuiRenderer` → `InteractiveMode` (it's not just a renderer)
5. **Simplify main.ts**: Move setup logic out, make it just arg parsing + mode routing

---

## Architecture Overview

### Current State (Problems)

```
main.ts (1100+ lines)
├── parseArgs, printHelp
├── buildSystemPrompt, loadProjectContextFiles
├── resolveModelScope, model resolution logic
├── runInteractiveMode() - thin wrapper around TuiRenderer
├── runSingleShotMode() - duplicates event handling, session saving
├── runRpcMode() - duplicates event handling, session saving, auto-compaction, bash execution
└── executeRpcBashCommand() - duplicate of TuiRenderer.executeBashCommand()

tui/tui-renderer.ts (2400+ lines)
├── TUI lifecycle (init, render, event loop)
├── Agent event handling + session persistence (duplicated in main.ts)
├── Auto-compaction logic (duplicated in main.ts runRpcMode)
├── Bash execution (duplicated in main.ts)
├── All slash command implementations (/export, /copy, /model, /thinking, etc.)
├── All hotkey handlers (Ctrl+C, Ctrl+P, Shift+Tab, etc.)
├── Model/thinking cycling logic
└── 6 different selector UIs (model, thinking, theme, session, branch, oauth)
```

### Target State

```
src/
├── main.ts (~200 lines)
│   ├── parseArgs, printHelp
│   └── Route to appropriate mode
│
├── core/
│   ├── agent-session.ts      # Shared agent/session logic (THE key abstraction)
│   ├── bash-executor.ts      # Bash execution with streaming + cancellation
│   └── setup.ts              # Model resolution, system prompt building, session loading
│
└── modes/
    ├── print-mode.ts         # Simple: prompt, output result
    ├── rpc-mode.ts           # JSON stdin/stdout protocol
    └── interactive/
        ├── interactive-mode.ts   # Main orchestrator
        ├── command-handlers.ts   # Slash command implementations
        ├── hotkeys.ts            # Hotkey handling
        └── selectors.ts          # Modal selector management
```

---

## AgentSession API

This is the core abstraction shared by all modes. See full API design below.

```typescript
class AgentSession {
  // State access
  get state(): AgentState;
  get model(): Model<any> | null;
  get thinkingLevel(): ThinkingLevel;
  get isStreaming(): boolean;
  get messages(): Message[];

  // Event subscription (handles session persistence internally)
  subscribe(listener: (event: AgentEvent) => void): () => void;

  // Prompting
  prompt(text: string, options?: PromptOptions): Promise<void>;
  queueMessage(text: string): Promise<void>;
  clearQueue(): string[];
  abort(): Promise<void>;
  reset(): Promise<void>;

  // Model management
  setModel(model: Model<any>): Promise<void>;
  cycleModel(): Promise<ModelCycleResult | null>;
  getAvailableModels(): Promise<Model<any>[]>;

  // Thinking level
  setThinkingLevel(level: ThinkingLevel): void;
  cycleThinkingLevel(): ThinkingLevel | null;
  supportsThinking(): boolean;

  // Compaction
  compact(customInstructions?: string): Promise<CompactionResult>;
  abortCompaction(): void;
  checkAutoCompaction(): Promise<CompactionResult | null>;
  setAutoCompactionEnabled(enabled: boolean): void;
  get autoCompactionEnabled(): boolean;

  // Bash execution
  executeBash(command: string, onChunk?: (chunk: string) => void): Promise<BashResult>;
  abortBash(): void;
  get isBashRunning(): boolean;

  // Session management
  switchSession(sessionPath: string): Promise<void>;
  branch(entryIndex: number): string;
  getUserMessagesForBranching(): Array<{ entryIndex: number; text: string }>;
  getSessionStats(): SessionStats;
  exportToHtml(outputPath?: string): string;

  // Utilities
  getLastAssistantText(): string | null;
}
```

---

## Work Packages

### WP1: Create bash-executor.ts
> Extract bash execution into a standalone module that both AgentSession and tests can use.

**Files to create:**
- `src/core/bash-executor.ts`

**Extract from:**
- `src/tui/tui-renderer.ts`: `executeBashCommand()` method (lines ~2190-2270)
- `src/main.ts`: `executeRpcBashCommand()` function (lines ~640-700)

**Implementation:**
```typescript
// src/core/bash-executor.ts
export interface BashExecutorOptions {
  onChunk?: (chunk: string) => void;
  signal?: AbortSignal;
}

export interface BashResult {
  output: string;
  exitCode: number | null;
  cancelled: boolean;
  truncated: boolean;
  fullOutputPath?: string;
}

export function executeBash(command: string, options?: BashExecutorOptions): Promise<BashResult>;
```

**Logic to include:**
- Spawn shell process with `getShellConfig()`
- Stream stdout/stderr through `onChunk` callback (if provided)
- Handle temp file creation for large output (> DEFAULT_MAX_BYTES)
- Sanitize output (stripAnsi, sanitizeBinaryOutput, normalize newlines)
- Apply truncation via `truncateTail()`
- Support cancellation via AbortSignal (calls `killProcessTree`)
- Return structured result

**Verification:**
1. `npm run check` passes
2. Manual test: Run `pi` in interactive mode, execute `!ls -la`, verify output appears
3. Manual test: Run `!sleep 10`, press Esc, verify cancellation works

- [ ] Create `src/core/bash-executor.ts` with `executeBash()` function
- [ ] Add proper TypeScript types and exports
- [ ] Verify with `npm run check`

---

### WP2: Create agent-session.ts (Core Structure)
> Create the AgentSession class with basic structure and state access.

**Files to create:**
- `src/core/agent-session.ts`
- `src/core/index.ts` (barrel export)

**Dependencies:** None (can use existing imports)

**Implementation - Phase 1 (structure + state access):**
```typescript
// src/core/agent-session.ts
import type { Agent, AgentEvent, AgentState, ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { Model, Message } from "@mariozechner/pi-ai";
import type { SessionManager } from "../session-manager.js";
import type { SettingsManager } from "../settings-manager.js";

export interface AgentSessionConfig {
  agent: Agent;
  sessionManager: SessionManager;
  settingsManager: SettingsManager;
  scopedModels?: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
  fileCommands?: FileSlashCommand[];
}

export class AgentSession {
  readonly agent: Agent;
  readonly sessionManager: SessionManager;
  readonly settingsManager: SettingsManager;

  private scopedModels: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
  private fileCommands: FileSlashCommand[];

  constructor(config: AgentSessionConfig) {
    this.agent = config.agent;
    this.sessionManager = config.sessionManager;
    this.settingsManager = config.settingsManager;
    this.scopedModels = config.scopedModels ?? [];
    this.fileCommands = config.fileCommands ?? [];
  }

  // State access (simple getters)
  get state(): AgentState { return this.agent.state; }
  get model(): Model<any> | null { return this.agent.state.model; }
  get thinkingLevel(): ThinkingLevel { return this.agent.state.thinkingLevel; }
  get isStreaming(): boolean { return this.agent.state.isStreaming; }
  get messages(): Message[] { return this.agent.state.messages; }
  get sessionFile(): string { return this.sessionManager.getSessionFile(); }
  get sessionId(): string { return this.sessionManager.getSessionId(); }
}
```

**Verification:**
1. `npm run check` passes
2. Class can be instantiated (will test via later integration)

- [ ] Create `src/core/agent-session.ts` with basic structure
- [ ] Create `src/core/index.ts` barrel export
- [ ] Verify with `npm run check`

---

### WP3: AgentSession - Event Subscription + Session Persistence
> Add subscribe() method that wraps agent subscription and handles session persistence.

**Files to modify:**
- `src/core/agent-session.ts`

**Extract from:**
- `src/tui/tui-renderer.ts`: `subscribeToAgent()` method (lines ~470-495)
- `src/main.ts`: `runRpcMode()` subscription logic (lines ~720-745)
- `src/main.ts`: `runSingleShotMode()` subscription logic (lines ~605-610)

**Implementation:**
```typescript
// Add to AgentSession class

private unsubscribeAgent?: () => void;
private eventListeners: Array<(event: AgentEvent) => void> = [];

/**
 * Subscribe to agent events. Session persistence is handled internally.
 * Multiple listeners can be added. Returns unsubscribe function.
 */
subscribe(listener: (event: AgentEvent) => void): () => void {
  this.eventListeners.push(listener);

  // Set up agent subscription if not already done
  if (!this.unsubscribeAgent) {
    this.unsubscribeAgent = this.agent.subscribe(async (event) => {
      // Notify all listeners
      for (const l of this.eventListeners) {
        l(event);
      }

      // Handle session persistence
      if (event.type === "message_end") {
        this.sessionManager.saveMessage(event.message);

        // Initialize session after first user+assistant exchange
        if (this.sessionManager.shouldInitializeSession(this.agent.state.messages)) {
          this.sessionManager.startSession(this.agent.state);
        }

        // Check auto-compaction after assistant messages
        if (event.message.role === "assistant") {
          await this.checkAutoCompaction();
        }
      }
    });
  }

  // Return unsubscribe function for this specific listener
  return () => {
    const index = this.eventListeners.indexOf(listener);
    if (index !== -1) {
      this.eventListeners.splice(index, 1);
    }
  };
}

/**
 * Unsubscribe from agent entirely (used during cleanup/reset)
 */
private unsubscribeAll(): void {
  if (this.unsubscribeAgent) {
    this.unsubscribeAgent();
    this.unsubscribeAgent = undefined;
  }
  this.eventListeners = [];
}
```

**Verification:**
1. `npm run check` passes

- [ ] Add `subscribe()` method to AgentSession
- [ ] Add `unsubscribeAll()` private method
- [ ] Verify with `npm run check`

---

### WP4: AgentSession - Prompting Methods
> Add prompt(), queueMessage(), clearQueue(), abort(), reset() methods.

**Files to modify:**
- `src/core/agent-session.ts`

**Extract from:**
- `src/tui/tui-renderer.ts`: editor.onSubmit validation logic (lines ~340-380)
- `src/tui/tui-renderer.ts`: handleClearCommand() (lines ~2005-2035)
- Slash command expansion from `expandSlashCommand()`

**Implementation:**
```typescript
// Add to AgentSession class

private queuedMessages: string[] = [];

/**
 * Send a prompt to the agent.
 * - Validates model and API key
 * - Expands slash commands by default
 * - Throws if no model or no API key
 */
async prompt(text: string, options?: {
  expandSlashCommands?: boolean;
  attachments?: Attachment[];
}): Promise<void> {
  const expandCommands = options?.expandSlashCommands ?? true;

  // Validate model
  if (!this.model) {
    throw new Error(
      "No model selected.\n\n" +
      "Set an API key (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.)\n" +
      `or create ${getModelsPath()}\n\n` +
      "Then use /model to select a model."
    );
  }

  // Validate API key
  const apiKey = await getApiKeyForModel(this.model);
  if (!apiKey) {
    throw new Error(
      `No API key found for ${this.model.provider}.\n\n` +
      `Set the appropriate environment variable or update ${getModelsPath()}`
    );
  }

  // Expand slash commands
  const expandedText = expandCommands ? expandSlashCommand(text, this.fileCommands) : text;

  await this.agent.prompt(expandedText, options?.attachments);
}

/**
 * Queue a message while agent is streaming.
 */
async queueMessage(text: string): Promise<void> {
  this.queuedMessages.push(text);
  await this.agent.queueMessage({
    role: "user",
    content: [{ type: "text", text }],
    timestamp: Date.now(),
  });
}

/**
 * Clear queued messages. Returns them for restoration to editor.
 */
clearQueue(): string[] {
  const queued = [...this.queuedMessages];
  this.queuedMessages = [];
  this.agent.clearMessageQueue();
  return queued;
}

/**
 * Abort current operation and wait for idle.
 */
async abort(): Promise<void> {
  this.agent.abort();
  await this.agent.waitForIdle();
}

/**
 * Reset agent and session. Starts a fresh session.
 */
async reset(): Promise<void> {
  this.unsubscribeAll();
  await this.abort();
  this.agent.reset();
  this.sessionManager.reset();
  this.queuedMessages = [];
  // Re-subscribe (caller may have added listeners before reset)
  // Actually, listeners are cleared in unsubscribeAll, so caller needs to re-subscribe
}
```

**Verification:**
1. `npm run check` passes

- [ ] Add `prompt()` method with validation and slash command expansion
- [ ] Add `queueMessage()` method
- [ ] Add `clearQueue()` method
- [ ] Add `abort()` method
- [ ] Add `reset()` method
- [ ] Verify with `npm run check`

---

### WP5: AgentSession - Model Management
> Add setModel(), cycleModel(), getAvailableModels() methods.

**Files to modify:**
- `src/core/agent-session.ts`

**Extract from:**
- `src/tui/tui-renderer.ts`: `cycleModel()` method (lines ~970-1070)
- Model validation scattered throughout

**Implementation:**
```typescript
// Add to AgentSession class

export interface ModelCycleResult {
  model: Model<any>;
  thinkingLevel: ThinkingLevel;
  isScoped: boolean;
}

/**
 * Set model directly. Validates API key, saves to session and settings.
 */
async setModel(model: Model<any>): Promise<void> {
  const apiKey = await getApiKeyForModel(model);
  if (!apiKey) {
    throw new Error(`No API key for ${model.provider}/${model.id}`);
  }

  this.agent.setModel(model);
  this.sessionManager.saveModelChange(model.provider, model.id);
  this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
}

/**
 * Cycle to next model. Uses scoped models if available.
 * Returns null if only one model available.
 */
async cycleModel(): Promise<ModelCycleResult | null> {
  if (this.scopedModels.length > 0) {
    return this.cycleScopedModel();
  } else {
    return this.cycleAvailableModel();
  }
}

private async cycleScopedModel(): Promise<ModelCycleResult | null> {
  if (this.scopedModels.length <= 1) return null;

  const currentModel = this.model;
  let currentIndex = this.scopedModels.findIndex(
    (sm) => sm.model.id === currentModel?.id && sm.model.provider === currentModel?.provider
  );

  if (currentIndex === -1) currentIndex = 0;
  const nextIndex = (currentIndex + 1) % this.scopedModels.length;
  const next = this.scopedModels[nextIndex];

  // Validate API key
  const apiKey = await getApiKeyForModel(next.model);
  if (!apiKey) {
    throw new Error(`No API key for ${next.model.provider}/${next.model.id}`);
  }

  // Apply model
  this.agent.setModel(next.model);
  this.sessionManager.saveModelChange(next.model.provider, next.model.id);
  this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id);

  // Apply thinking level (silently use "off" if not supported)
  const effectiveThinking = next.model.reasoning ? next.thinkingLevel : "off";
  this.agent.setThinkingLevel(effectiveThinking);
  this.sessionManager.saveThinkingLevelChange(effectiveThinking);
  this.settingsManager.setDefaultThinkingLevel(effectiveThinking);

  return { model: next.model, thinkingLevel: effectiveThinking, isScoped: true };
}

private async cycleAvailableModel(): Promise<ModelCycleResult | null> {
  const { models: availableModels, error } = await getAvailableModels();
  if (error) throw new Error(`Failed to load models: ${error}`);
  if (availableModels.length <= 1) return null;

  const currentModel = this.model;
  let currentIndex = availableModels.findIndex(
    (m) => m.id === currentModel?.id && m.provider === currentModel?.provider
  );

  if (currentIndex === -1) currentIndex = 0;
  const nextIndex = (currentIndex + 1) % availableModels.length;
  const nextModel = availableModels[nextIndex];

  const apiKey = await getApiKeyForModel(nextModel);
  if (!apiKey) {
    throw new Error(`No API key for ${nextModel.provider}/${nextModel.id}`);
  }

  this.agent.setModel(nextModel);
  this.sessionManager.saveModelChange(nextModel.provider, nextModel.id);
  this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);

  return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false };
}

/**
 * Get all available models with valid API keys.
 */
async getAvailableModels(): Promise<Model<any>[]> {
  const { models, error } = await getAvailableModels();
  if (error) throw new Error(error);
  return models;
}
```

**Verification:**
1. `npm run check` passes

- [ ] Add `ModelCycleResult` interface
- [ ] Add `setModel()` method
- [ ] Add `cycleModel()` method with scoped/available variants
- [ ] Add `getAvailableModels()` method
- [ ] Verify with `npm run check`

---

### WP6: AgentSession - Thinking Level Management
> Add setThinkingLevel(), cycleThinkingLevel(), supportsThinking() methods.

**Files to modify:**
- `src/core/agent-session.ts`

**Extract from:**
- `src/tui/tui-renderer.ts`: `cycleThinkingLevel()` method (lines ~940-970)

**Implementation:**
```typescript
// Add to AgentSession class

/**
 * Set thinking level. Silently uses "off" if model doesn't support it.
 * Saves to session and settings.
 */
setThinkingLevel(level: ThinkingLevel): void {
  const effectiveLevel = this.supportsThinking() ? level : "off";
  this.agent.setThinkingLevel(effectiveLevel);
  this.sessionManager.saveThinkingLevelChange(effectiveLevel);
  this.settingsManager.setDefaultThinkingLevel(effectiveLevel);
}

/**
 * Cycle to next thinking level.
 * Returns new level, or null if model doesn't support thinking.
 */
cycleThinkingLevel(): ThinkingLevel | null {
  if (!this.supportsThinking()) return null;

  const modelId = this.model?.id || "";
  const supportsXhigh = modelId.includes("codex-max");
  const levels: ThinkingLevel[] = supportsXhigh
    ? ["off", "minimal", "low", "medium", "high", "xhigh"]
    : ["off", "minimal", "low", "medium", "high"];

  const currentIndex = levels.indexOf(this.thinkingLevel);
  const nextIndex = (currentIndex + 1) % levels.length;
  const nextLevel = levels[nextIndex];

  this.setThinkingLevel(nextLevel);
  return nextLevel;
}

/**
 * Check if current model supports thinking.
 */
supportsThinking(): boolean {
  return !!this.model?.reasoning;
}
```

**Verification:**
1. `npm run check` passes

- [ ] Add `setThinkingLevel()` method
- [ ] Add `cycleThinkingLevel()` method
- [ ] Add `supportsThinking()` method
- [ ] Verify with `npm run check`

---

### WP7: AgentSession - Compaction
> Add compact(), abortCompaction(), checkAutoCompaction(), autoCompactionEnabled methods.

**Files to modify:**
- `src/core/agent-session.ts`

**Extract from:**
- `src/tui/tui-renderer.ts`: `executeCompaction()` (lines ~2280-2370)
- `src/tui/tui-renderer.ts`: `checkAutoCompaction()` (lines ~495-525)
- `src/main.ts`: `runRpcMode()` auto-compaction logic (lines ~730-770)

**Implementation:**
```typescript
// Add to AgentSession class

export interface CompactionResult {
  tokensBefore: number;
  tokensAfter: number;
  summary: string;
}

private compactionAbortController: AbortController | null = null;

/**
 * Manually compact the session context.
 * Aborts current agent operation first.
 */
async compact(customInstructions?: string): Promise<CompactionResult> {
  // Abort any running operation
  this.unsubscribeAll();
  await this.abort();

  // Create abort controller
  this.compactionAbortController = new AbortController();

  try {
    const apiKey = await getApiKeyForModel(this.model!);
    if (!apiKey) {
      throw new Error(`No API key for ${this.model!.provider}`);
    }

    const entries = this.sessionManager.loadEntries();
    const settings = this.settingsManager.getCompactionSettings();
    const compactionEntry = await compact(
      entries,
      this.model!,
      settings,
      apiKey,
      this.compactionAbortController.signal,
      customInstructions,
    );

    if (this.compactionAbortController.signal.aborted) {
      throw new Error("Compaction cancelled");
    }

    // Save and reload
    this.sessionManager.saveCompaction(compactionEntry);
    const loaded = loadSessionFromEntries(this.sessionManager.loadEntries());
    this.agent.replaceMessages(loaded.messages);

    return {
      tokensBefore: compactionEntry.tokensBefore,
      tokensAfter: compactionEntry.tokensAfter,
      summary: compactionEntry.summary,
    };
  } finally {
    this.compactionAbortController = null;
    // Note: caller needs to re-subscribe after compaction
  }
}

/**
 * Cancel in-progress compaction.
 */
abortCompaction(): void {
  this.compactionAbortController?.abort();
}

/**
 * Check if auto-compaction should run, and run if so.
 * Returns result if compaction occurred, null otherwise.
 */
async checkAutoCompaction(): Promise<CompactionResult | null> {
  const settings = this.settingsManager.getCompactionSettings();
  if (!settings.enabled) return null;

  // Get last non-aborted assistant message
  const messages = this.messages;
  let lastAssistant: AssistantMessage | null = null;
  for (let i = messages.length - 1; i >= 0; i--) {
    const msg = messages[i];
    if (msg.role === "assistant") {
      const assistantMsg = msg as AssistantMessage;
      if (assistantMsg.stopReason !== "aborted") {
        lastAssistant = assistantMsg;
        break;
      }
    }
  }
  if (!lastAssistant) return null;

  const contextTokens = calculateContextTokens(lastAssistant.usage);
  const contextWindow = this.model?.contextWindow ?? 0;

  if (!shouldCompact(contextTokens, contextWindow, settings)) return null;

  // Perform auto-compaction (don't abort current operation for auto)
  try {
    const apiKey = await getApiKeyForModel(this.model!);
    if (!apiKey) return null;

    const entries = this.sessionManager.loadEntries();
    const compactionEntry = await compact(entries, this.model!, settings, apiKey);

    this.sessionManager.saveCompaction(compactionEntry);
    const loaded = loadSessionFromEntries(this.sessionManager.loadEntries());
    this.agent.replaceMessages(loaded.messages);

    return {
      tokensBefore: compactionEntry.tokensBefore,
      tokensAfter: compactionEntry.tokensAfter,
      summary: compactionEntry.summary,
    };
  } catch {
    return null; // Silently fail auto-compaction
  }
}

/**
 * Toggle auto-compaction setting.
 */
setAutoCompactionEnabled(enabled: boolean): void {
  this.settingsManager.setCompactionEnabled(enabled);
}

get autoCompactionEnabled(): boolean {
  return this.settingsManager.getCompactionEnabled();
}
```

**Verification:**
1. `npm run check` passes

- [ ] Add `CompactionResult` interface
- [ ] Add `compact()` method
- [ ] Add `abortCompaction()` method
- [ ] Add `checkAutoCompaction()` method
- [ ] Add `setAutoCompactionEnabled()` and getter
- [ ] Verify with `npm run check`

---

### WP8: AgentSession - Bash Execution
> Add executeBash(), abortBash(), isBashRunning using the bash-executor module.

**Files to modify:**
- `src/core/agent-session.ts`

**Dependencies:** WP1 (bash-executor.ts)

**Implementation:**
```typescript
// Add to AgentSession class

import { executeBash as executeBashCommand, type BashResult } from "./bash-executor.js";
import type { BashExecutionMessage } from "../messages.js";

private bashAbortController: AbortController | null = null;

/**
 * Execute a bash command. Adds result to agent context and session.
 */
async executeBash(command: string, onChunk?: (chunk: string) => void): Promise<BashResult> {
  this.bashAbortController = new AbortController();

  try {
    const result = await executeBashCommand(command, {
      onChunk,
      signal: this.bashAbortController.signal,
    });

    // Create and save message
    const bashMessage: BashExecutionMessage = {
      role: "bashExecution",
      command,
      output: result.output,
      exitCode: result.exitCode,
      cancelled: result.cancelled,
      truncated: result.truncated,
      fullOutputPath: result.fullOutputPath,
      timestamp: Date.now(),
    };

    this.agent.appendMessage(bashMessage);
    this.sessionManager.saveMessage(bashMessage);

    // Initialize session if needed
    if (this.sessionManager.shouldInitializeSession(this.agent.state.messages)) {
      this.sessionManager.startSession(this.agent.state);
    }

    return result;
  } finally {
    this.bashAbortController = null;
  }
}

/**
 * Cancel running bash command.
 */
abortBash(): void {
  this.bashAbortController?.abort();
}

get isBashRunning(): boolean {
  return this.bashAbortController !== null;
}
```

**Verification:**
1. `npm run check` passes

- [ ] Add bash execution methods using bash-executor module
- [ ] Verify with `npm run check`

---

### WP9: AgentSession - Session Management
> Add switchSession(), branch(), getUserMessagesForBranching(), getSessionStats(), exportToHtml().

**Files to modify:**
- `src/core/agent-session.ts`

**Extract from:**
- `src/tui/tui-renderer.ts`: `handleResumeSession()` (lines ~1650-1710)
- `src/tui/tui-renderer.ts`: `showUserMessageSelector()` branch logic (lines ~1560-1600)
- `src/tui/tui-renderer.ts`: `handleSessionCommand()` (lines ~1870-1930)

**Implementation:**
```typescript
// Add to AgentSession class

export interface SessionStats {
  sessionFile: string;
  sessionId: string;
  userMessages: number;
  assistantMessages: number;
  toolCalls: number;
  toolResults: number;
  totalMessages: number;
  tokens: {
    input: number;
    output: number;
    cacheRead: number;
    cacheWrite: number;
    total: number;
  };
  cost: number;
}

/**
 * Switch to a different session file.
 * Aborts current operation, loads messages, restores model/thinking.
 */
async switchSession(sessionPath: string): Promise<void> {
  this.unsubscribeAll();
  await this.abort();
  this.queuedMessages = [];

  this.sessionManager.setSessionFile(sessionPath);
  const loaded = loadSessionFromEntries(this.sessionManager.loadEntries());
  this.agent.replaceMessages(loaded.messages);

  // Restore model
  const savedModel = this.sessionManager.loadModel();
  if (savedModel) {
    const availableModels = (await getAvailableModels()).models;
    const match = availableModels.find(
      (m) => m.provider === savedModel.provider && m.id === savedModel.modelId
    );
    if (match) {
      this.agent.setModel(match);
    }
  }

  // Restore thinking level
  const savedThinking = this.sessionManager.loadThinkingLevel();
  if (savedThinking) {
    this.agent.setThinkingLevel(savedThinking as ThinkingLevel);
  }

  // Note: caller needs to re-subscribe after switch
}

/**
 * Create a branch from a specific entry index.
 * Returns the text of the selected user message (for editor pre-fill).
 */
branch(entryIndex: number): string {
  const entries = this.sessionManager.loadEntries();
  const selectedEntry = entries[entryIndex];

  if (selectedEntry.type !== "message" || selectedEntry.message.role !== "user") {
    throw new Error("Invalid entry index for branching");
  }

  const selectedText = this.extractUserMessageText(selectedEntry.message.content);

  // Create branched session
  const newSessionFile = this.sessionManager.createBranchedSessionFromEntries(entries, entryIndex);
  this.sessionManager.setSessionFile(newSessionFile);

  // Reload
  const loaded = loadSessionFromEntries(this.sessionManager.loadEntries());
  this.agent.replaceMessages(loaded.messages);

  return selectedText;
}

/**
 * Get all user messages from session for branch selector.
 */
getUserMessagesForBranching(): Array<{ entryIndex: number; text: string }> {
  const entries = this.sessionManager.loadEntries();
  const result: Array<{ entryIndex: number; text: string }> = [];

  for (let i = 0; i < entries.length; i++) {
    const entry = entries[i];
    if (entry.type !== "message") continue;
    if (entry.message.role !== "user") continue;

    const text = this.extractUserMessageText(entry.message.content);
    if (text) {
      result.push({ entryIndex: i, text });
    }
  }

  return result;
}

private extractUserMessageText(content: string | Array<{ type: string; text?: string }>): string {
  if (typeof content === "string") return content;
  if (Array.isArray(content)) {
    return content
      .filter((c): c is { type: "text"; text: string } => c.type === "text")
      .map((c) => c.text)
      .join("");
  }
  return "";
}

/**
 * Get session statistics.
 */
getSessionStats(): SessionStats {
  const state = this.state;
  const userMessages = state.messages.filter((m) => m.role === "user").length;
  const assistantMessages = state.messages.filter((m) => m.role === "assistant").length;
  const toolResults = state.messages.filter((m) => m.role === "toolResult").length;

  let toolCalls = 0;
  let totalInput = 0;
  let totalOutput = 0;
  let totalCacheRead = 0;
  let totalCacheWrite = 0;
  let totalCost = 0;

  for (const message of state.messages) {
    if (message.role === "assistant") {
      const assistantMsg = message as AssistantMessage;
      toolCalls += assistantMsg.content.filter((c) => c.type === "toolCall").length;
      totalInput += assistantMsg.usage.input;
      totalOutput += assistantMsg.usage.output;
      totalCacheRead += assistantMsg.usage.cacheRead;
      totalCacheWrite += assistantMsg.usage.cacheWrite;
      totalCost += assistantMsg.usage.cost.total;
    }
  }

  return {
    sessionFile: this.sessionFile,
    sessionId: this.sessionId,
    userMessages,
    assistantMessages,
    toolCalls,
    toolResults,
    totalMessages: state.messages.length,
    tokens: {
      input: totalInput,
      output: totalOutput,
      cacheRead: totalCacheRead,
      cacheWrite: totalCacheWrite,
      total: totalInput + totalOutput + totalCacheRead + totalCacheWrite,
    },
    cost: totalCost,
  };
}

/**
 * Export session to HTML.
 */
exportToHtml(outputPath?: string): string {
  return exportSessionToHtml(this.sessionManager, this.state, outputPath);
}
```

**Verification:**
1. `npm run check` passes

- [ ] Add `SessionStats` interface
- [ ] Add `switchSession()` method
- [ ] Add `branch()` method
- [ ] Add `getUserMessagesForBranching()` method
- [ ] Add `getSessionStats()` method
- [ ] Add `exportToHtml()` method
- [ ] Verify with `npm run check`

---

### WP10: AgentSession - Utility Methods
> Add getLastAssistantText() and any remaining utilities.

**Files to modify:**
- `src/core/agent-session.ts`

**Extract from:**
- `src/tui/tui-renderer.ts`: `handleCopyCommand()` (lines ~1840-1870)

**Implementation:**
```typescript
// Add to AgentSession class

/**
 * Get text content of last assistant message (for /copy).
 * Returns null if no assistant message exists.
 */
getLastAssistantText(): string | null {
  const lastAssistant = this.messages
    .slice()
    .reverse()
    .find((m) => m.role === "assistant");

  if (!lastAssistant) return null;

  let text = "";
  for (const content of lastAssistant.content) {
    if (content.type === "text") {
      text += content.text;
    }
  }

  return text.trim() || null;
}

/**
 * Get queued message count (for UI display).
 */
get queuedMessageCount(): number {
  return this.queuedMessages.length;
}

/**
 * Get queued messages (for display, not modification).
 */
getQueuedMessages(): readonly string[] {
  return this.queuedMessages;
}
```

**Verification:**
1. `npm run check` passes

- [ ] Add `getLastAssistantText()` method
- [ ] Add `queuedMessageCount` getter
- [ ] Add `getQueuedMessages()` method
- [ ] Verify with `npm run check`

---

### WP11: Create print-mode.ts
> Extract single-shot mode into its own module using AgentSession.

**Files to create:**
- `src/modes/print-mode.ts`

**Extract from:**
- `src/main.ts`: `runSingleShotMode()` function (lines ~615-640)

**Implementation:**
```typescript
// src/modes/print-mode.ts

import type { Attachment } from "@mariozechner/pi-agent-core";
import type { AssistantMessage } from "@mariozechner/pi-ai";
import type { AgentSession } from "../core/agent-session.js";

export async function runPrintMode(
  session: AgentSession,
  mode: "text" | "json",
  messages: string[],
  initialMessage?: string,
  initialAttachments?: Attachment[],
): Promise<void> {

  if (mode === "json") {
    // Output all events as JSON
    session.subscribe((event) => {
      console.log(JSON.stringify(event));
    });
  }

  // Send initial message with attachments
  if (initialMessage) {
    await session.prompt(initialMessage, { attachments: initialAttachments });
  }

  // Send remaining messages
  for (const message of messages) {
    await session.prompt(message);
  }

  // In text mode, output final response
  if (mode === "text") {
    const state = session.state;
    const lastMessage = state.messages[state.messages.length - 1];

    if (lastMessage?.role === "assistant") {
      const assistantMsg = lastMessage as AssistantMessage;

      // Check for error/aborted
      if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") {
        console.error(assistantMsg.errorMessage || `Request ${assistantMsg.stopReason}`);
        process.exit(1);
      }

      // Output text content
      for (const content of assistantMsg.content) {
        if (content.type === "text") {
          console.log(content.text);
        }
      }
    }
  }
}
```

**Verification:**
1. `npm run check` passes
2. Manual test: `pi -p "echo hello"` still works

- [ ] Create `src/modes/print-mode.ts`
- [ ] Verify with `npm run check`

---

### WP12: Create rpc-mode.ts
> Extract RPC mode into its own module using AgentSession.

**Files to create:**
- `src/modes/rpc-mode.ts`

**Extract from:**
- `src/main.ts`: `runRpcMode()` function (lines ~700-800)

**Implementation:**
```typescript
// src/modes/rpc-mode.ts

import * as readline from "readline";
import type { AgentSession } from "../core/agent-session.js";

export async function runRpcMode(session: AgentSession): Promise<never> {
  // Output all events as JSON
  session.subscribe((event) => {
    console.log(JSON.stringify(event));

    // Emit auto-compaction events
    // (checkAutoCompaction is called internally by AgentSession after assistant messages)
  });

  // Listen for JSON input
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
    terminal: false,
  });

  rl.on("line", async (line: string) => {
    try {
      const input = JSON.parse(line);

      switch (input.type) {
        case "prompt":
          if (input.message) {
            await session.prompt(input.message, {
              attachments: input.attachments,
              expandSlashCommands: false, // RPC mode doesn't expand slash commands
            });
          }
          break;

        case "abort":
          await session.abort();
          break;

        case "compact":
          try {
            const result = await session.compact(input.customInstructions);
            console.log(JSON.stringify({ type: "compaction", ...result }));
          } catch (error: any) {
            console.log(JSON.stringify({ type: "error", error: `Compaction failed: ${error.message}` }));
          }
          break;

        case "bash":
          if (input.command) {
            try {
              const result = await session.executeBash(input.command);
              console.log(JSON.stringify({ type: "bash_end", message: result }));
            } catch (error: any) {
              console.log(JSON.stringify({ type: "error", error: `Bash failed: ${error.message}` }));
            }
          }
          break;

        default:
          console.log(JSON.stringify({ type: "error", error: `Unknown command: ${input.type}` }));
      }
    } catch (error: any) {
      console.log(JSON.stringify({ type: "error", error: error.message }));
    }
  });

  // Keep process alive forever
  return new Promise(() => {});
}
```

**Verification:**
1. `npm run check` passes
2. Manual test: RPC mode still works (if you have a way to test it)

- [ ] Create `src/modes/rpc-mode.ts`
- [ ] Verify with `npm run check`

---

### WP13: Create modes/index.ts barrel export
> Create barrel export for all modes.

**Files to create:**
- `src/modes/index.ts`

**Implementation:**
```typescript
// src/modes/index.ts
export { runPrintMode } from "./print-mode.js";
export { runRpcMode } from "./rpc-mode.js";
// InteractiveMode will be added later
```

- [ ] Create `src/modes/index.ts`
- [ ] Verify with `npm run check`

---

### WP14: Update main.ts to use AgentSession and new modes
> Refactor main.ts to use AgentSession and the new mode modules.

**Files to modify:**
- `src/main.ts`

**Changes:**
1. Remove `runSingleShotMode()` function (replaced by print-mode.ts)
2. Remove `runRpcMode()` function (replaced by rpc-mode.ts)
3. Remove `executeRpcBashCommand()` function (replaced by bash-executor.ts)
4. Create `AgentSession` instance after agent setup
5. Pass `AgentSession` to mode functions

**Key changes in main():**
```typescript
// After agent creation, create AgentSession
const session = new AgentSession({
  agent,
  sessionManager,
  settingsManager,
  scopedModels,
  fileCommands: loadSlashCommands(),
});

// Route to modes
if (mode === "rpc") {
  await runRpcMode(session);
} else if (isInteractive) {
  // For now, still use TuiRenderer directly (will refactor in WP15+)
  await runInteractiveMode(agent, sessionManager, ...);
} else {
  await runPrintMode(session, mode, parsed.messages, initialMessage, initialAttachments);
}
```

**Verification:**
1. `npm run check` passes
2. Manual test: `pi -p "hello"` works
3. Manual test: `pi --mode json "hello"` works
4. Manual test: `pi --mode rpc` works

- [ ] Remove `runSingleShotMode()` from main.ts
- [ ] Remove `runRpcMode()` from main.ts
- [ ] Remove `executeRpcBashCommand()` from main.ts
- [ ] Import and use `runPrintMode` from modes
- [ ] Import and use `runRpcMode` from modes
- [ ] Create `AgentSession` in main()
- [ ] Update mode routing to use new functions
- [ ] Verify with `npm run check`
- [ ] Manual test all three modes

---

### WP15: Refactor TuiRenderer to use AgentSession
> Update TuiRenderer to use AgentSession instead of direct agent/sessionManager access.

**Files to modify:**
- `src/tui/tui-renderer.ts`

**This is the largest change. Strategy:**
1. Change constructor to accept `AgentSession` instead of separate agent/sessionManager/settingsManager
2. Replace all `this.agent.*` calls with `this.session.agent.*` or appropriate AgentSession methods
3. Replace all `this.sessionManager.*` calls with AgentSession methods
4. Replace all `this.settingsManager.*` calls with AgentSession methods where applicable
5. Remove duplicated logic that now lives in AgentSession

**Key replacements:**
| Old | New |
|-----|-----|
| `this.agent.prompt()` | `this.session.prompt()` |
| `this.agent.abort()` | `this.session.abort()` |
| `this.sessionManager.saveMessage()` | (handled internally by AgentSession.subscribe) |
| `this.cycleThinkingLevel()` | `this.session.cycleThinkingLevel()` |
| `this.cycleModel()` | `this.session.cycleModel()` |
| `this.executeBashCommand()` | `this.session.executeBash()` |
| `this.executeCompaction()` | `this.session.compact()` |
| `this.checkAutoCompaction()` | (handled internally by AgentSession) |
| `this.handleClearCommand()` reset logic | `this.session.reset()` |
| `this.handleResumeSession()` | `this.session.switchSession()` |

**Constructor change:**
```typescript
// Old
constructor(
  agent: Agent,
  sessionManager: SessionManager,
  settingsManager: SettingsManager,
  version: string,
  ...
)

// New
constructor(
  session: AgentSession,
  version: string,
  ...
)
```

**Verification:**
1. `npm run check` passes
2. Manual test: Full interactive mode works
3. Manual test: All slash commands work
4. Manual test: All hotkeys work
5. Manual test: Bash execution works
6. Manual test: Model/thinking cycling works

- [ ] Change TuiRenderer constructor to accept AgentSession
- [ ] Update all agent access to go through session
- [ ] Remove `subscribeToAgent()` method (use session.subscribe)
- [ ] Remove `checkAutoCompaction()` method (handled by session)
- [ ] Update `cycleThinkingLevel()` to use session method
- [ ] Update `cycleModel()` to use session method
- [ ] Update bash execution to use session.executeBash()
- [ ] Update compaction to use session.compact()
- [ ] Update reset logic to use session.reset()
- [ ] Update session switching to use session.switchSession()
- [ ] Update branch logic to use session.branch()
- [ ] Remove all direct sessionManager access
- [ ] Verify with `npm run check`
- [ ] Manual test interactive mode thoroughly

---

### WP16: Update runInteractiveMode to use AgentSession
> Update the runInteractiveMode function in main.ts to create and pass AgentSession.

**Files to modify:**
- `src/main.ts`

**Changes:**
```typescript
async function runInteractiveMode(
  session: AgentSession,  // Changed from individual params
  version: string,
  changelogMarkdown: string | null,
  collapseChangelog: boolean,
  modelFallbackMessage: string | null,
  versionCheckPromise: Promise<string | null>,
  initialMessages: string[],
  initialMessage?: string,
  initialAttachments?: Attachment[],
  fdPath: string | null,
): Promise<void> {
  const renderer = new TuiRenderer(
    session,
    version,
    changelogMarkdown,
    collapseChangelog,
    fdPath,
  );
  // ... rest stays similar
}
```

**Verification:**
1. `npm run check` passes
2. Manual test: Interactive mode works

- [ ] Update `runInteractiveMode()` signature
- [ ] Update TuiRenderer instantiation
- [ ] Verify with `npm run check`

---

### WP17: Rename TuiRenderer to InteractiveMode
> Rename the class and file to better reflect its purpose.

**Files to rename/modify:**
- `src/tui/tui-renderer.ts` → `src/modes/interactive/interactive-mode.ts`
- Update all imports

**Steps:**
1. Create `src/modes/interactive/` directory
2. Move and rename file
3. Rename class from `TuiRenderer` to `InteractiveMode`
4. Update imports in main.ts
5. Update barrel export in modes/index.ts

**Verification:**
1. `npm run check` passes
2. Manual test: Interactive mode works

- [ ] Create `src/modes/interactive/` directory
- [ ] Move `tui/tui-renderer.ts` to `modes/interactive/interactive-mode.ts`
- [ ] Rename class to `InteractiveMode`
- [ ] Update imports in main.ts
- [ ] Update modes/index.ts barrel export
- [ ] Verify with `npm run check`

---

### WP18: Move remaining TUI components
> Move TUI-specific components to the interactive mode directory.

**Files to move:**
- `src/tui/assistant-message.ts` → `src/modes/interactive/components/`
- `src/tui/bash-execution.ts` → `src/modes/interactive/components/`
- `src/tui/compaction.ts` → `src/modes/interactive/components/`
- `src/tui/custom-editor.ts` → `src/modes/interactive/components/`
- `src/tui/dynamic-border.ts` → `src/modes/interactive/components/`
- `src/tui/footer.ts` → `src/modes/interactive/components/`
- `src/tui/model-selector.ts` → `src/modes/interactive/selectors/`
- `src/tui/oauth-selector.ts` → `src/modes/interactive/selectors/`
- `src/tui/queue-mode-selector.ts` → `src/modes/interactive/selectors/`
- `src/tui/session-selector.ts` → `src/modes/interactive/selectors/`
- `src/tui/theme-selector.ts` → `src/modes/interactive/selectors/`
- `src/tui/thinking-selector.ts` → `src/modes/interactive/selectors/`
- `src/tui/tool-execution.ts` → `src/modes/interactive/components/`
- `src/tui/user-message.ts` → `src/modes/interactive/components/`
- `src/tui/user-message-selector.ts` → `src/modes/interactive/selectors/`

**Note:** This is optional reorganization. Can be done later or skipped if too disruptive.

- [ ] Create directory structure under `src/modes/interactive/`
- [ ] Move component files
- [ ] Move selector files
- [ ] Update all imports
- [ ] Remove empty `src/tui/` directory
- [ ] Verify with `npm run check`

---

### WP19: Extract setup logic from main.ts
> Create setup.ts with model resolution, system prompt building, etc.

**Files to create:**
- `src/core/setup.ts`

**Extract from main.ts:**
- `buildSystemPrompt()` function
- `loadProjectContextFiles()` function
- `loadContextFileFromDir()` function
- `resolveModelScope()` function
- Model resolution logic (the priority system)
- Session loading/restoration logic

**Implementation:**
```typescript
// src/core/setup.ts

export interface SetupOptions {
  provider?: string;
  model?: string;
  apiKey?: string;
  systemPrompt?: string;
  appendSystemPrompt?: string;
  thinking?: ThinkingLevel;
  continue?: boolean;
  resume?: boolean;
  models?: string[];
  tools?: ToolName[];
  sessionManager: SessionManager;
  settingsManager: SettingsManager;
}

export interface SetupResult {
  agent: Agent;
  initialModel: Model<any> | null;
  initialThinking: ThinkingLevel;
  scopedModels: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
  modelFallbackMessage: string | null;
}

export async function setupAgent(options: SetupOptions): Promise<SetupResult>;

export function buildSystemPrompt(
  customPrompt?: string,
  selectedTools?: ToolName[],
  appendSystemPrompt?: string
): string;

export function loadProjectContextFiles(): Array<{ path: string; content: string }>;

export async function resolveModelScope(
  patterns: string[]
): Promise<Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>>;
```

**Verification:**
1. `npm run check` passes
2. All modes still work

- [ ] Create `src/core/setup.ts`
- [ ] Move `buildSystemPrompt()` from main.ts
- [ ] Move `loadProjectContextFiles()` from main.ts
- [ ] Move `loadContextFileFromDir()` from main.ts
- [ ] Move `resolveModelScope()` from main.ts
- [ ] Create `setupAgent()` function
- [ ] Update main.ts to use setup.ts
- [ ] Verify with `npm run check`

---

### WP20: Final cleanup and documentation
> Clean up main.ts, add documentation, verify everything works.

**Tasks:**
1. Remove any dead code from main.ts
2. Ensure main.ts is ~200-300 lines (just arg parsing + routing)
3. Add JSDoc comments to AgentSession public methods
4. Update README if needed
5. Final manual testing of all features

**Verification:**
1. `npm run check` passes
2. All three modes work
3. All slash commands work
4. All hotkeys work
5. Session persistence works
6. Compaction works
7. Bash execution works
8. Model/thinking cycling works

- [ ] Remove dead code from main.ts
- [ ] Add JSDoc to AgentSession
- [ ] Final testing
- [ ] Update README if needed

---

## Testing Checklist (E2E)

After refactoring is complete, verify these scenarios:

### Interactive Mode
- [ ] Start fresh session: `pi`
- [ ] Continue session: `pi -c`
- [ ] Resume session: `pi -r`
- [ ] Initial message: `pi "hello"`
- [ ] File attachment: `pi @file.txt "summarize"`
- [ ] Model cycling: Ctrl+P
- [ ] Thinking cycling: Shift+Tab
- [ ] Tool expansion: Ctrl+O
- [ ] Thinking toggle: Ctrl+T
- [ ] Abort: Esc during streaming
- [ ] Clear: Ctrl+C twice to exit
- [ ] Bash command: `!ls -la`
- [ ] Bash cancel: Esc during bash
- [ ] /thinking command
- [ ] /model command
- [ ] /export command
- [ ] /copy command
- [ ] /session command
- [ ] /changelog command
- [ ] /branch command
- [ ] /login and /logout commands
- [ ] /queue command
- [ ] /theme command
- [ ] /clear command
- [ ] /compact command
- [ ] /autocompact command
- [ ] /resume command
- [ ] Message queuing while streaming

### Print Mode
- [ ] Basic: `pi -p "hello"`
- [ ] JSON: `pi --mode json "hello"`
- [ ] Multiple messages: `pi -p "first" "second"`
- [ ] File attachment: `pi -p @file.txt "summarize"`

### RPC Mode
- [ ] Start: `pi --mode rpc`
- [ ] Send prompt via JSON
- [ ] Abort via JSON
- [ ] Compact via JSON
- [ ] Bash via JSON

---

## Notes

- This refactoring should be done incrementally, testing after each work package
- If a WP introduces regressions, fix them before moving to the next
- The most risky WP is WP15 (updating TuiRenderer) - take extra care there
- Consider creating git commits after each major WP for easy rollback


Successfully wrote 48506 bytes to /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/refactor.md


bash

cd /Users/badlogic/workspaces/pi-mono && git add packages/coding-agent/docs/refactor.md && git commit -m "Add refactoring plan for coding-agent"


Running formatting, linting, and type checking...

> pi-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 229 files in 117ms. Fixed 1 file.

> @mariozechner/pi-agent-core@0.14.2 check
> tsgo --noEmit

> @mariozechner/pi-ai@0.14.2 check
> biome check --write . && tsgo --noEmit

Checked 36 files in 38ms. No fixes applied.

> @mariozechner/pi-coding-agent@0.14.2 check
> tsgo --noEmit

> @mariozechner/pi-mom@0.14.2 check
> biome check --write . && tsgo --noEmit

Checked 12 files in 14ms. No fixes applied.

> @mariozechner/pi@0.14.2 check
> biome check --write .

Checked 10 files in 17ms. No fixes applied.

> @mariozechner/pi-proxy@0.14.2 check
> biome check --write .

Checked 3 files in 3ms. No fixes applied.

> @mariozechner/pi-tui@0.14.2 check
> biome check --write . && tsgo --noEmit

Checked 22 files in 23ms. No fixes applied.

> @mariozechner/pi-web-ui@0.14.2 check
> tsc --noEmit && cd example && tsc --noEmit

> pi-web-ui-example@1.2.2 check
> tsc --noEmit

✅ All pre-commit checks passed!
[refactor 1507f8b] Add refactoring plan for coding-agent
 1 file changed, 1668 insertions(+)
 create mode 100644 packages/coding-agent/docs/refactor.md


Done. Created `packages/coding-agent/docs/refactor.md` with:

1. **Goals and motivation** for the refactoring
2. **Architecture overview** (current vs target state)
3. **Full AgentSession API** design
4. **20 work packages** in optimal order, each with:
   - Files to create/modify
   - What to extract from where (with line numbers)
   - Implementation details/code snippets
   - Verification steps
   - Checkboxes for tracking
5. **E2E testing checklist** at the end
6. **Resume instructions** at the top

A new session can:
1. Read the refactor.md file
2. Run `git diff` to see in-progress work
3. Find the first unchecked work package
4. Read the referenced files before making changes
5. Continue implementing

Want me to start on WP1 (bash-executor.ts)?


get messages(): Message[]; not AppMessage with our extension?


The user is pointing out that `get messages(): Message[]` in the AgentSession API should probably return `Message[] | AppMessage[]` or a union type that includes the custom `BashExecutionMessage` type.

Looking at the code, `BashExecutionMessage` is defined in `messages.ts` and it's a custom message type with `role: "bashExecution"`. The agent's `state.messages` would include these custom messages alongside standard `Message` types.

So the getter should probably be typed as `(Message | AppMessage)[]` or whatever the correct union type is that includes both standard messages and the custom app messages like `BashExecutionMessage`.

Let me check what `AppMessage` is defined as in the codebase.

Good catch. Let me check what types we're dealing with:

cd /Users/badlogic/workspaces/pi-mono && grep -r "AppMessage" packages/coding-agent/src/ --include="*.ts" | head -20

cd /Users/badlogic/workspaces/pi-mono && grep -r "BashExecutionMessage" packages/coding-agent/src/ --include="*.ts" | head -10


packages/coding-agent/src/tui/tui-renderer.ts:import type { Agent, AgentEvent, AgentState, AppMessage, ThinkingLevel } from "@mariozechner/pi-agent-core";
packages/coding-agent/src/tui/tui-renderer.ts:	private addMessageToChat(message: Message | AppMessage): void {
packages/coding-agent/src/compaction.ts:import type { AppMessage } from "@mariozechner/pi-agent-core";
packages/coding-agent/src/compaction.ts:function getAssistantUsage(msg: AppMessage): Usage | null {
packages/coding-agent/src/compaction.ts:	currentMessages: AppMessage[],
packages/coding-agent/src/compaction.ts:	const messagesToSummarize: AppMessage[] = [];
packages/coding-agent/src/messages.ts: * Extends the base AppMessage type with coding-agent specific message types,
packages/coding-agent/src/messages.ts:import type { AppMessage } from "@mariozechner/pi-agent-core";
packages/coding-agent/src/messages.ts:export function isBashExecutionMessage(msg: AppMessage | Message): msg is BashExecutionMessage {
packages/coding-agent/src/messages.ts: * Transform AppMessages (including custom types) to LLM-compatible Messages.
packages/coding-agent/src/messages.ts:export function messageTransformer(messages: AppMessage[]): Message[] {
packages/coding-agent/src/session-manager.ts:import type { AgentState, AppMessage } from "@mariozechner/pi-agent-core";
packages/coding-agent/src/session-manager.ts:	message: AppMessage;
packages/coding-agent/src/session-manager.ts:	messages: AppMessage[];
packages/coding-agent/src/session-manager.ts:export function createSummaryMessage(summary: string): AppMessage {
packages/coding-agent/src/session-manager.ts:		const messages: AppMessage[] = [];
packages/coding-agent/src/session-manager.ts:	const keptMessages: AppMessage[] = [];
packages/coding-agent/src/session-manager.ts:	loadMessages(): AppMessage[] {


packages/coding-agent/src/main.ts:import { type BashExecutionMessage, messageTransformer } from "./messages.js";
packages/coding-agent/src/main.ts:					const bashMessage: BashExecutionMessage = {
packages/coding-agent/src/export-html.ts:import { type BashExecutionMessage, isBashExecutionMessage } from "./messages.js";
packages/coding-agent/src/export-html.ts:	if (isBashExecutionMessage(message)) {
packages/coding-agent/src/export-html.ts:		const bashMsg = message as unknown as BashExecutionMessage;
packages/coding-agent/src/tui/tui-renderer.ts:import { type BashExecutionMessage, isBashExecutionMessage } from "../messages.js";
packages/coding-agent/src/tui/tui-renderer.ts:		if (isBashExecutionMessage(message)) {
packages/coding-agent/src/tui/tui-renderer.ts:			const bashMsg = message as BashExecutionMessage;
packages/coding-agent/src/tui/tui-renderer.ts:			if (isBashExecutionMessage(message)) {
packages/coding-agent/src/tui/tui-renderer.ts:			if (isBashExecutionMessage(message)) {


/**
 * Custom message types and transformers for the coding agent.
 *
 * Extends the base AppMessage type with coding-agent specific message types,
 * and provides a transformer to convert them to LLM-compatible messages.
 */

import type { AppMessage } from "@mariozechner/pi-agent-core";
import type { Message } from "@mariozechner/pi-ai";

// ============================================================================
// Custom Message Types
// ============================================================================

/**
 * Message type for bash executions via the ! command.
 */
export interface BashExecutionMessage {
	role: "bashExecution";
	command: string;
	output: string;
	exitCode: number | null;
	cancelled: boolean;
	truncated: boolean;
	fullOutputPath?: string;
	timestamp: number;
}

// Extend CustomMessages via declaration merging
declare module "@mariozechner/pi-agent-core" {
	interface CustomMessages {
		bashExecution: BashExecutionMessage;
	}
}

// ============================================================================
// Type Guards
// ============================================================================

/**
 * Type guard for BashExecutionMessage.
 */
export function isBashExecutionMessage(msg: AppMessage | Message): msg is BashExecutionMessage {
	return (msg as BashExecutionMessage).role === "bashExecution";
}

// ============================================================================
// Message Formatting
// ============================================================================

/**
 * Convert a BashExecutionMessage to user message text for LLM context.
 */
export function bashExecutionToText(msg: BashExecutionMessage): string {
	let text = `Ran \`${msg.command}\`\n`;
	if (msg.output) {
		text += "```\n" + msg.output + "\n```";
	} else {
		text += "(no output)";
	}
	if (msg.cancelled) {
		text += "\n\n(command cancelled)";
	} else if (msg.exitCode !== null && msg.exitCode !== 0) {
		text += `\n\nCommand exited with code ${msg.exitCode}`;
	}
	if (msg.truncated && msg.fullOutputPath) {
		text += `\n\n[Output truncated. Full output: ${msg.fullOutputPath}]`;
	}
	return text;
}

// ============================================================================
// Message Transformer
// ============================================================================

/**
 * Transform AppMessages (including custom types) to LLM-compatible Messages.
 *
 * This is used by:
 * - Agent's messageTransformer option (for prompt calls)
 * - Compaction's generateSummary (for summarization)
 */
export function messageTransformer(messages: AppMessage[]): Message[] {
	return messages
		.map((m): Message | null => {
			if (isBashExecutionMessage(m)) {
				// Convert bash execution to user message
				return {
					role: "user",
					content: [{ type: "text", text: bashExecutionToText(m) }],
					timestamp: m.timestamp,
				};
			}
			// Pass through standard LLM roles
			if (m.role === "user" || m.role === "assistant" || m.role === "toolResult") {
				return m as Message;
			}
			// Filter out unknown message types
			return null;
		})
		.filter((m): m is Message => m !== null);
}


You're right. The agent stores `AppMessage[]` which includes both standard `Message` types and custom types like `BashExecutionMessage`. Let me update the refactor.md:

edit

get messages(): AppMessage[];  // Includes custom types like BashExecutionMessage

get messages(): Message[] { return this.agent.state.messages; }

get messages(): AppMessage[] { return this.agent.state.messages; }

import type { Agent, AgentEvent, AgentState, ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { Model, Message } from "@mariozechner/pi-ai";

import type { Agent, AgentEvent, AgentState, AppMessage, ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { Model } from "@mariozechner/pi-ai";


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/refactor.md. Changed 28 characters to 83 characters.

      ...
   83   get state(): AgentState;
   84   get model(): Model<any> | null;
   85   get thinkingLevel(): ThinkingLevel;
   86   get isStreaming(): boolean;
-  87   get messages(): Message[];
+  87   get messages(): AppMessage[];  // Includes custom types like BashExecutionMessage
   88
   89   // Event subscription (handles session persistence internally)
   90   subscribe(listener: (event: AgentEvent) => void): () => void;
   91
      ...


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/refactor.md. Changed 65 characters to 68 characters.

      ...
  228   get state(): AgentState { return this.agent.state; }
  229   get model(): Model<any> | null { return this.agent.state.model; }
  230   get thinkingLevel(): ThinkingLevel { return this.agent.state.thinkingLevel; }
  231   get isStreaming(): boolean { return this.agent.state.isStreaming; }
- 232   get messages(): Message[] { return this.agent.state.messages; }
+ 232   get messages(): AppMessage[] { return this.agent.state.messages; }
  233   get sessionFile(): string { return this.sessionManager.getSessionFile(); }
  234   get sessionId(): string { return this.sessionManager.getSessionId(); }
  235 }
  236 ```
      ...


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/refactor.md. Changed 155 characters to 158 characters.

      ...
  194
  195 **Implementation - Phase 1 (structure + state access):**
  196 ```typescript
  197 // src/core/agent-session.ts
- 198 import type { Agent, AgentEvent, AgentState, ThinkingLevel } from "@mariozechner/pi-agent-core";
- 199 import type { Model, Message } from "@mariozechner/pi-ai";
+ 198 import type { Agent, AgentEvent, AgentState, AppMessage, ThinkingLevel } from "@mariozechner/pi-agent-core";
+ 199 import type { Model } from "@mariozechner/pi-ai";
  200 import type { SessionManager } from "../session-manager.js";
  201 import type { SettingsManager } from "../settings-manager.js";
  202
  203 export interface AgentSessionConfig {
      ...


bash

cd /Users/badlogic/workspaces/pi-mono && git add packages/coding-agent/docs/refactor.md && git commit -m "Fix: use AppMessage[] instead of Message[] in AgentSession"


Running formatting, linting, and type checking...

> pi-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 229 files in 119ms. No fixes applied.

> @mariozechner/pi-agent-core@0.14.2 check
> tsgo --noEmit

> @mariozechner/pi-ai@0.14.2 check
> biome check --write . && tsgo --noEmit

Checked 36 files in 39ms. No fixes applied.

> @mariozechner/pi-coding-agent@0.14.2 check
> tsgo --noEmit

> @mariozechner/pi-mom@0.14.2 check
> biome check --write . && tsgo --noEmit

Checked 12 files in 15ms. No fixes applied.

> @mariozechner/pi@0.14.2 check
> biome check --write .

Checked 10 files in 17ms. No fixes applied.

> @mariozechner/pi-proxy@0.14.2 check
> biome check --write .

Checked 3 files in 4ms. No fixes applied.

> @mariozechner/pi-tui@0.14.2 check
> biome check --write . && tsgo --noEmit

Checked 22 files in 26ms. No fixes applied.

> @mariozechner/pi-web-ui@0.14.2 check
> tsc --noEmit && cd example && tsc --noEmit

> pi-web-ui-example@1.2.2 check
> tsc --noEmit

✅ All pre-commit checks passed!
[refactor 4197b0c] Fix: use AppMessage[] instead of Message[] in AgentSession
 1 file changed, 4 insertions(+), 4 deletions(-)


Fixed. `AgentSession.messages` now returns `AppMessage[]` which is the union type that includes standard messages plus custom types like `BashExecutionMessage`.

The declaration merging in `messages.ts` extends `CustomMessages` in pi-agent-core, so `AppMessage` automatically includes `BashExecutionMessage`.

Ready to start implementing WP1?


how do we set things on the agentsession? all i see is getters


The user is right - I only defined getters for state access but there are some things that need setters or at least methods to modify state. Let me think about what needs to be settable:

1. `model` - we have `setModel()` method - good
2. `thinkingLevel` - we have `setThinkingLevel()` method - good
3. `autoCompactionEnabled` - we have `setAutoCompactionEnabled()` method - good
4. `scopedModels` - currently only set in constructor. Do we need to change this at runtime? Probably not.
5. `fileCommands` - currently only set in constructor. Do we need to change this at runtime? Probably not.

Wait, looking at TuiRenderer, there's also:
- Queue mode (`agent.setQueueMode()` / `agent.getQueueMode()`)
- The footer uses `setAutoCompactEnabled()`

Let me check what else might need setters...

Actually looking at the API I defined, the setters are there as methods:
- `setModel()`
- `setThinkingLevel()`
- `setAutoCompactionEnabled()`

But the user might be asking about:
1. Setting things like `scopedModels` after construction?
2. Queue mode?

Let me add queue mode and make sure all the necessary setters are documented as methods (not property setters).

Also, looking at the constructor - `scopedModels` and `fileCommands` are passed in at construction time from main.ts and shouldn't change during runtime, so those are fine as constructor-only.

Let me update the plan to add queue mode methods.

Good catch. Looking at the API, I have methods for setting things (`setModel()`, `setThinkingLevel()`, `setAutoCompactionEnabled()`), but I'm missing queue mode. Let me also make the API clearer:


# Coding Agent Refactoring Plan

## Status

**Branch:** `refactor`
**Started:** 2024-12-08

To resume work on this refactoring:
1. Read this document fully
2. Run `git diff` to see current work in progress
3. Check the work packages below - find first unchecked item
4. Read any files mentioned in that work package before making changes

---

## Goals

1. **Eliminate code duplication** between the three run modes (interactive, print/json, rpc)
2. **Create a testable core** (`AgentSession`) that encapsulates all agent/session logic
3. **Separate concerns**: TUI rendering vs agent state management vs I/O
4. **Improve naming**: `TuiRenderer` → `InteractiveMode` (it's not just a renderer)
5. **Simplify main.ts**: Move setup logic out, make it just arg parsing + mode routing

---

## Architecture Overview

### Current State (Problems)

```
main.ts (1100+ lines)
├── parseArgs, printHelp
├── buildSystemPrompt, loadProjectContextFiles
├── resolveModelScope, model resolution logic
├── runInteractiveMode() - thin wrapper around TuiRenderer
├── runSingleShotMode() - duplicates event handling, session saving
├── runRpcMode() - duplicates event handling, session saving, auto-compaction, bash execution
└── executeRpcBashCommand() - duplicate of TuiRenderer.executeBashCommand()

tui/tui-renderer.ts (2400+ lines)
├── TUI lifecycle (init, render, event loop)
├── Agent event handling + session persistence (duplicated in main.ts)
├── Auto-compaction logic (duplicated in main.ts runRpcMode)
├── Bash execution (duplicated in main.ts)
├── All slash command implementations (/export, /copy, /model, /thinking, etc.)
├── All hotkey handlers (Ctrl+C, Ctrl+P, Shift+Tab, etc.)
├── Model/thinking cycling logic
└── 6 different selector UIs (model, thinking, theme, session, branch, oauth)
```

### Target State

```
src/
├── main.ts (~200 lines)
│   ├── parseArgs, printHelp
│   └── Route to appropriate mode
│
├── core/
│   ├── agent-session.ts      # Shared agent/session logic (THE key abstraction)
│   ├── bash-executor.ts      # Bash execution with streaming + cancellation
│   └── setup.ts              # Model resolution, system prompt building, session loading
│
└── modes/
    ├── print-mode.ts         # Simple: prompt, output result
    ├── rpc-mode.ts           # JSON stdin/stdout protocol
    └── interactive/
        ├── interactive-mode.ts   # Main orchestrator
        ├── command-handlers.ts   # Slash command implementations
        ├── hotkeys.ts            # Hotkey handling
        └── selectors.ts          # Modal selector management
```

---

## AgentSession API

This is the core abstraction shared by all modes. See full API design below.

```typescript
class AgentSession {
  // State access
  get state(): AgentState;
  get model(): Model<any> | null;
  get thinkingLevel(): ThinkingLevel;
  get isStreaming(): boolean;
  get messages(): AppMessage[];  // Includes custom types like BashExecutionMessage

  // Event subscription (handles session persistence internally)
  subscribe(listener: (event: AgentEvent) => void): () => void;

  // Prompting
  prompt(text: string, options?: PromptOptions): Promise<void>;
  queueMessage(text: string): Promise<void>;
  clearQueue(): string[];
  abort(): Promise<void>;
  reset(): Promise<void>;

  // Model management
  setModel(model: Model<any>): Promise<void>;
  cycleModel(): Promise<ModelCycleResult | null>;
  getAvailableModels(): Promise<Model<any>[]>;

  // Thinking level
  setThinkingLevel(level: ThinkingLevel): void;
  cycleThinkingLevel(): ThinkingLevel | null;
  supportsThinking(): boolean;

  // Compaction
  compact(customInstructions?: string): Promise<CompactionResult>;
  abortCompaction(): void;
  checkAutoCompaction(): Promise<CompactionResult | null>;
  setAutoCompactionEnabled(enabled: boolean): void;
  get autoCompactionEnabled(): boolean;

  // Bash execution
  executeBash(command: string, onChunk?: (chunk: string) => void): Promise<BashResult>;
  abortBash(): void;
  get isBashRunning(): boolean;

[1549 more lines in file. Use offset=121 to continue]


edit

```typescript
class AgentSession {
  // State access
  get state(): AgentState;
  get model(): Model<any> | null;
  get thinkingLevel(): ThinkingLevel;
  get isStreaming(): boolean;
  get messages(): AppMessage[];  // Includes custom types like BashExecutionMessage

  // Event subscription (handles session persistence internally)
  subscribe(listener: (event: AgentEvent) => void): () => void;

  // Prompting
  prompt(text: string, options?: PromptOptions): Promise<void>;
  queueMessage(text: string): Promise<void>;
  clearQueue(): string[];
  abort(): Promise<void>;
  reset(): Promise<void>;

  // Model management
  setModel(model: Model<any>): Promise<void>;
  cycleModel(): Promise<ModelCycleResult | null>;
  getAvailableModels(): Promise<Model<any>[]>;

  // Thinking level
  setThinkingLevel(level: ThinkingLevel): void;
  cycleThinkingLevel(): ThinkingLevel | null;
  supportsThinking(): boolean;

  // Compaction
  compact(customInstructions?: string): Promise<CompactionResult>;
  abortCompaction(): void;
  checkAutoCompaction(): Promise<CompactionResult | null>;
  setAutoCompactionEnabled(enabled: boolean): void;
  get autoCompactionEnabled(): boolean;

  // Bash execution
  executeBash(command: string, onChunk?: (chunk: string) => void): Promise<BashResult>;
  abortBash(): void;
  get isBashRunning(): boolean;

```typescript
class AgentSession {
  // ─── Read-only State Access ───
  get state(): AgentState;
  get model(): Model<any> | null;
  get thinkingLevel(): ThinkingLevel;
  get isStreaming(): boolean;
  get messages(): AppMessage[];  // Includes custom types like BashExecutionMessage
  get queueMode(): QueueMode;

  // ─── Event Subscription ───
  // Handles session persistence internally (saves messages, checks auto-compaction)
  subscribe(listener: (event: AgentEvent) => void): () => void;

  // ─── Prompting ───
  prompt(text: string, options?: PromptOptions): Promise<void>;
  queueMessage(text: string): Promise<void>;
  clearQueue(): string[];
  abort(): Promise<void>;
  reset(): Promise<void>;

  // ─── Model Management ───
  setModel(model: Model<any>): Promise<void>;  // Validates API key, saves to session + settings
  cycleModel(): Promise<ModelCycleResult | null>;
  getAvailableModels(): Promise<Model<any>[]>;

  // ─── Thinking Level ───
  setThinkingLevel(level: ThinkingLevel): void;  // Saves to session + settings
  cycleThinkingLevel(): ThinkingLevel | null;
  supportsThinking(): boolean;

  // ─── Queue Mode ───
  setQueueMode(mode: QueueMode): void;  // Saves to settings

  // ─── Compaction ───
  compact(customInstructions?: string): Promise<CompactionResult>;
  abortCompaction(): void;
  checkAutoCompaction(): Promise<CompactionResult | null>;  // Called internally after assistant messages
  setAutoCompactionEnabled(enabled: boolean): void;  // Saves to settings
  get autoCompactionEnabled(): boolean;

  // ─── Bash Execution ───
  executeBash(command: string, onChunk?: (chunk: string) => void): Promise<BashResult>;
  abortBash(): void;
  get isBashRunning(): boolean;


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/refactor.md. Changed 1334 characters to 1712 characters.

      ...
   78 This is the core abstraction shared by all modes. See full API design below.
   79
   80 ```typescript
   81 class AgentSession {
-  82   // State access
+  82   // ─── Read-only State Access ───
   83   get state(): AgentState;
   84   get model(): Model<any> | null;
   85   get thinkingLevel(): ThinkingLevel;
   86   get isStreaming(): boolean;
   87   get messages(): AppMessage[];  // Includes custom types like BashExecutionMessage
+  88   get queueMode(): QueueMode;
   88
-  89   // Event subscription (handles session persistence internally)
+  90   // ─── Event Subscription ───
+  91   // Handles session persistence internally (saves messages, checks auto-compaction)
   90   subscribe(listener: (event: AgentEvent) => void): () => void;
   91
-  92   // Prompting
+  94   // ─── Prompting ───
   93   prompt(text: string, options?: PromptOptions): Promise<void>;
   94   queueMessage(text: string): Promise<void>;
   95   clearQueue(): string[];
   96   abort(): Promise<void>;
   97   reset(): Promise<void>;
   98
-  99   // Model management
- 100   setModel(model: Model<any>): Promise<void>;
+ 101   // ─── Model Management ───
+ 102   setModel(model: Model<any>): Promise<void>;  // Validates API key, saves to session + settings
  101   cycleModel(): Promise<ModelCycleResult | null>;
  102   getAvailableModels(): Promise<Model<any>[]>;
  103
- 104   // Thinking level
- 105   setThinkingLevel(level: ThinkingLevel): void;
+ 106   // ─── Thinking Level ───
+ 107   setThinkingLevel(level: ThinkingLevel): void;  // Saves to session + settings
  106   cycleThinkingLevel(): ThinkingLevel | null;
  107   supportsThinking(): boolean;
  108
- 109   // Compaction
+ 111   // ─── Queue Mode ───
+ 112   setQueueMode(mode: QueueMode): void;  // Saves to settings
+ 113
+ 114   // ─── Compaction ───
  110   compact(customInstructions?: string): Promise<CompactionResult>;
  111   abortCompaction(): void;
- 112   checkAutoCompaction(): Promise<CompactionResult | null>;
- 113   setAutoCompactionEnabled(enabled: boolean): void;
+ 117   checkAutoCompaction(): Promise<CompactionResult | null>;  // Called internally after assistant messages
+ 118   setAutoCompactionEnabled(enabled: boolean): void;  // Saves to settings
  114   get autoCompactionEnabled(): boolean;
  115
- 116   // Bash execution
+ 121   // ─── Bash Execution ───
  117   executeBash(command: string, onChunk?: (chunk: string) => void): Promise<BashResult>;
  118   abortBash(): void;
  119   get isBashRunning(): boolean;
  120
      ...


Now let me add the queue mode implementation to the relevant work package. Let me find WP6 and add queue mode there or create a small addition:

bash

cd /Users/badlogic/workspaces/pi-mono && grep -n "WP6:" packages/coding-agent/docs/refactor.md


568:### WP6: AgentSession - Thinking Level Management