Squashed 'vendor/forge/' changes from ad59e044..8344e71c

Amolith created

8344e71c fix(model-forge): use gpt-5.4 instead of gpt-5.4-codex
a8852794 fix(readme-forge): enable model invocation for readme-forge skill
1e99812d feat(model-forge): always run subagents in background unless asked
61b0cead feat(model-forge): add model + git strategy routing skill
faa3be91 feat: add charm-crush skill - production TUI patterns from charmbracelet's agentic CLI
76c85e32 feat: add exo-teams skill - Teams CLI automation

git-subtree-dir: vendor/forge
git-subtree-split: 8344e71c557853a0ce90bf6138ae9b524fcf9d7e

Change summary

README.md                                              |   3 
skills/charm-crush/SKILL.md                            | 138 +++
skills/charm-crush/references/agent-loop.md            | 309 ++++++++
skills/charm-crush/references/component-composition.md | 364 +++++++++
skills/charm-crush/references/design-system.md         | 302 +++++++
skills/charm-crush/references/tui-patterns.md          | 459 ++++++++++++
skills/exo-teams/SKILL.md                              | 144 +++
skills/exo-teams/references/commands.md                | 227 +++++
skills/exo-teams/references/data-discovery.md          | 126 +++
skills/exo-teams/references/scripting.md               | 145 +++
skills/model-forge/SKILL.md                            | 212 +++++
skills/readme-forge/SKILL.md                           |   2 
12 files changed, 2,430 insertions(+), 1 deletion(-)

Detailed changes

README.md 🔗

@@ -35,6 +35,8 @@ ln -s $(pwd)/forge/skills/* ~/.claude/skills/
 | [commit-forge](skills/commit-forge) | Clean, atomic git commits with conventional format |
 | [prompt-forge](skills/prompt-forge) | Universal prompt engineering guide for Claude and GPT |
 | [claude-headless](skills/claude-headless) | Build custom UIs on Claude Code's headless NDJSON protocol |
+| [exo-teams](skills/exo-teams) | Microsoft Teams CLI automation - messages, files, assignments, no admin consent |
+| [model-forge](skills/model-forge) | Pick the right model (opus/sonnet/haiku/codex) and git strategy (direct/feat/worktree) for any task |
 
 </details>
 
@@ -47,6 +49,7 @@ Production-grade skills for the entire [charmbracelet](https://github.com/charmb
 | Skill | Description |
 |-------|-------------|
 | [charm-ecosystem](skills/charm-ecosystem) | Architect's guide - which libraries to combine, decision tree, integration cookbook |
+| [charm-crush](skills/charm-crush) | Production TUI patterns reverse-engineered from Crush - architecture, agent loop, design system |
 
 **Libraries:**
 

skills/charm-crush/SKILL.md 🔗

@@ -0,0 +1,138 @@
+---
+name: charm-crush
+description: Architecture patterns from Crush, charmbracelet's production agentic coding CLI built on bubbletea v2, lipgloss v2, bubbles v2, glamour v2, and ultraviolet. Use when building production bubbletea apps, composing charm TUI components at scale, designing agentic CLI tools, implementing streaming LLM UIs, or asking about crush internals. NOT for individual charm library basics.
+argument-hint: "[pattern or component to look up]"
+---
+
+# Charm Crush - Production Agentic TUI Patterns
+
+Crush is charmbracelet's agentic coding CLI - their answer to Claude Code, built entirely with their own TUI stack. This skill captures the architecture patterns, component composition strategies, and design decisions from a production app with 10+ composed components, real-time LLM streaming, dialog overlays, and a full agent loop.
+
+Source: `github.com/charmbracelet/crush` (FSL-1.1-MIT)
+
+## Architecture Overview
+
+Crush follows a clear layered architecture:
+
+```
+main.go (cobra CLI)
+  -> internal/app/app.go (wiring: DB, config, agents, LSP, MCP, events)
+    -> internal/agent/ (LLM conversations, tool execution, streaming)
+    -> internal/ui/ (bubbletea v2 TUI)
+    -> internal/permission/ (tool approval via pubsub)
+    -> internal/skills/ (Agent Skills open standard)
+    -> internal/pubsub/ (generic typed broker for cross-component messaging)
+    -> internal/session/ (SQLite persistence via sqlc)
+```
+
+### Key Dependencies (go.mod)
+
+| Library | Version | Role |
+|---------|---------|------|
+| `charm.land/bubbletea/v2` | v2.0.2 | TUI framework |
+| `charm.land/lipgloss/v2` | v2.0.2 | Terminal styling |
+| `charm.land/bubbles/v2` | v2.0.0 | Reusable components (textarea, viewport, spinner, help) |
+| `charm.land/glamour/v2` | v2.0.0 | Markdown rendering |
+| `charm.land/fantasy` | v0.16.0 | LLM provider abstraction (Anthropic, OpenAI, Gemini, Bedrock, etc.) |
+| `charmbracelet/ultraviolet` | - | Screen-buffer rendering system |
+| `charm.land/catwalk` | v0.31.0 | Model registry + golden-file testing |
+| `charmbracelet/x/exp/charmtone` | - | Color palette system |
+
+## Decision Tree: How Crush Solves Common Problems
+
+Use $ARGUMENTS to find the relevant pattern, or browse by category:
+
+### TUI Architecture
+- **"How do I structure a large bubbletea app?"** -> Read `references/tui-patterns.md` (Centralized Model pattern)
+- **"How do I handle window resize?"** -> Read `references/tui-patterns.md` (Layout System)
+- **"How do I compose 10+ components?"** -> Read `references/component-composition.md`
+- **"How do I manage focus between panes?"** -> Read `references/tui-patterns.md` (Focus Management)
+- **"How do I render streaming content?"** -> Read `references/tui-patterns.md` (Streaming Rendering)
+- **"How do I do overlays/modals?"** -> Read `references/component-composition.md` (Dialog System)
+
+### Agent Loop
+- **"How does the LLM loop work?"** -> Read `references/agent-loop.md`
+- **"How do I handle tool approval?"** -> Read `references/agent-loop.md` (Permission System)
+- **"How do I stream LLM responses to the TUI?"** -> Read `references/agent-loop.md` (Streaming Bridge)
+
+### Design & Styling
+- **"How do I build a polished terminal UI?"** -> Read `references/design-system.md`
+- **"What colors does charm use?"** -> Read `references/design-system.md` (Charmtone Palette)
+- **"How do I structure styles for a large app?"** -> Read `references/design-system.md` (Styles Struct)
+
+## Core Pattern: The Centralized Model
+
+The single most important architectural decision in Crush. The `UI` struct in `internal/ui/model/ui.go` is the **sole bubbletea model**. Sub-components are NOT bubbletea models - they're stateful structs with imperative methods.
+
+```
+UI (the only tea.Model)
+  |-- Chat (stateful struct, no Update method)
+  |-- textarea (bubbles/v2 textarea.Model)
+  |-- dialog.Overlay (stack of Dialog interfaces)
+  |-- completions (non-standard Update returning bool)
+  |-- attachments (non-standard Update)
+  |-- status, header, pills (render methods on UI)
+```
+
+This means:
+- All message routing happens in one giant `switch msg.(type)` in `UI.Update()`
+- Focus state determines key routing (editor vs chat)
+- Components expose methods like `HandleMouseDown()`, `ScrollBy()`, `SetMessages()` instead of `Update(tea.Msg)`
+- Side effects return `tea.Cmd` from methods, not from Update
+
+### Why This Works
+
+Traditional Elm architecture (each component gets its own Update/View) breaks down at scale because:
+1. Message routing becomes a maze of forwarding
+2. Shared state requires complex message passing
+3. Focus management needs a central coordinator anyway
+
+Crush sidesteps this by making the parent the single source of truth for all state transitions.
+
+## Rendering Pipeline: Ultraviolet Screen Buffer
+
+Crush uses a hybrid rendering approach instead of pure string concatenation:
+
+1. `View()` creates an `ultraviolet.ScreenBuffer` sized to terminal dimensions
+2. Layout is computed as `image.Rectangle` regions via `ultraviolet/layout.SplitVertical/SplitHorizontal`
+3. Components draw into sub-regions: `uv.NewStyledString(str).Draw(scr, rect)`
+4. Dialogs draw last (overlay on top of everything)
+5. `canvas.Render()` flattens to a string for bubbletea
+
+This enables:
+- Overlapping content (dialogs, completions popup)
+- Precise cursor positioning across components
+- Screen-based layout math instead of string width guessing
+
+## Communication: Typed Pub/Sub
+
+Agent and TUI run on separate goroutines. They communicate through a generic typed broker (`internal/pubsub`) that publishes events which bubbletea converts to `tea.Msg`. See `references/agent-loop.md` for the full pattern and event types.
+
+## File Map
+
+| What | Where | Read for |
+|------|-------|----------|
+| Main TUI model | `internal/ui/model/ui.go` | Centralized model, Update loop, layout |
+| Chat component | `internal/ui/model/chat.go` | List wrapping, animation, mouse handling |
+| Tool renderers | `internal/ui/chat/*.go` | Per-tool rendering (bash, file, search, etc.) |
+| Dialog system | `internal/ui/dialog/` | Modal overlays, permissions, models picker |
+| List component | `internal/ui/list/list.go` | Lazy-rendered scrollable list |
+| Styles | `internal/ui/styles/styles.go` | Full design system |
+| Agent loop | `internal/agent/agent.go` | LLM streaming, tool execution |
+| Coordinator | `internal/agent/coordinator.go` | Multi-provider setup, model management |
+| Tool definitions | `internal/agent/tools/*.go` | Tool implementations (.go + .md pairs) |
+| Permission system | `internal/permission/permission.go` | Tool approval flow |
+| Skills loader | `internal/skills/skills.go` | SKILL.md parsing and discovery |
+| Pub/sub | `internal/pubsub/` | Typed event broker |
+| App wiring | `internal/app/app.go` | Service initialization |
+| Config | `internal/config/` | crush.json loading, provider config |
+| UI architecture guide | `internal/ui/AGENTS.md` | Charm's own UI development instructions |
+
+## Reference Files
+
+For detailed patterns with code examples from the actual source:
+
+- `references/tui-patterns.md` - Layout system, focus management, key handling, streaming, responsive design
+- `references/agent-loop.md` - Agent loop, fantasy SDK, tool system, permission flow, pubsub bridge
+- `references/design-system.md` - Charmtone colors, Styles struct, icon system, typography
+- `references/component-composition.md` - Interface hierarchy, dialog stack, list composition, chat items

skills/charm-crush/references/agent-loop.md 🔗

@@ -0,0 +1,309 @@
+# Agent Loop Patterns from Crush
+
+How Crush orchestrates LLM conversations, tool execution, streaming, and permission handling.
+
+## Table of Contents
+
+1. [Architecture Overview](#architecture-overview)
+2. [The Agent Loop](#the-agent-loop)
+3. [Fantasy SDK Integration](#fantasy-sdk-integration)
+4. [Streaming Bridge to TUI](#streaming-bridge-to-tui)
+5. [Tool System](#tool-system)
+6. [Permission System](#permission-system)
+7. [Message Queue](#message-queue)
+8. [Auto-Summarization](#auto-summarization)
+9. [Coordinator Pattern](#coordinator-pattern)
+
+## Architecture Overview
+
+```
+User types prompt
+  -> UI.sendMessage() creates a tea.Cmd
+    -> AgentCoordinator.Run(ctx, sessionID, prompt)
+      -> SessionAgent.Run(ctx, call)
+        -> fantasy.Agent.Stream(ctx, streamCall)
+          -> LLM responds with text/tool calls
+            -> Callbacks persist to DB via message.Service
+              -> message.Service publishes via pubsub.Broker
+                -> App.Events() channel delivers to bubbletea
+                  -> UI.Update() receives pubsub.Event[message.Message]
+                    -> Chat re-renders
+```
+
+## The Agent Loop
+
+**Source:** `internal/agent/agent.go` `Run()` method
+
+The core loop:
+
+1. **Queue check** - if session is busy, queue the prompt and return immediately
+2. **Prepare** - copy tools, model, system prompt (thread-safe via `csync.Value`)
+3. **Create user message** - persist to DB, triggers pubsub event
+4. **Generate title** - async goroutine on first message
+5. **Stream** - call `fantasy.Agent.Stream()` with callbacks
+6. **Handle result** - update session usage, check for summarization, process queue
+
+```go
+func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy.AgentResult, error) {
+    // Queue if busy
+    if a.IsSessionBusy(call.SessionID) {
+        a.messageQueue.Set(call.SessionID, append(existing, call))
+        return nil, nil
+    }
+
+    // Thread-safe copies
+    agentTools := a.tools.Copy()
+    largeModel := a.largeModel.Get()
+    systemPrompt := a.systemPrompt.Get()
+
+    // Add MCP server instructions to system prompt
+    for _, server := range mcp.GetStates() {
+        if server.State == mcp.StateConnected {
+            instructions.WriteString(server.Client.InitializeResult().Instructions)
+        }
+    }
+
+    // Create fantasy agent
+    agent := fantasy.NewAgent(
+        largeModel.Model,
+        fantasy.WithSystemPrompt(systemPrompt),
+        fantasy.WithTools(agentTools...),
+    )
+
+    // Get history, create user message, then stream
+    history, files := a.preparePrompt(msgs, call.Attachments...)
+    result, err := agent.Stream(ctx, fantasy.AgentStreamCall{...})
+}
+```
+
+## Fantasy SDK Integration
+
+**Source:** `internal/agent/agent.go`
+
+Fantasy (`charm.land/fantasy`) is Charm's multi-provider LLM abstraction. Crush uses the agent streaming API with rich callbacks:
+
+```go
+result, err := agent.Stream(genCtx, fantasy.AgentStreamCall{
+    Prompt:          promptText,
+    Messages:        history,
+    Files:           files,
+    ProviderOptions: call.ProviderOptions,
+    MaxOutputTokens: &call.MaxOutputTokens,
+
+    PrepareStep: func(ctx context.Context, opts fantasy.PrepareStepFunctionOptions) (context.Context, fantasy.PrepareStepResult, error) {
+        // Called before each LLM call in the agentic loop
+        // Refresh tools (MCP might have changed)
+        prepared.Tools = a.tools.Copy()
+        // Drain queued prompts and inject them
+        for _, queued := range queuedCalls {
+            prepared.Messages = append(prepared.Messages, userMessage.ToAIMessage()...)
+        }
+        // Create assistant message placeholder in DB
+        assistantMsg, _ := a.messages.Create(ctx, sessionID, ...)
+        currentAssistant = &assistantMsg
+        return ctx, prepared, nil
+    },
+
+    OnReasoningStart: func(id string, reasoning fantasy.ReasoningContent) error {
+        currentAssistant.AppendReasoningContent(reasoning.Text)
+        return a.messages.Update(ctx, *currentAssistant)
+    },
+    OnReasoningDelta: func(id string, text string) error {
+        currentAssistant.AppendReasoningContent(text)
+        return a.messages.Update(ctx, *currentAssistant)
+    },
+    OnTextDelta: func(id string, text string) error {
+        currentAssistant.AppendContent(text)
+        return a.messages.Update(ctx, *currentAssistant)
+    },
+    OnToolInputStart: func(id string, toolName string) error {
+        currentAssistant.AddToolCall(message.ToolCall{ID: id, Name: toolName})
+        return a.messages.Update(ctx, *currentAssistant)
+    },
+    OnToolCall: func(tc fantasy.ToolCallContent) error {
+        currentAssistant.AddToolCall(message.ToolCall{ID: tc.ToolCallID, Name: tc.ToolName, Input: tc.Input, Finished: true})
+        return a.messages.Update(ctx, *currentAssistant)
+    },
+    OnToolResult: func(result fantasy.ToolResultContent) error {
+        a.messages.Create(ctx, sessionID, message.CreateMessageParams{Role: message.Tool, Parts: [...]})
+        return nil
+    },
+    OnStepFinish: func(stepResult fantasy.StepResult) error {
+        // Update token usage on session
+        a.updateSessionUsage(largeModel, &session, stepResult.Usage, ...)
+        a.sessions.Save(ctx, session)
+        return nil
+    },
+
+    StopWhen: []fantasy.StopCondition{
+        // Stop when context window is nearly full
+        func(_ []fantasy.StepResult) bool {
+            remaining := contextWindow - tokensUsed
+            return remaining <= threshold && !disableAutoSummarize
+        },
+        // Stop on repeated tool calls (loop detection)
+        func(steps []fantasy.StepResult) bool {
+            return hasRepeatedToolCalls(steps, windowSize, maxRepeats)
+        },
+    },
+})
+```
+
+Key insight: `PrepareStep` runs before EACH step in the agentic loop (not just the first). This lets Crush:
+- Inject queued user messages mid-conversation
+- Refresh MCP tools dynamically
+- Create a fresh assistant message for each step
+- Apply Anthropic cache control to the right message positions
+
+## Streaming Bridge to TUI
+
+The flow from agent goroutine to TUI:
+
+1. **Agent callback** (`OnTextDelta`, etc.) calls `a.messages.Update(ctx, msg)`
+2. **message.Service** persists to SQLite, then publishes: `broker.Publish(pubsub.UpdatedEvent, msg)`
+3. **App** has a goroutine converting pubsub channels to `tea.Msg` via `app.Events()` channel
+4. **Bubbletea** reads from `app.Events()` and dispatches to `UI.Update()`
+5. **UI** receives `pubsub.Event[message.Message]` and updates the chat item
+
+```go
+// In UI.Update():
+case pubsub.Event[message.Message]:
+    if msg.Payload.SessionID != m.session.ID {
+        // Handle child session (agent tool)
+        break
+    }
+    switch msg.Type {
+    case pubsub.CreatedEvent:
+        cmds = append(cmds, m.appendSessionMessage(msg.Payload))
+    case pubsub.UpdatedEvent:
+        cmds = append(cmds, m.updateSessionMessage(msg.Payload))
+    }
+```
+
+The chat item uses cached rendering - it only re-renders when the underlying message data changes.
+
+## Tool System
+
+**Source:** `internal/agent/tools/`
+
+Tools are self-documenting pairs: a `.go` implementation file and a `.md` description file in the same directory.
+
+Built-in tools: bash, edit, multiedit, view, write, grep, glob, ls, diagnostics, references, fetch, download, lsp_restart, sourcegraph, job_output, job_kill, list_mcp_resources, todos, agent, agentic_fetch.
+
+MCP tools are dynamically loaded and prefixed with `mcp_`.
+
+Each tool implements `fantasy.AgentTool` which provides a JSON schema for the LLM and an execution function.
+
+Tool results flow back through `OnToolResult` callback -> `message.Service.Create()` -> pubsub -> TUI.
+
+## Permission System
+
+**Source:** `internal/permission/permission.go`
+
+The permission service mediates tool execution:
+
+```go
+type Service interface {
+    Request(ctx context.Context, opts CreatePermissionRequest) (bool, error)
+    Grant(permission PermissionRequest)
+    GrantPersistent(permission PermissionRequest)
+    Deny(permission PermissionRequest)
+    AutoApproveSession(sessionID string)
+    SetSkipRequests(skip bool)  // yolo mode
+}
+```
+
+When a tool needs permission:
+1. Tool calls `permissions.Request()` which blocks
+2. Permission service publishes `pubsub.Event[permission.PermissionRequest]`
+3. TUI receives event, opens `dialog.Permissions`
+4. User chooses: Allow / Allow for session / Deny
+5. TUI calls `permissions.Grant()` or `permissions.Deny()`
+6. The blocked `Request()` call returns, tool continues or errors
+
+Allow-lists can be configured in `crush.json` to skip prompting for specific tools.
+
+Yolo mode (`--yolo` flag) sets `SkipRequests(true)` which auto-approves everything.
+
+## Message Queue
+
+**Source:** `internal/agent/agent.go`
+
+If the user sends a new prompt while the agent is busy:
+
+```go
+if a.IsSessionBusy(call.SessionID) {
+    existing, _ := a.messageQueue.Get(call.SessionID)
+    existing = append(existing, call)
+    a.messageQueue.Set(call.SessionID, existing)
+    return nil, nil  // queued, not executed yet
+}
+```
+
+Queued messages are drained in `PrepareStep` (called before each LLM step):
+
+```go
+PrepareStep: func(ctx context.Context, opts ...) (...) {
+    queuedCalls, _ := a.messageQueue.Get(call.SessionID)
+    a.messageQueue.Del(call.SessionID)
+    for _, queued := range queuedCalls {
+        userMessage, _ := a.createUserMessage(ctx, queued)
+        prepared.Messages = append(prepared.Messages, userMessage.ToAIMessage()...)
+    }
+}
+```
+
+This means queued prompts are injected into the conversation at the next natural break point (between LLM steps).
+
+## Auto-Summarization
+
+When the context window is nearly full, Crush auto-summarizes:
+
+```go
+const (
+    largeContextWindowThreshold = 200_000
+    largeContextWindowBuffer    = 20_000
+    smallContextWindowRatio     = 0.2
+)
+
+// In StopWhen condition:
+remaining := contextWindow - tokensUsed
+if cw > largeContextWindowThreshold {
+    threshold = largeContextWindowBuffer  // 20K buffer for large models
+} else {
+    threshold = int64(float64(cw) * smallContextWindowRatio)  // 20% for small models
+}
+if remaining <= threshold && !disableAutoSummarize {
+    shouldSummarize = true
+    return true  // stop the loop
+}
+```
+
+After the loop stops, if `shouldSummarize` is true, the coordinator triggers summarization using the small model.
+
+## Coordinator Pattern
+
+**Source:** `internal/agent/coordinator.go`
+
+The Coordinator manages the lifecycle:
+
+```go
+type Coordinator interface {
+    Run(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error)
+    Cancel(sessionID string)
+    IsSessionBusy(sessionID string) bool
+    QueuedPrompts(sessionID string) int
+    UpdateModels(ctx context.Context) error
+    Model() Model
+}
+```
+
+It handles:
+- **Multi-provider setup** - creates `fantasy.LanguageModel` from config for each provider type (Anthropic, OpenAI, Google, Bedrock, Azure, OpenRouter, Vercel, etc.)
+- **Model switching** - `UpdateModels()` reconfigures agents when user changes model mid-session
+- **Tool registration** - collects built-in tools + MCP tools, passes to session agent
+- **System prompt assembly** - loads Go templates from `internal/agent/templates/`, injects runtime data (working dir, OS, skills, context files)
+
+The coordinator owns a map of named agents (currently "coder" and "task") and delegates to the current agent.
+
+Thread safety throughout uses `internal/csync` which provides `Value[T]`, `Slice[T]`, and `Map[K,V]` - simple wrappers around values with mutex protection.

skills/charm-crush/references/component-composition.md 🔗

@@ -0,0 +1,364 @@
+# Component Composition Patterns from Crush
+
+How Crush composes bubbletea + lipgloss + bubbles + ultraviolet into a production multi-pane TUI.
+
+## Table of Contents
+
+1. [Interface Hierarchy](#interface-hierarchy)
+2. [Chat Item System](#chat-item-system)
+3. [Dialog Stack](#dialog-stack)
+4. [List Component](#list-component)
+5. [Shared Context Pattern](#shared-context-pattern)
+6. [Tool Renderer Factory](#tool-renderer-factory)
+7. [Completions Popup](#completions-popup)
+8. [Notification System](#notification-system)
+
+## Interface Hierarchy
+
+**Source:** `internal/ui/list/item.go`, `internal/ui/chat/messages.go`, `internal/ui/chat/tools.go`
+
+Crush builds a layered interface system through composition, not inheritance:
+
+```
+list.Item (base)
+  Render(width int) string
+
+list.RawRenderable
+  RawRender(width int) string
+
+list.Focusable
+  SetFocused(focused bool)
+
+list.Highlightable
+  SetHighlight(startLine, startCol, endLine, endCol int)
+  Highlight() (startLine, startCol, endLine, endCol int)
+
+list.MouseClickable
+  HandleMouseClick(btn MouseButton, x, y int) bool
+```
+
+Chat extends these:
+
+```
+chat.MessageItem = list.Item + list.RawRenderable + Identifiable
+  ID() string
+
+chat.ToolMessageItem extends MessageItem with:
+  ToolCall() message.ToolCall
+  ToolResult() *message.ToolResult
+  MessageID() string
+  SetToolResult(result *message.ToolResult)
+  ToolStatus() string
+```
+
+Opt-in capabilities (components implement only what they need):
+
+```
+chat.Animatable
+  StartAnimation() tea.Cmd
+  Animate(msg anim.StepMsg) tea.Cmd
+
+chat.Expandable
+  ToggleExpanded() bool
+
+chat.Compactable
+  SetCompact(compact bool)
+
+chat.KeyEventHandler
+  HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd)
+
+chat.NestedToolContainer
+  SetNestedTools(tools []ToolMessageItem)
+```
+
+This design lets the Chat and List check capabilities at runtime:
+
+```go
+// In UI.Update when handling animation:
+for _, item := range items {
+    if animatable, ok := item.(chat.Animatable); ok {
+        if cmd := animatable.StartAnimation(); cmd != nil {
+            cmds = append(cmds, cmd)
+        }
+    }
+}
+
+// In rendering, check if item supports compact mode:
+if simplifiable, ok := nestedToolItem.(chat.Compactable); ok {
+    simplifiable.SetCompact(true)
+}
+```
+
+## Chat Item System
+
+**Source:** `internal/ui/chat/`
+
+The Chat wraps a `list.List` and provides message-specific behavior. Items are created by `ExtractMessageItems()` which parses a `message.Message` into one or more `MessageItem`s.
+
+### Message Types
+
+| File | Handles | Key Feature |
+|------|---------|-------------|
+| `chat/user.go` | User messages | Shows input text + attachments |
+| `chat/assistant.go` | Assistant text, thinking, errors | Streaming markdown, reasoning blocks, info footer |
+| `chat/bash.go` | Bash, JobOutput, JobKill | Command display, output truncation, job tracking |
+| `chat/file.go` | View, Write, Edit, MultiEdit, Download | Diff rendering, file path display |
+| `chat/search.go` | Glob, Grep, LS, Sourcegraph | Result lists with truncation |
+| `chat/fetch.go` | Fetch, WebFetch, WebSearch | URL display, content preview |
+| `chat/agent.go` | Agent, AgenticFetch | Nested tool containers |
+| `chat/mcp.go` | MCP tools (mcp_ prefix) | Generic tool rendering with server name |
+| `chat/generic.go` | Fallback | Any unrecognized tool |
+| `chat/diagnostics.go` | LSP diagnostics | Error/warning lists |
+| `chat/todos.go` | Todo lists | Checkbox rendering |
+
+### Cached Rendering
+
+Items cache their rendered output and invalidate when data changes. This is critical because the list only renders visible items (lazy rendering), but those items may be re-rendered on every frame during streaming.
+
+```go
+// Pattern from chat/messages.go - embedded cache struct
+type cachedMessageItem struct {
+    cachedRender string
+    cachedWidth  int
+    dirty        bool
+}
+
+func (c *cachedMessageItem) invalidate() {
+    c.dirty = true
+}
+
+func (c *cachedMessageItem) render(width int, renderFn func(int) string) string {
+    if !c.dirty && c.cachedWidth == width {
+        return c.cachedRender
+    }
+    c.cachedRender = renderFn(width)
+    c.cachedWidth = width
+    c.dirty = false
+    return c.cachedRender
+}
+```
+
+### Tool Renderer Factory
+
+**Source:** `internal/ui/chat/tools.go`
+
+`NewToolMessageItem` is a central factory routing tool names to specific types:
+
+```go
+func NewToolMessageItem(sty *styles.Styles, msg *message.Message, tc message.ToolCall, result *message.ToolResult) ToolMessageItem {
+    switch tc.Name {
+    case "bash":
+        return newBashItem(sty, msg, tc, result)
+    case "edit", "multiedit":
+        return newEditItem(sty, msg, tc, result)
+    case "view":
+        return newViewItem(sty, msg, tc, result)
+    // ... etc
+    default:
+        if strings.HasPrefix(tc.Name, "mcp_") {
+            return newMCPItem(sty, msg, tc, result)
+        }
+        return newGenericItem(sty, msg, tc, result)
+    }
+}
+```
+
+Each tool renderer implements `RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string` which produces the styled output.
+
+## Dialog Stack
+
+**Source:** `internal/ui/dialog/dialog.go`
+
+Dialogs are an overlay system managed by `dialog.Overlay`:
+
+```go
+type Dialog interface {
+    ID() string
+    HandleMsg(msg tea.Msg) Action  // returns typed action
+    Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor
+}
+
+type Overlay struct {
+    dialogs []Dialog  // stack
+}
+
+func (d *Overlay) OpenDialog(dialog Dialog)      // push
+func (d *Overlay) CloseFrontDialog()             // pop
+func (d *Overlay) ContainsDialog(id string) bool // check
+func (d *Overlay) HasDialogs() bool              // any open?
+```
+
+Dialog implementations in `internal/ui/dialog/`:
+
+| Dialog | ID | Purpose |
+|--------|----|---------|
+| `Models` | "models" | Model picker with provider groups |
+| `Sessions` | "sessions" | Session browser with search |
+| `Commands` | "commands" | Slash command picker |
+| `Permissions` | "permissions" | Tool approval with diff view |
+| `APIKeyInput` | "api_key_input" | API key entry |
+| `OAuthCopilot` | "oauth_copilot" | GitHub Copilot OAuth flow |
+| `FilePicker` | "filepicker" | File browser |
+| `Reasoning` | "reasoning" | Extended thinking view |
+| `Quit` | "quit" | Quit confirmation |
+| `Arguments` | "arguments" | Skill argument input |
+
+### Dialog Actions
+
+Dialogs return typed `Action` values that the parent UI handles:
+
+```go
+// In UI.handleDialogMsg:
+action := m.dialog.Update(msg)
+switch a := action.(type) {
+case dialog.ModelSelectedAction:
+    // user picked a model
+case dialog.PermissionAction:
+    // user allowed/denied tool
+case dialog.SessionSelectedAction:
+    // user switched session
+}
+```
+
+This decouples dialog logic from the parent. Dialogs don't need to know about the UI - they just return what happened.
+
+### Dialog Rendering
+
+Dialogs use `RenderContext` for consistent layout:
+
+```go
+rc := dialog.NewRenderContext(styles, width)
+rc.Title = "Select Model"
+rc.Parts = []string{modelList, helpText}
+rc.Help = helpView
+rendered := rc.Render()
+```
+
+`RenderContext` handles title gradient, content alignment, help bar positioning, and onboarding-mode layout (bottom-left instead of centered).
+
+## List Component
+
+**Source:** `internal/ui/list/list.go` (650 lines)
+
+A custom lazy-rendered scrollable list. Not the standard bubbles list - this is crush-specific.
+
+Key design decisions:
+- **Lazy rendering**: only visible items are rendered
+- **No internal cache**: items cache their own rendering (see cached render pattern above)
+- **Scroll by lines, not items**: viewport tracks `offsetIdx` (first visible item) + `offsetLine` (lines scrolled within that item)
+- **Render callbacks**: the parent can modify items before rendering via `RegisterRenderCallback()`
+
+```go
+type List struct {
+    width, height int
+    items         []Item
+    gap           int
+    reverse       bool
+    focused       bool
+    selectedIdx   int
+    offsetIdx     int
+    offsetLine    int
+    renderCallbacks []func(idx, selectedIdx int, item Item) Item
+}
+```
+
+The `Render()` method walks visible items, renders each to a string, joins with gap, and truncates to viewport height. This is the string that Chat then draws onto the ultraviolet screen buffer.
+
+The list supports both forward and reverse rendering (`reverse` flag) for chat-style bottom-up layouts.
+
+## Shared Context Pattern
+
+**Source:** `internal/ui/common/common.go`
+
+```go
+type Common struct {
+    App    *app.App
+    Styles *styles.Styles
+}
+
+func (c *Common) Config() *config.Config { return c.App.Config() }
+func (c *Common) Store() *config.ConfigStore { ... }
+```
+
+Every component that needs styles or app access receives `*common.Common` at creation:
+
+```go
+ch := NewChat(com)       // chat
+status := NewStatus(com, ui)  // status bar
+header := newHeader(com)      // header
+```
+
+This avoids passing individual dependencies and makes it easy to add new shared state.
+
+## Completions Popup
+
+**Source:** `internal/ui/completions/completions.go`
+
+The `@` completions popup shows files and MCP resources. It's positioned relative to the cursor:
+
+```go
+// In UI.Draw():
+if m.completionsOpen && m.completions.HasItems() {
+    w, h := m.completions.Size()
+    x := m.completionsPositionStart.X
+    y := m.completionsPositionStart.Y - h  // above cursor
+
+    // Keep within screen bounds
+    if x+w > screenW { x = screenW - w }
+    x = max(0, x)
+    y = max(0, y+1)
+
+    completionsView := uv.NewStyledString(m.completions.Render())
+    completionsView.Draw(scr, image.Rectangle{
+        Min: image.Pt(x, y),
+        Max: image.Pt(x+w, y+h),
+    })
+}
+```
+
+The completions component has its own key handling that returns whether it consumed the event:
+
+```go
+// Non-standard Update signature
+func (c *Completions) Update(msg tea.Msg) bool {
+    // returns true if it handled the key (consumed)
+}
+```
+
+Items are loaded asynchronously via `completions.CompletionItemsLoadedMsg`.
+
+## Notification System
+
+**Source:** `internal/ui/notification/`
+
+Desktop notifications are sent when:
+- A tool needs permission approval
+- The agent finishes its turn
+- But ONLY when the terminal window is unfocused AND the terminal supports focus reporting
+
+```go
+func (m *UI) shouldSendNotification() bool {
+    if cfg.Options.DisableNotifications { return false }
+    return m.caps.ReportFocusEvents && !m.notifyWindowFocused
+}
+```
+
+Focus tracking uses bubbletea's `tea.FocusMsg` / `tea.BlurMsg`:
+
+```go
+case tea.FocusMsg:
+    m.notifyWindowFocused = true
+case tea.BlurMsg:
+    m.notifyWindowFocused = false
+```
+
+The notification backend is swapped based on terminal capability:
+
+```go
+case tea.ModeReportMsg:
+    if m.caps.ReportFocusEvents {
+        m.notifyBackend = notification.NewNativeBackend(notification.Icon)
+    }
+```
+
+If the terminal doesn't support focus reporting, notifications stay disabled (NoopBackend).

skills/charm-crush/references/design-system.md 🔗

@@ -0,0 +1,302 @@
+# Design System from Crush
+
+The visual language of Crush, extracted from `internal/ui/styles/styles.go` and component rendering code.
+
+## Table of Contents
+
+1. [Charmtone Color Palette](#charmtone-color-palette)
+2. [Semantic Color Roles](#semantic-color-roles)
+3. [The Styles Struct](#the-styles-struct)
+4. [Icon System](#icon-system)
+5. [Typography Patterns](#typography-patterns)
+6. [Layout Constants](#layout-constants)
+7. [Gradient Effects](#gradient-effects)
+
+## Charmtone Color Palette
+
+**Source:** `github.com/charmbracelet/x/exp/charmtone`
+
+Crush uses Charm's proprietary color palette "Charmtone" with food-themed names. These are true-color values designed to look good on dark terminal backgrounds.
+
+| Role | Charmtone Name | Usage |
+|------|---------------|-------|
+| Primary | `Charple` | Focus borders, highlights, accent |
+| Secondary | `Dolly` | Cursor color, secondary highlights |
+| Tertiary | `Bok` | Editor prompt, tertiary accent |
+| Background | `Pepper` | Main background |
+| Background lighter | `BBQ` | Slightly lighter bg for contrast |
+| Background subtle | `Charcoal` | Borders, separators |
+| Background overlay | `Iron` | Dialog overlays |
+| Foreground | `Ash` | Primary text |
+| Foreground muted | `Squid` | Secondary text |
+| Foreground half-muted | `Smoke` | Between muted and base |
+| Foreground subtle | `Oyster` | Placeholders, hints |
+| Error | `Sriracha` | Error states |
+| Warning | `Zest` | Warning states |
+| Info | `Malibu` | Info states, blue accents |
+| White | `Butter` | High-contrast text |
+| Blue | `Malibu` | Tool names, links |
+| Blue light | `Sardine` | Light blue accents |
+| Blue dark | `Damson` | Dark blue accents |
+| Green | `Julep` | Success states |
+| Green light | `Bok` | Light green accents |
+| Green dark | `Guac` | Pending job icons |
+| Red | `Coral` | Error text |
+| Red dark | `Sriracha` | Error backgrounds |
+| Yellow | `Mustard` | Warning text, note tags |
+
+## Semantic Color Roles
+
+**Source:** `internal/ui/styles/styles.go`
+
+Colors are organized by semantic role, not by hue:
+
+```go
+var (
+    primary   = charmtone.Charple   // "brand" color
+    secondary = charmtone.Dolly     // cursor, secondary accent
+    tertiary  = charmtone.Bok       // editor prompt
+
+    bgBase        = charmtone.Pepper    // app background
+    bgBaseLighter = charmtone.BBQ       // content blocks
+    bgSubtle      = charmtone.Charcoal  // borders
+    bgOverlay     = charmtone.Iron      // dialog bg
+
+    fgBase      = charmtone.Ash     // text
+    fgMuted     = charmtone.Squid   // secondary text
+    fgHalfMuted = charmtone.Smoke   // in-between
+    fgSubtle    = charmtone.Oyster  // hints
+
+    border      = charmtone.Charcoal  // unfocused borders
+    borderFocus = charmtone.Charple   // focused borders
+
+    error   = charmtone.Sriracha
+    warning = charmtone.Zest
+    info    = charmtone.Malibu
+)
+```
+
+Every component references these semantic colors rather than hard-coding values. This makes theming possible by swapping the palette.
+
+## The Styles Struct
+
+**Source:** `internal/ui/styles/styles.go`
+
+The `Styles` struct is a massive (~500 fields) centralized style registry. It's organized in nested groups:
+
+```go
+type Styles struct {
+    // Base text
+    Base, Muted, HalfMuted, Subtle lipgloss.Style
+
+    // Tags
+    TagBase, TagError, TagInfo lipgloss.Style
+
+    // Semantic colors (as color.Color for components that need raw colors)
+    Primary, Secondary, Tertiary color.Color
+    BgBase, BgSubtle, BgOverlay  color.Color
+    FgBase, FgMuted, FgSubtle    color.Color
+    Error, Warning, Info         color.Color
+
+    // Nested groups
+    Header struct { Charm, Diagonals, Percentage, Keystroke, WorkingDir lipgloss.Style }
+    Chat   struct { Message struct { UserFocused, AssistantFocused, Thinking... } }
+    Tool   struct { IconPending, IconSuccess, NameNormal, ContentLine, Body... }
+    Dialog struct { Title, View, Help... }
+    Pills  struct { ... }
+
+    // Component-specific styles
+    TextInput textinput.Styles
+    TextArea  textarea.Styles
+    Help      help.Styles
+    Diff      diffview.Style
+    FilePicker filepicker.Styles
+
+    // Markdown rendering config
+    Markdown      ansi.StyleConfig
+    PlainMarkdown ansi.StyleConfig
+}
+```
+
+Styles are created once in `DefaultStyles()` and passed via `common.Common`:
+
+```go
+type Common struct {
+    App    *app.App
+    Styles *styles.Styles
+}
+```
+
+Every component receives `*common.Common` at creation time.
+
+## Icon System
+
+**Source:** `internal/ui/styles/styles.go`
+
+Crush uses Unicode characters as icons, defined as constants:
+
+```go
+const (
+    CheckIcon   = "✓"
+    SpinnerIcon = "⋯"
+    LoadingIcon = "⟳"
+    ModelIcon   = "◇"
+    ArrowRightIcon = "→"
+
+    // Tool status
+    ToolPending = "●"
+    ToolSuccess = "✓"
+    ToolError   = "×"
+
+    // Radio buttons
+    RadioOn  = "◉"
+    RadioOff = "○"
+
+    // Borders
+    BorderThin  = "│"
+    BorderThick = "▌"
+
+    // Separators
+    SectionSeparator = "─"
+
+    // Todos
+    TodoCompletedIcon  = "✓"
+    TodoPendingIcon    = "•"
+    TodoInProgressIcon = "→"
+
+    // Attachments
+    ImageIcon = "■"
+    TextIcon  = "≡"
+
+    // Scrollbar
+    ScrollbarThumb = "┃"
+    ScrollbarTrack = "│"
+
+    // LSP diagnostics
+    LSPErrorIcon   = "E"
+    LSPWarningIcon = "W"
+    LSPInfoIcon    = "I"
+    LSPHintIcon    = "H"
+)
+```
+
+Icons are styled using the corresponding `lipgloss.Style` from the Styles struct. For example:
+
+```go
+s.Tool.IconPending = lipgloss.NewStyle().Foreground(blue)
+s.Tool.IconSuccess = lipgloss.NewStyle().Foreground(green)
+s.Tool.IconError   = lipgloss.NewStyle().Foreground(red)
+```
+
+## Typography Patterns
+
+### Text Hierarchy
+
+| Level | Style | Usage |
+|-------|-------|-------|
+| Primary | `Base` (fgBase/Ash) | Main content, user messages |
+| Secondary | `Muted` (fgMuted/Squid) | Metadata, timestamps, secondary info |
+| Tertiary | `HalfMuted` (fgHalfMuted/Smoke) | Less important metadata |
+| Hint | `Subtle` (fgSubtle/Oyster) | Placeholders, keystroke hints |
+
+### Message Rendering
+
+User messages and assistant messages have distinct focused/blurred styles:
+
+```go
+Chat.Message.UserFocused     // highlight border on left
+Chat.Message.UserBlurred     // muted border on left
+Chat.Message.AssistantFocused
+Chat.Message.AssistantBlurred
+```
+
+Tool calls get three states: focused, blurred, and compact (nested tools):
+
+```go
+Chat.Message.ToolCallFocused  // full detail
+Chat.Message.ToolCallBlurred  // muted
+Chat.Message.ToolCallCompact  // minimal, for nested agent tools
+```
+
+### Tags
+
+Small colored labels for status indicators:
+
+```go
+TagBase  // default tag
+TagError // red background
+TagInfo  // blue/info background
+Tool.ErrorTag    // "ERROR" label
+Tool.NoteTag     // "NOTE" label (yellow bg)
+Tool.AgentTaskTag // "TASK" label (blue bg)
+```
+
+### Section Headers
+
+Used to separate logical sections within tool output:
+
+```go
+Chat.Message.SectionHeader  // styled divider text
+Section.Title               // section title style
+Section.Line                // horizontal line style
+```
+
+## Layout Constants
+
+```go
+// Global
+const defaultMargin     = 2
+const defaultListIndent = 2
+
+// Compact mode breakpoints
+const compactModeWidthBreakpoint  = 120
+const compactModeHeightBreakpoint = 30
+
+// Text
+const maxTextWidth = 120  // max readable width for chat messages
+const MessageLeftPaddingTotal = 2  // border + padding
+
+// Dialogs
+const defaultDialogMaxWidth = 70
+const defaultDialogHeight   = 20
+
+// Permissions dialog
+const diffMaxWidth      = 180
+const simpleMaxWidth    = 100
+const splitModeMinWidth = 140
+
+// Sidebar
+const sidebarWidth = 30
+
+// Editor
+const editorHeight = 5
+
+// Paste threshold
+const pasteLinesThreshold = 10  // lines before treating paste as attachment
+```
+
+## Gradient Effects
+
+**Source:** `internal/ui/styles/grad.go`
+
+Dialog titles use color gradients rendered character-by-character:
+
+```go
+type RenderContext struct {
+    TitleGradientFromColor color.Color  // defaults to Primary
+    TitleGradientToColor   color.Color  // defaults to Secondary
+}
+```
+
+The title text is rendered with a smooth gradient from the primary color (Charple) to the secondary color (Dolly), giving dialogs their distinctive polished look.
+
+The logo also uses gradient colors:
+
+```go
+LogoTitleColorA  color.Color  // gradient start
+LogoTitleColorB  color.Color  // gradient end
+LogoCharmColor   color.Color  // "Charm" text
+LogoVersionColor color.Color  // version number
+```
+
+This creates the signature Charm aesthetic - dark backgrounds with warm, saturated accent gradients.

skills/charm-crush/references/tui-patterns.md 🔗

@@ -0,0 +1,459 @@
+# TUI Architecture Patterns from Crush
+
+Extracted from `internal/ui/` of charmbracelet/crush. Every pattern here is battle-tested in production.
+
+## Table of Contents
+
+1. [The Centralized Model Pattern](#the-centralized-model-pattern)
+2. [Layout System](#layout-system)
+3. [Focus Management](#focus-management)
+4. [Key Event Routing](#key-event-routing)
+5. [Streaming Rendering](#streaming-rendering)
+6. [Responsive Design](#responsive-design)
+7. [Mouse Handling](#mouse-handling)
+8. [Animation System](#animation-system)
+9. [Screen Buffer Rendering](#screen-buffer-rendering)
+
+## The Centralized Model Pattern
+
+**Source:** `internal/ui/model/ui.go`
+
+The UI struct is the sole `tea.Model`. It owns all state directly:
+
+```go
+type UI struct {
+    com          *common.Common
+    width, height int
+    layout       uiLayout
+    state        uiState       // uiOnboarding | uiInitialize | uiLanding | uiChat
+    focus        uiFocusState  // uiFocusNone | uiFocusEditor | uiFocusMain
+
+    textarea    textarea.Model      // bubbles textarea
+    chat        *Chat               // custom list wrapper
+    dialog      *dialog.Overlay     // stacked dialog system
+    completions *completions.Completions
+    attachments *attachments.Attachments
+    status      *Status
+    header      *header
+    // ... more fields
+}
+```
+
+Sub-components do NOT implement `tea.Model`. They expose imperative methods:
+
+```go
+// Chat has no Update method. The parent calls these directly:
+m.chat.HandleMouseDown(x, y)
+m.chat.ScrollBy(n)
+m.chat.SetMessages(items...)
+m.chat.Animate(msg)
+
+// Completions has a non-standard Update returning bool (consumed?):
+consumed := m.completions.Update(msg)
+
+// Sidebar is just a render method on UI:
+m.drawSidebar(scr, layout.sidebar)
+```
+
+The Update method is one large switch statement routing all messages:
+
+```go
+func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+    var cmds []tea.Cmd
+    switch msg := msg.(type) {
+    case tea.WindowSizeMsg:
+        m.width, m.height = msg.Width, msg.Height
+        m.updateLayoutAndSize()
+    case tea.KeyPressMsg:
+        if cmd := m.handleKeyPressMsg(msg); cmd != nil { cmds = append(cmds, cmd) }
+    case pubsub.Event[message.Message]:
+        // route to chat
+    case pubsub.Event[permission.PermissionRequest]:
+        // open dialog
+    case anim.StepMsg:
+        // route to chat animation
+    case spinner.TickMsg:
+        // route to spinner
+    // ... 30+ message types
+    }
+    return m, tea.Batch(cmds...)
+}
+```
+
+**Why not standard Elm per-component?** When you have 10+ components that share state (focus, session, layout), message forwarding becomes a maze. The centralized model keeps all transitions in one place. Components become simple render+method structs.
+
+## Layout System
+
+**Source:** `internal/ui/model/ui.go`
+
+Layout uses `image.Rectangle` from Go's standard library via ultraviolet's layout helpers:
+
+```go
+type uiLayout struct {
+    area           uv.Rectangle  // full terminal
+    header         uv.Rectangle
+    main           uv.Rectangle  // chat area
+    pills          uv.Rectangle  // todo pills
+    editor         uv.Rectangle  // text input
+    sidebar        uv.Rectangle  // session details (non-compact)
+    status         uv.Rectangle  // help/status bar
+    sessionDetails uv.Rectangle  // compact mode overlay
+}
+```
+
+Layout is computed in `generateLayout()` based on state. Different states get different layouts:
+
+```go
+func (m *UI) generateLayout(w, h int) uiLayout {
+    area := image.Rect(0, 0, w, h)
+    helpHeight := 1
+    editorHeight := 5
+    sidebarWidth := 30
+
+    // Add margins
+    appRect, helpRect := layout.SplitVertical(area, layout.Fixed(area.Dy()-helpHeight))
+    appRect.Min.Y += 1; appRect.Max.Y -= 1
+    appRect.Min.X += 1; appRect.Max.X -= 1
+
+    switch m.state {
+    case uiChat:
+        if m.isCompact {
+            // Compact: header | main | editor | help (no sidebar)
+            headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(1))
+            mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
+        } else {
+            // Full: main+editor | sidebar, with help bar at bottom
+            mainRect, sideRect := layout.SplitHorizontal(appRect, layout.Fixed(appRect.Dx()-sidebarWidth))
+            mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
+        }
+    case uiLanding:
+        // header | main | editor | help
+    case uiOnboarding, uiInitialize:
+        // header | main | help
+    }
+}
+```
+
+Key pattern: `layout.SplitVertical` and `layout.SplitHorizontal` take an area and a constraint (`layout.Fixed(n)`) and return two non-overlapping rectangles.
+
+After computing layout, `updateSize()` propagates sizes to child components:
+
+```go
+func (m *UI) updateSize() {
+    m.status.SetWidth(m.layout.status.Dx())
+    m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
+    m.textarea.SetWidth(m.layout.editor.Dx())
+    m.textarea.SetHeight(m.layout.editor.Dy() - 2) // account for margins
+}
+```
+
+`updateLayoutAndSize()` is called on every `WindowSizeMsg` and state change.
+
+## Focus Management
+
+**Source:** `internal/ui/model/ui.go`
+
+Focus is a simple enum:
+
+```go
+type uiFocusState uint8
+const (
+    uiFocusNone uiFocusState = iota
+    uiFocusEditor  // keys go to textarea
+    uiFocusMain    // keys go to chat list
+)
+```
+
+Tab switches focus. Focus determines:
+1. Where key events route
+2. Which cursor shows
+3. Which component gets highlight/border styling
+
+```go
+// In the key handler:
+case key.Matches(msg, m.keyMap.Tab):
+    if m.focus == uiFocusEditor {
+        m.focus = uiFocusMain
+    } else {
+        m.focus = uiFocusEditor
+    }
+```
+
+Cursor position is returned from `Draw()` based on focus:
+
+```go
+func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+    // ... draw all components ...
+    if m.dialog.HasDialogs() {
+        return m.dialog.Draw(scr, scr.Bounds()) // dialog cursor takes priority
+    }
+    switch m.focus {
+    case uiFocusEditor:
+        if m.textarea.Focused() {
+            cur := m.textarea.Cursor()
+            cur.X += 1                            // app margin
+            cur.Y += m.layout.editor.Min.Y + 1    // editor position + attachment row
+            return cur
+        }
+    }
+    return nil
+}
+```
+
+## Key Event Routing
+
+**Source:** `internal/ui/model/keys.go`, `internal/ui/model/ui.go`
+
+Keys are defined as `key.Binding` structs organized in a `KeyMap`:
+
+```go
+type KeyMap struct {
+    Quit     key.Binding
+    Help     key.Binding
+    Tab      key.Binding
+    Commands key.Binding
+    Models   key.Binding
+    Editor   EditorKeyMap
+    Chat     ChatKeyMap
+}
+```
+
+The key handler delegates based on state and focus:
+
+```go
+func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
+    // Dialogs intercept first
+    if m.dialog.HasDialogs() {
+        return m.handleDialogMsg(msg)
+    }
+    // Global keys (quit, help, commands, models)
+    // Then state-specific:
+    switch m.state {
+    case uiChat:
+        switch m.focus {
+        case uiFocusEditor:
+            return m.handleEditorKey(msg)
+        case uiFocusMain:
+            return m.handleChatKey(msg)
+        }
+    }
+}
+```
+
+Keyboard enhancements are detected and key help is updated dynamically:
+
+```go
+case tea.KeyboardEnhancementsMsg:
+    m.keyenh = msg
+    if msg.SupportsKeyDisambiguation() {
+        m.keyMap.Models.SetHelp("ctrl+m", "models")
+        m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
+    }
+```
+
+## Streaming Rendering
+
+**Source:** `internal/agent/agent.go`, `internal/ui/model/ui.go`
+
+The agent loop runs in a goroutine. It uses fantasy's streaming callbacks:
+
+```go
+result, err := agent.Stream(ctx, fantasy.AgentStreamCall{
+    OnTextDelta: func(id string, text string) error {
+        currentAssistant.AppendContent(text)
+        return a.messages.Update(ctx, *currentAssistant)  // persists and publishes
+    },
+    // ... other callbacks
+})
+```
+
+`messages.Update()` triggers a pubsub event. The app converts pubsub events to `tea.Msg` via a channel that bubbletea reads. The UI receives `pubsub.Event[message.Message]` and updates the chat:
+
+```go
+case pubsub.Event[message.Message]:
+    switch msg.Type {
+    case pubsub.UpdatedEvent:
+        cmds = append(cmds, m.updateSessionMessage(msg.Payload))
+    }
+```
+
+The chat item re-renders its cached content on update. Glamour renders markdown in real-time as chunks arrive.
+
+The chat has a `follow` flag - when true (user hasn't scrolled up), it auto-scrolls to bottom on every new content:
+
+```go
+if m.chat.Follow() {
+    if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+        cmds = append(cmds, cmd)
+    }
+}
+```
+
+## Responsive Design
+
+**Source:** `internal/ui/model/ui.go`
+
+Compact mode triggers automatically at breakpoints:
+
+```go
+const (
+    compactModeWidthBreakpoint  = 120
+    compactModeHeightBreakpoint = 30
+)
+
+func (m *UI) updateLayoutAndSize() {
+    if m.state == uiChat {
+        if m.forceCompactMode {
+            m.isCompact = true
+            return
+        }
+        if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
+            m.isCompact = true
+        } else {
+            m.isCompact = false
+        }
+    }
+    m.layout = m.generateLayout(m.width, m.height)
+    m.updateSize()
+}
+```
+
+Compact mode removes the sidebar and replaces it with a compact header. Users can also force compact mode via config (`options.tui.compact_mode`).
+
+Text width is capped for readability:
+
+```go
+const maxTextWidth = 120  // in chat/messages.go
+```
+
+## Mouse Handling
+
+**Source:** `internal/ui/model/ui.go`
+
+Mouse events are handled per-type with coordinate translation:
+
+```go
+case tea.MouseClickMsg:
+    // Dialogs first
+    if m.dialog.HasDialogs() {
+        m.dialog.Update(msg)
+        return m, tea.Batch(cmds...)
+    }
+    // Focus click (editor vs chat)
+    if cmd := m.handleClickFocus(msg); cmd != nil { ... }
+    // Translate to chat-local coordinates
+    x := msg.X - m.layout.main.Min.X
+    y := msg.Y - m.layout.main.Min.Y
+    if handled, cmd := m.chat.HandleMouseDown(x, y); handled { ... }
+
+case tea.MouseWheelMsg:
+    switch msg.Button {
+    case tea.MouseWheelUp:
+        m.chat.ScrollByAndAnimate(-MouseScrollThreshold)
+    case tea.MouseWheelDown:
+        m.chat.ScrollByAndAnimate(MouseScrollThreshold)
+    }
+```
+
+The chat tracks drag state for text selection with double/triple click detection:
+
+```go
+case tea.MouseReleaseMsg:
+    if m.chat.HandleMouseUp(x, y) && m.chat.HasHighlight() {
+        // Delay to detect double-click vs single-click
+        cmds = append(cmds, tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg {
+            if time.Since(m.lastClickTime) >= doubleClickThreshold {
+                return copyChatHighlightMsg{}
+            }
+            return nil
+        }))
+    }
+```
+
+## Animation System
+
+**Source:** `internal/ui/anim/anim.go`
+
+Animations use a `StepMsg` tick message. The chat routes it to animatable items:
+
+```go
+case anim.StepMsg:
+    if m.state == uiChat {
+        if cmd := m.chat.Animate(msg); cmd != nil {
+            cmds = append(cmds, cmd)
+        }
+        if m.chat.Follow() {
+            m.chat.ScrollToBottomAndAnimate()
+        }
+    }
+```
+
+Items that support animation implement the `Animatable` interface:
+
+```go
+type Animatable interface {
+    StartAnimation() tea.Cmd
+    Animate(msg anim.StepMsg) tea.Cmd
+}
+```
+
+Scrolling is animated - methods like `ScrollByAndAnimate()` and `ScrollToBottomAndAnimate()` smooth the transition.
+
+## Screen Buffer Rendering
+
+**Source:** `internal/ui/model/ui.go`
+
+The View() -> Draw() pipeline:
+
+```go
+func (m *UI) View() tea.View {
+    var v tea.View
+    v.AltScreen = true
+    v.BackgroundColor = m.com.Styles.Background
+    v.MouseMode = tea.MouseModeCellMotion
+
+    canvas := uv.NewScreenBuffer(m.width, m.height)
+    v.Cursor = m.Draw(canvas, canvas.Bounds())
+
+    content := canvas.Render()
+    // Normalize newlines, trim trailing spaces
+    v.Content = content
+    return v
+}
+
+func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+    screen.Clear(scr)
+    switch m.state {
+    case uiChat:
+        if m.isCompact {
+            m.drawHeader(scr, layout.header)
+        } else {
+            m.drawSidebar(scr, layout.sidebar)
+        }
+        m.chat.Draw(scr, layout.main)
+        uv.NewStyledString(m.renderEditorView(editorWidth)).Draw(scr, layout.editor)
+    }
+    // Status bar
+    m.status.Draw(scr, layout.status)
+    // Completions popup (overlays)
+    if m.completionsOpen { ... }
+    // Dialogs last (always on top)
+    if m.dialog.HasDialogs() {
+        return m.dialog.Draw(scr, scr.Bounds())
+    }
+    // Return cursor based on focus
+}
+```
+
+The screen buffer handles:
+- Z-ordering (components drawn later overlay earlier ones)
+- Cursor from the last-drawn focused component
+- Efficient diffing (bubbletea handles this)
+
+Terminal progress bar is enabled for supported terminals:
+
+```go
+if m.progressBarEnabled && m.sendProgressBar && m.isAgentBusy() {
+    v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
+}
+```

skills/exo-teams/SKILL.md 🔗

@@ -0,0 +1,144 @@
+---
+name: exo-teams
+description: CLI tool for Microsoft Teams internal API - no admin consent required. Use when working with Teams messages, DMs, assignments, class materials, file uploads, calendar, or deadlines. Trigger on "teams", "exo-teams", "microsoft teams", "assignments", "class materials", "send message teams", "teams files", "uni automation", "deadlines", or "submit assignment".
+argument-hint: "[command or 'help']"
+---
+
+# exo-teams
+
+Go CLI for Microsoft Teams using the internal (desktop app) API. No admin consent, no Graph API registration, no IT department. Tokens stored at `~/.exo-teams/`.
+
+## Auth
+
+Five token scopes, all acquired via device code OAuth (`exo-teams auth`):
+
+| Token | Used for |
+|-------|----------|
+| skype | messaging, activity feed, read receipts |
+| chatsvcagg | teams/channels/chats listing |
+| teams | middle tier ops |
+| graph | calendar, files, search, user profiles |
+| assignments | education assignments via `assignments.onenote.com` (bypasses admin consent) |
+
+Tokens auto-refresh. Check status with `exo-teams whoami`.
+
+**Skypetoken quirk**: messaging uses `Authentication: skypetoken=<value>` header, NOT `Authorization: Bearer`. The skypetoken is derived from the raw skype JWT via an authz exchange endpoint.
+
+## Quick Start
+
+```bash
+exo-teams auth                              # device code login
+exo-teams whoami                            # token status + account info
+exo-teams list-teams                        # see all teams, channels, and channel IDs
+exo-teams list-chats                        # all DMs and group chats with IDs
+```
+
+## Command Reference
+
+See `references/commands.md` for full flag docs. Quick map:
+
+| Command | What it does |
+|---------|-------------|
+| `auth [--import\|--refresh]` | login, import from fossteams, or force refresh |
+| `whoami` | account info + per-token expiry |
+| `list-teams` | all teams + channels with IDs |
+| `list-chats` | all DMs + group chats with IDs |
+| `get-messages <search>` | channel messages by name search or ID |
+| `get-chat <search>` | DM/group chat messages |
+| `send <conv-id> <message>` | send text to any conversation |
+| `send-file <conv-id> --file <path>` | upload file to OneDrive, send share link |
+| `new-dm <name> <message>` | find user by name, open DM, send message |
+| `files <team-search>` | list SharePoint files |
+| `upload <team-search> --path --file` | upload to SharePoint |
+| `download <team-search> --path` | download from SharePoint |
+| `calendar [--days N]` | upcoming calendar events (default 7 days) |
+| `assignments [--classes]` | all assignments with submission status |
+| `submit <class> <assignment> --file` | submit a file to an assignment |
+| `deadlines` | pending assignments sorted by due date |
+| `unread` | unread conversations at a glance |
+| `activity` | activity feed (mentions, replies, reactions) |
+| `search <query>` | search messages + files |
+| `mark-read <conv-id>` | mark conversation as read |
+
+All commands accept `--json` for machine-readable output.
+
+## Data Discovery Guide
+
+See `references/data-discovery.md` for full patterns. Critical ones:
+
+**Find team/channel IDs:**
+```bash
+exo-teams list-teams
+# Output: == Team Name == / #channel-name   19:abc123@thread.tacv2
+```
+
+**Find chat/DM IDs:**
+```bash
+exo-teams list-chats
+# Output: * [DM]  Person Name   19:abc@unq.gbl.spaces
+```
+
+**Find your user ID:**
+```bash
+exo-teams whoami --json
+# or: exo-teams new-dm "their name" "" (prints found user ID to stderr)
+```
+
+**Find assignment IDs:**
+```bash
+exo-teams assignments --classes     # list class IDs
+exo-teams assignments --json        # includes classId, id, submissionId
+```
+
+## Key Gotchas
+
+- Conversation IDs contain `:` and `@` - they are always URL-encoded internally, but pass them raw to CLI args
+- `chat.hidden` is unreliable - most active DMs have `hidden=true`, use `list-chats` not hidden filter
+- `annotationsSummary.emotions` returns either `[]any` or `map[string]any` depending on message
+- File upload to DMs uses OneDrive ("Microsoft Teams Chat Files" folder) + share link, not AMS
+- SharePoint upload (team files) uses Graph PUT to `/groups/{id}/drive/root:/{path}:/content`
+- 423 on OneDrive upload means file is locked - tool auto-retries with deduped name up to 5 times
+- Activity feed reads from a special conversation ID `48:notifications`
+- Unread status comes from `isRead` field on Chat which can be bool or null - null means read
+- Search uses Graph `/search/query` with `message` + `driveItem` entity types (no Chat.Read needed)
+- `assignments` command uses `assignments.onenote.com` API not Graph `/education/` (bypasses admin consent)
+
+## Common Patterns
+
+```bash
+# Check what needs submitting today
+exo-teams deadlines
+
+# Read a specific channel
+exo-teams get-messages "Academic Writing" --replies --count 50
+
+# Read all messages ever (paginated)
+exo-teams get-messages "General" --all
+
+# Messages after a date
+exo-teams get-messages "General" --since 2026-03-01
+
+# Send a file to a DM
+exo-teams send-file "19:abc@unq.gbl.spaces" --file report.pdf --message "here it is"
+
+# Multi-file send
+exo-teams send-file "19:abc@unq.gbl.spaces" --file a.pdf --file b.docx
+
+# Download class material
+exo-teams files "Academic Writing" --drive "Class Materials"
+exo-teams download "Academic Writing" --path "Class Materials/week1.pdf" --drive "Class Materials"
+
+# Submit assignment
+exo-teams submit "Academic Writing" "Essay 1" --file essay.docx
+
+# Export channel to JSON
+exo-teams get-messages "General" --all --json > general.json
+```
+
+## Scripting Patterns
+
+See `references/scripting.md` for full automation examples including:
+- Fetch all pending assignments + download class materials
+- Monitor channel for new messages (poll loop)
+- Export full conversation to markdown
+- Bulk-send files across multiple conversations

skills/exo-teams/references/commands.md 🔗

@@ -0,0 +1,227 @@
+# Command Reference
+
+Full flag documentation for every exo-teams command.
+
+## auth
+
+```
+exo-teams auth [--import] [--refresh]
+```
+
+- No flags: device code OAuth login (opens browser)
+- `--import`: import tokens from fossteams (`~/.config/fossteams/`)
+- `--refresh`: force refresh using saved refresh token
+
+## whoami
+
+```
+exo-teams whoami [--json]
+```
+
+Shows display name, email, and expiry for all 5 tokens. JSON output is an array of `{name, valid, expiry}`.
+
+## list-teams
+
+```
+exo-teams list-teams [--json]
+```
+
+Lists all teams with their channels. Output format:
+```
+== Team Name ==
+   Description
+   #channel-name                   19:channelid@thread.tacv2 (general)
+```
+
+The channel ID is what you pass to `get-messages`.
+
+## list-chats
+
+```
+exo-teams list-chats [--json]
+```
+
+Lists DMs and group chats. Resolves MRI UUIDs to display names via Graph API.
+```
+* [DM   ] Person Name                       19:chatid@unq.gbl.spaces
+           Person: last message preview
+```
+
+`*` prefix means unread. The chat ID is what you pass to `get-chat`, `send`, `send-file`, `mark-read`.
+
+## get-messages
+
+```
+exo-teams get-messages <search> [--count N] [--all] [--replies] [--since DATE] [--json]
+```
+
+- `<search>`: matches against "TeamName ChannelName" (case-insensitive), or exact channel ID
+- `--count N`: messages per page (default 200)
+- `--all`: follow backwardLink pagination to get all messages
+- `--replies`: show reply threads grouped and indented
+- `--since DATE`: filter to messages on/after date, format `2026-03-15`
+
+Output (oldest first):
+```
+[2026-03-20 14:22] Alice: message content
+  > [2026-03-20 14:25] Bob: reply content  (with --replies)
+```
+
+## get-chat
+
+```
+exo-teams get-chat <search> [--count N] [--all] [--replies] [--since DATE] [--json]
+```
+
+- `<search>`: matches against title, member name, chat ID, or last message content
+- Same flags as `get-messages`
+
+## send
+
+```
+exo-teams send <conversation-id> <message>
+```
+
+Sends a plain text message. Works for both channel IDs and chat IDs.
+
+## send-file
+
+```
+exo-teams send-file <conversation-id> --file <path> [--file <path>...] [--message <text>]
+```
+
+- `--file`: local file path (repeat for multiple files)
+- `--message`: optional text with the first file
+
+**How it works**: uploads to OneDrive `/me/drive/root:/Microsoft Teams Chat Files/<name>:/content`, creates an org-scoped share link, then sends a message with the file metadata. Retries up to 5 times on 423 (locked) with deduplicated names.
+
+## new-dm
+
+```
+exo-teams new-dm <user-search> <message>
+```
+
+Searches tenant users via Graph `$filter=startswith(displayName,'...')`, creates a 1:1 conversation, sends the message. Prints found user name/email to stderr.
+
+## files
+
+```
+exo-teams files <team-search> [--path <subfolder>] [--drive <drive-name>] [--all-drives] [--json]
+```
+
+- `<team-search>`: partial team name match
+- `--path`: subfolder within drive (e.g. `General` or `General/Week 1`)
+- `--drive`: specific drive by name (e.g. `Class Materials`)
+- `--all-drives`: list files from ALL drives (Documents, Class Materials, etc.)
+
+Education teams typically have two drives: `Documents` (default) and `Class Materials`.
+
+Output:
+```
+F filename.pdf                          1234 KB  2026-03-20 14:22  Alice
+D Subfolder                             3 items  2026-03-15 10:00  Bob
+```
+
+## upload
+
+```
+exo-teams upload <team-search> --path <remote-path> --file <local-file>
+```
+
+- `--path`: remote path in team drive (e.g. `General/report.pdf`)
+- `--file`: local file to upload
+
+Uses Graph PUT to `/groups/{groupId}/drive/root:/{path}:/content`.
+
+## download
+
+```
+exo-teams download <team-search> --path <file-path> [--output <dir-or-file>] [--drive <drive-name>]
+```
+
+- `--path`: file path in team drive (e.g. `General/Cat Cafe (1).xlsx`)
+- `--output`: local output directory or full file path (default: current directory)
+- `--drive`: target a specific drive by name
+
+## calendar
+
+```
+exo-teams calendar [--days N] [--json]
+```
+
+- `--days N`: days ahead to show (default 7)
+
+Shows subject, time range, location, organizer, and body preview. Skips cancelled events.
+
+## assignments
+
+```
+exo-teams assignments [--classes] [--json]
+```
+
+- `--classes`: list education classes with IDs instead of assignments
+
+Default output shows all assignments across all classes with submission status:
+```
+[ ] Assignment Name               due: 2026-03-25 23:59  class: Academic Writing
+    Instructions preview...
+[ ] uncompleted
+[~] in progress / working
+[x] submitted
+[>] returned (graded)
+```
+
+Submitted files are listed with their names and links.
+
+## submit
+
+```
+exo-teams submit <class-search> <assignment-search> --file <path>
+```
+
+1. Finds class by partial name match
+2. Finds assignment by partial name match
+3. Gets your submission ID
+4. Calls SetUpResourcesFolder
+5. Uploads file as a submission resource
+6. Calls submit endpoint
+
+## deadlines
+
+```
+exo-teams deadlines [--json]
+```
+
+Shows only non-submitted assignments, sorted by due date. Calculates days remaining. Shows OVERDUE, TODAY, TOMORROW labels.
+
+## unread
+
+```
+exo-teams unread [--json]
+```
+
+Filters chats where `isRead == false`. Shows title, last sender, and last message preview.
+
+## activity
+
+```
+exo-teams activity [--json]
+```
+
+Reads from the `48:notifications` special conversation thread. Shows activityType (mention, reply, reaction), sender, and content.
+
+## search
+
+```
+exo-teams search <query> [--json]
+```
+
+Searches using Graph `/search/query` with `message` and `driveItem` entity types. Returns up to 25 hits. Shows sender, timestamp, channel/location, and content preview.
+
+## mark-read
+
+```
+exo-teams mark-read <conversation-id>
+```
+
+Sets consumptionHorizon to current timestamp via PUT to internal API.

skills/exo-teams/references/data-discovery.md 🔗

@@ -0,0 +1,126 @@
+# Data Discovery Guide
+
+How to find IDs and data buried in Microsoft Teams.
+
+## "How do I find which teams I'm in?"
+
+```bash
+exo-teams list-teams
+```
+
+Output shows team names and all their channels with IDs. The group ID (for SharePoint/files) is in the JSON:
+```bash
+exo-teams list-teams --json | jq '.[].teamSiteInformation.groupId'
+```
+
+## "How do I find a channel ID?"
+
+```bash
+exo-teams list-teams
+# Look for: #channel-name   19:abc123@thread.tacv2
+```
+
+Channel IDs look like `19:abc123@thread.tacv2`. Pass them directly to `get-messages`.
+
+## "How do I find a conversation/chat ID?"
+
+```bash
+exo-teams list-chats
+# Look for: [DM]  Person Name   19:abc@unq.gbl.spaces
+```
+
+Chat IDs look like `19:abc@unq.gbl.spaces`. Pass them to `send`, `get-chat`, `mark-read`, `send-file`.
+
+## "How do I find a file in SharePoint?"
+
+```bash
+# List default drive
+exo-teams files "Team Name"
+
+# List a specific subfolder
+exo-teams files "Team Name" --path "General/Week 1"
+
+# See class materials (education teams)
+exo-teams files "Team Name" --drive "Class Materials"
+
+# See ALL drives at once
+exo-teams files "Team Name" --all-drives
+```
+
+The file path shown in `files` output is what you pass to `--path` for `download`.
+
+## "How do I find assignment details?"
+
+```bash
+# List all classes
+exo-teams assignments --classes
+
+# List all assignments with status
+exo-teams assignments
+
+# Get full JSON including classId, assignment ID, submission ID
+exo-teams assignments --json
+```
+
+Assignment status icons:
+- `[ ]` - not submitted
+- `[~]` - working / in progress
+- `[x]` - submitted
+- `[>]` - returned (graded)
+
+## "How do I check unread messages?"
+
+```bash
+# Quick overview
+exo-teams unread
+
+# Full list with IDs
+exo-teams list-chats
+# * prefix = unread
+```
+
+Unread is based on `isRead == false` in chat metadata. Note: `isRead` can be null (treated as read) or bool.
+
+## "How do I find someone's user ID?"
+
+```bash
+# Try new-dm - it will print the found user ID to stderr
+exo-teams new-dm "their name" "test" 2>&1 | head
+# stderr: found user: Alice Smith (alice@school.edu)
+# stderr: conversation created: 19:...
+
+# Or search via JSON
+exo-teams list-chats --json | jq '.[].members[] | select(.friendlyName | test("Alice"))'
+# Returns: {"mri":"8:orgid:uuid-here","friendlyName":"Alice Smith","role":"User"}
+```
+
+MRI format is `8:orgid:<uuid>`. The UUID is the Azure AD object ID used in Graph API calls.
+
+## "How do I find what I submitted for an assignment?"
+
+```bash
+exo-teams assignments --json | jq '.[] | select(.displayName | test("Essay")) | {status: .submissionStatus, submitted: .submittedDateTime, resources: .submittedResources}'
+```
+
+Or just run `exo-teams assignments` - submitted resources are printed under each assignment.
+
+## "How do I see all my classes?"
+
+```bash
+exo-teams assignments --classes
+# Output: ClassName   class-uuid
+```
+
+The class UUID is what the assignments API uses internally.
+
+## ID Format Reference
+
+| ID type | Format example | Where to get it |
+|---------|---------------|-----------------|
+| Channel ID | `19:abc@thread.tacv2` | `list-teams` |
+| Chat/DM ID | `19:abc@unq.gbl.spaces` | `list-chats` |
+| Group ID (team) | UUID | `list-teams --json` -> `teamSiteInformation.groupId` |
+| User MRI | `8:orgid:uuid` | `list-chats --json` -> `members[].mri` |
+| Class ID | UUID | `assignments --classes` |
+| Assignment ID | UUID | `assignments --json` -> `id` |
+| Submission ID | UUID | `assignments --json` -> `submissionId` |

skills/exo-teams/references/scripting.md 🔗

@@ -0,0 +1,145 @@
+# Scripting Patterns
+
+Automation examples using exo-teams.
+
+## Fetch all assignments and check status
+
+```bash
+#!/bin/bash
+echo "=== PENDING DEADLINES ==="
+exo-teams deadlines
+
+echo ""
+echo "=== ALL ASSIGNMENTS ==="
+exo-teams assignments
+```
+
+```bash
+# Machine-readable pipeline
+exo-teams assignments --json | jq '
+  .[] | {
+    class: .className,
+    name: .displayName,
+    due: .dueDateTime,
+    status: .submissionStatus
+  }
+'
+```
+
+## Download all class materials
+
+```bash
+#!/bin/bash
+TEAM="Academic Writing"
+OUTPUT_DIR="./class-materials"
+mkdir -p "$OUTPUT_DIR"
+
+# List files
+exo-teams files "$TEAM" --drive "Class Materials" --json | jq -r '.[] | select(.folder == null) | .name' | while read name; do
+  exo-teams download "$TEAM" \
+    --path "Class Materials/$name" \
+    --drive "Class Materials" \
+    --output "$OUTPUT_DIR/$name"
+done
+```
+
+## Send a file to someone
+
+```bash
+# Step 1: find their chat ID
+exo-teams list-chats | grep "Alice"
+# * [DM   ] Alice Smith    19:abc123@unq.gbl.spaces
+
+# Step 2: send
+exo-teams send-file "19:abc123@unq.gbl.spaces" \
+  --file report.pdf \
+  --message "Here's the report"
+```
+
+## Submit an assignment
+
+```bash
+# Check what's pending
+exo-teams deadlines
+
+# Submit
+exo-teams submit "Academic Writing" "Essay 1" --file my-essay.docx
+```
+
+## Monitor a channel for new messages (poll loop)
+
+```bash
+#!/bin/bash
+CHANNEL="General"
+LAST_DATE=$(date -u +"%Y-%m-%d")
+
+while true; do
+  echo "--- checking at $(date) ---"
+  exo-teams get-messages "$CHANNEL" --since "$LAST_DATE"
+  LAST_DATE=$(date -u +"%Y-%m-%d")
+  sleep 60
+done
+```
+
+## Export a conversation to markdown
+
+```bash
+#!/bin/bash
+CHAT="Alice"
+OUTPUT="chat-export.md"
+
+echo "# Chat Export: $CHAT" > "$OUTPUT"
+echo "Generated: $(date)" >> "$OUTPUT"
+echo "" >> "$OUTPUT"
+
+exo-teams get-chat "$CHAT" --all --json | jq -r '
+  reverse[] |
+  select(.messagetype == "RichText/Html" or .messagetype == "Text") |
+  "**[\(.composetime | split("T")[0])] \(.imdisplayname):** \(.content | gsub("<[^>]*>"; ""))"
+' >> "$OUTPUT"
+
+echo "Exported to $OUTPUT"
+```
+
+## Check all unread and mark them
+
+```bash
+# See unread
+exo-teams unread --json | jq -r '.[].id'
+
+# Mark all unread as read
+exo-teams unread --json | jq -r '.[].id' | while read id; do
+  echo "marking $id as read..."
+  exo-teams mark-read "$id"
+done
+```
+
+## Bulk search and find files
+
+```bash
+# Search for files matching a query
+exo-teams search "lecture notes" --json | jq '
+  .[] |
+  select(.resource.name != null) |
+  {name: .resource.name, url: .resource.webUrl}
+'
+```
+
+## Get today's calendar and assignments together
+
+```bash
+echo "=== CALENDAR TODAY ==="
+exo-teams calendar --days 1
+
+echo ""
+echo "=== DUE SOON ==="
+exo-teams deadlines | head -5
+```
+
+## Tips
+
+- All commands accept `--json` - pipe into `jq` for filtering/transformation
+- `get-messages` and `get-chat` print status to stderr, data to stdout - safe to redirect stdout
+- `--all` flag on get-messages follows backwardLink pagination automatically
+- `--since` accepts `YYYY-MM-DD` or `YYYY-MM-DD HH:MM:SS`
+- Token refresh is automatic - no need to manually call `auth --refresh` in scripts

skills/model-forge/SKILL.md 🔗

@@ -0,0 +1,212 @@
+---
+name: model-forge
+description: Pick the right model for a task and the right git strategy for the work. Use when about to dispatch a subagent, start a new feature, or unsure whether to use opus/sonnet/haiku/codex, or whether to branch/worktree/work direct. NOT for actually running the work - this only routes.
+disable-model-invocation: false
+---
+
+# Model Forge
+
+Routing reference for picking the right model and the right git strategy. Consult before any subagent dispatch or non-trivial change.
+
+## Critical Rule
+
+**ALWAYS run subagents in background (`run_in_background: true`) UNLESS the user explicitly asks for foreground.** The main thread stays unblocked, the user can keep working, and long-running dispatches don't freeze the conversation. Foreground subagents are the exception, not the default.
+
+## Model Capability Matrix
+
+### claude-opus-4-6
+Heaviest reasoning, best at multi-file architecture and critical correctness. Slowest and most expensive claude.
+**Speed/cost:** ~3-5x slower than sonnet, ~5x cost premium.
+**Use for:**
+- Deep architecture design and hard refactors
+- Security audits requiring full mental model
+- Complex math, proofs, algorithmic correctness
+- Novel problems with no clear path
+- Multi-system integration design
+- Critical decisions where being wrong is expensive
+
+### claude-sonnet-4-6
+Balanced workhorse. Default for ~90% of work and most subagent dispatches.
+**Speed/cost:** solid throughput, mid-tier cost.
+**Use for:**
+- Standard feature coding and debugging
+- Research synthesis and writing
+- Code review for logic and readability
+- Scaffolding new modules from spec
+- Most multi-step subagent tasks
+- Anything you'd default to without thinking
+
+### claude-haiku-4-5
+Fastest and cheapest. Capable enough for mechanical work.
+**Speed/cost:** near-instant, fraction of sonnet cost.
+**Use for:**
+- File reads, scans, classification
+- Transcription (youtube, audio, OCR)
+- Single tool calls with no reasoning
+- Grep-style searches across large trees
+- Generating structured metadata from raw input
+- Bulk context gathering across many files
+
+### gpt-5.4 (via codex plugin)
+Adversarial engineer with different priors than claude. Mechanically excellent on focused tasks, brittle on vague ones. Extremely slow.
+**Speed/cost:** ~30x slower than sonnet (10 min vs 18s in real tests). 3-4x fewer tokens than claude on comparable work. ChatGPT Plus = 30-150 msgs/5hr window.
+**Benchmarks:** terminal-bench 77.3% vs claude 65.4%. Debate mode (claude + codex) bug detection jumps 53% → 80%.
+**IMPORTANT:** Always pass `--model gpt-5.4` (or `model: "gpt-5.4"` in subagent config). Default model selection is unreliable - lock it explicitly every time.
+**Use for:**
+- Adversarial code review (highest-ROI use case - independent priors catch what claude misses)
+- Focused bug fixes with a clear spec and acceptance criteria
+- Mechanical refactors to a defined pattern (85-90% success on bounded scope)
+- Test coverage for existing logic (stays in lane, no scope creep)
+- Maintenance batch work (deps, doc fixes, webhook changes - queue 3-5 at session start)
+- Terminal/CLI workflows
+- Second-opinion on completed code
+
+## Routing Decision Tree
+
+1. Is the task trivial / single tool call / scan / transcription / classification? → **haiku**
+2. Standard coding / research / writing / debugging / scaffolding? → **sonnet** (default)
+3. Deep architecture / hard multi-file refactor / correctness-critical / novel? → **opus**
+4. Adversarial bug hunt / security audit / can wait 10+ min? → **codex** (background only)
+5. Time-sensitive (need result < 2 min)? → **never codex**, sonnet or haiku
+
+## Parallelism Rules
+
+**Dispatch in parallel when:**
+- Subtasks are independent (different dirs, different topics)
+- Multiple haiku scanning unrelated paths
+- Multiple sonnet researching separate questions
+- Send all dispatches in a single message, collect results
+
+**Dispatch in background when:**
+- Long-running work (codex, opus refactor)
+- User can keep working while it runs
+- Result doesn't block immediate next step
+
+**Keep in main agent when:**
+- Small fast iterative loops (write -> test -> fix)
+- Round-trip overhead exceeds task duration
+- You need the tool results in your own context
+
+**Always:**
+- Run subagents in background (`run_in_background: true`) so the main thread stays unblocked
+- Lock codex model explicitly to `gpt-5.4` on every invocation
+- Send parallel dispatches in a single message, not sequential calls
+
+**Never:**
+- Run parallel agents on the same files without worktrees
+- Dispatch codex for anything you're watching live
+- Use opus for trivial tool calls
+- Run subagents in foreground when background works
+
+## Git Strategy
+
+### Direct on current branch
+- Change is < 50 lines, 1-2 files
+- No parallel agents, pure linear flow
+- Easily reversible
+- **Example:** fix a typo, add a small util, edit a config
+
+### Feature branch (`feat/<slug>`)
+- Diff is large enough to bundle commits
+- Single linear workstream, no overlapping agents
+- Want to test/review before merging
+- **Example:** `git checkout -b feat/auth-refresh` for new auth flow
+
+### Git worktree
+- Multiple subagents touching overlapping files
+- Want experiment isolation while main work continues
+- Long-running opus/codex shouldn't dirty working tree
+- **Example:**
+  ```bash
+  git worktree add ../project-feat-api feat/api-layer
+  # dispatch sonnet into ../project-feat-api
+  # main agent stays unblocked in original tree
+  git merge feat/api-layer
+  git worktree remove ../project-feat-api
+  ```
+
+## Quick Reference
+
+| task type | model | branch | parallel? | background? |
+|---|---|---|---|---|
+| transcription / extraction | haiku | current | yes | yes |
+| file scan / grep | haiku | current | yes | no |
+| bulk context gathering | haiku | current | yes | yes |
+| research synthesis | sonnet | current | yes | optional |
+| code review (logic/style) | sonnet | current | no | no |
+| scaffolding new module | sonnet | feat | no | no |
+| writing docs | sonnet | feat | no | no |
+| debugging (interactive) | sonnet | current | no | no |
+| large refactor | opus | worktree | no | yes |
+| novel architecture | opus | feat | no | no |
+| security design | opus | feat | no | no |
+| adversarial code review | codex | feat | no | yes |
+| bug hunt | codex | worktree | no | yes |
+| security audit | codex | feat | no | yes |
+
+## Codex-Specific Failure Modes
+
+Codex is **too literal**. It will fulfill the exact request and break adjacent things. Real example from HN: asked to "fix compiler warnings", it made a bunch of values nullable to silence the warnings - technically correct, broke data integrity downstream.
+
+**Codex will fail at (NEVER use it for):**
+- **UI work of any kind** - layouts, components, styling, UX, design judgment. Codex has zero taste and no eye for visual hierarchy. Always claude.
+- **Anything you need to discuss** - "yo i need to talk about this", brainstorming, exploration, "what do you think", trade-off conversations. Codex is a one-shot executor, not a collaborator. Always claude.
+- **Reading user intent** - "make it better" → won't make leaps, will pick the most literal interpretation
+- **Architecture/product judgment** - no sense of tradeoffs beyond what's written
+- **Obscure libraries** - hallucinates rather than admitting unknown
+- **Multi-file dependency chains** - gets stuck in circles
+- **Continuous conversation context** - context degrades fast, not built for back-and-forth
+
+**Codex will excel at:**
+- Tasks where being literal is a feature (test writing, mechanical refactors)
+- Clearly-bounded specs with acceptance criteria
+- Adversarial passes with explicit focus arguments
+
+**Prompt requirements for codex:**
+- One concern per prompt (don't bundle)
+- Explicit scope boundaries (which files, what behavior, what done looks like)
+- Structured sections: General → Autonomy → Code Implementation → Editing Constraints → Exploration → Plan Tool
+- Use the positional focus argument: `/codex:adversarial-review challenge whether this caching design was right`
+- Heavy XML structure beats prose for multi-step specs
+
+## Optimal Codex Workflow
+
+The pattern that works (claude + codex hybrid):
+
+1. **Claude builds** - architecture, exploration, back-and-forth iteration
+2. **Codex reviews** - dispatch `/codex:adversarial-review` with a specific focus after a major change. 6-10 min wait, actionable findings
+3. **Claude filters** - triage codex output: "which are real issues vs noise, which need design decisions"
+4. **Codex fixes** - confirmed bugs with clear specs get delegated back to codex
+5. **Batch maintenance** - queue codex with 3-5 P2 tasks at session start, work on real stuff while it churns
+
+What doesn't work:
+- Letting codex drive architecture decisions
+- Vague task descriptions
+- Auto-running codex on every claude response (drains usage fast)
+
+## Anti-patterns
+
+1. **Codex for time-sensitive work** - 10+ min latency kills interactive flow
+2. **Codex with vague prompts** - it will interpret literally and break things
+3. **Opus for trivial tool calls** - haiku does it at 5% cost
+4. **Worktrees for non-overlapping single-agent work** - merge overhead with no upside
+5. **Big refactors on main** - hard rollback, polluted history
+6. **Parallel agents on same files without worktrees** - concurrent write conflicts
+7. **Dispatching codex synchronously** - always background, never wait
+8. **Forgetting `--model gpt-5.4`** - default selection is unreliable
+9. **Codex for UI work** - zero taste, no visual judgment, will produce bricks
+10. **Codex when you need to discuss/brainstorm** - it's a one-shot executor, not a collaborator
+
+## Examples
+
+**Bulk context gathering:** 5 independent file reads → 5 haiku in parallel, single message dispatch.
+
+**New feature scaffolding (small):** sonnet, current branch, no subagents.
+
+**New feature scaffolding (big, multi-file):** sonnet, `feat/<slug>` branch, optional sonnet subagents for independent modules.
+
+**Security audit before launch:** codex in background on `feat/audit` worktree, you keep building on main.
+
+**Hard refactor across 20 files:** opus in worktree, you stay on main fixing other stuff, merge when done.
+
+**"Find all references to X across the codebase":** haiku, current branch, no subagent (Grep tool is faster).

skills/readme-forge/SKILL.md 🔗

@@ -1,7 +1,7 @@
 ---
 name: readme-forge
 description: Generate a README in alxx's personal style. Use when user says "write a README", "create README", "readme for this project", "update README", or needs a repo README.
-disable-model-invocation: true
+disable-model-invocation: false
 argument-hint: "[repo path or leave blank for current]"
 ---