/ extensions / cmux / index.ts
index.ts
  1  /**
  2   * cmux — Push pi agent state into the cmux sidebar.
  3   *
  4   * Hooks into pi lifecycle events and fires cmux CLI commands to update
  5   * sidebar status keys, progress, and notifications. Fire-and-forget —
  6   * errors are silently ignored so cmux issues never affect pi.
  7   *
  8   * No-op when CMUX_SOCKET_PATH is not set (i.e. not running inside cmux).
  9   */
 10  
 11  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
 12  
 13  const CMUX_SOCKET = process.env.CMUX_SOCKET_PATH;
 14  // Track the workspace that spawned this Pi process; `cmux current-workspace`
 15  // follows the focused workspace and can point at a different one.
 16  const CMUX_WORKSPACE_ID = process.env.CMUX_WORKSPACE_ID;
 17  
 18  // Colors
 19  const GREEN = "#22C55E";
 20  const AMBER = "#F59E0B";
 21  const PURPLE = "#8B5CF6";
 22  const BLUE = "#3B82F6";
 23  const GRAY = "#6B7280";
 24  
 25  // Status keys we own — cleared on shutdown
 26  const STATUS_KEYS = [
 27    "pi_state",
 28    "pi_model",
 29    "pi_thinking",
 30    "pi_tokens",
 31    "pi_cost",
 32    "pi_tool",
 33  ];
 34  
 35  function formatTokens(n: number): string {
 36    if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
 37    if (n >= 10_000) return `${Math.round(n / 1000)}k`;
 38    if (n >= 1_000) return `${(n / 1000).toFixed(1)}k`;
 39    return String(n);
 40  }
 41  
 42  function formatCost(n: number): string {
 43    return `$${n.toFixed(2)}`;
 44  }
 45  
 46  function shortModel(id: string): string {
 47    // Strip common prefixes: "claude-" etc., keep it readable
 48    return id
 49      .replace(/^claude-/, "")
 50      .replace(/-\d{8}$/, "");
 51  }
 52  
 53  function countMatches(text: string, pattern: RegExp): number {
 54    return [...text.matchAll(pattern)].length;
 55  }
 56  
 57  export default function (pi: ExtensionAPI) {
 58    if (!CMUX_SOCKET) return;
 59  
 60    let sessionCost = 0;
 61    let hasUI = false;
 62  
 63    // Fire-and-forget cmux CLI call
 64    function run(...args: string[]) {
 65      if (!hasUI) return;
 66      pi.exec("cmux", args, { timeout: 2000 }).catch(() => {});
 67    }
 68  
 69    function setStatus(key: string, value: string, icon: string, color: string) {
 70      run("set-status", key, value, "--icon", icon, "--color", color);
 71    }
 72  
 73    function clearStatus(key: string) {
 74      run("clear-status", key);
 75    }
 76  
 77    async function syncWorkspaceName() {
 78      if (!hasUI || !CMUX_WORKSPACE_ID) return;
 79  
 80      const sessionName = pi.getSessionName()?.trim();
 81      if (!sessionName) return;
 82  
 83      const treeResult = await pi.exec(
 84        "cmux",
 85        ["tree", "--workspace", CMUX_WORKSPACE_ID],
 86        { timeout: 2000 }
 87      ).catch(() => undefined);
 88      const tree = treeResult?.stdout;
 89      if (!tree || treeResult?.code !== 0) return;
 90  
 91      const paneCount = countMatches(tree, /\bpane pane:/g);
 92      const surfaceCount = countMatches(tree, /\bsurface surface:/g);
 93      if (paneCount !== 1 || surfaceCount !== 1) return;
 94  
 95      const workspaceMatch = tree.match(/workspace\s+workspace:\S+\s+"([^"]*)"/);
 96      const currentTitle = workspaceMatch?.[1]?.trim();
 97      if (currentTitle === sessionName) return;
 98  
 99      await pi.exec(
100        "cmux",
101        ["rename-workspace", "--workspace", CMUX_WORKSPACE_ID, sessionName],
102        { timeout: 2000 }
103      ).catch(() => {});
104    }
105  
106    // --- Session lifecycle ---
107  
108    pi.on("session_start", async (_event, ctx) => {
109      hasUI = ctx.hasUI;
110      if (!hasUI) return;
111  
112      await syncWorkspaceName();
113  
114      // Reconstruct session cost from existing entries
115      sessionCost = 0;
116      for (const entry of ctx.sessionManager.getBranch()) {
117        if (
118          entry.type === "message" &&
119          entry.message.role === "assistant" &&
120          (entry.message as any).usage?.cost?.total
121        ) {
122          sessionCost += (entry.message as any).usage.cost.total;
123        }
124      }
125  
126      // Set initial sidebar state
127      setStatus("pi_state", "Idle", "checkmark.circle", GREEN);
128  
129      if (ctx.model?.id) {
130        setStatus("pi_model", shortModel(ctx.model.id), "brain", PURPLE);
131      }
132  
133      const thinking = pi.getThinkingLevel();
134      if (thinking && thinking !== "off") {
135        setStatus("pi_thinking", thinking, "sparkles", AMBER);
136      }
137  
138      if (sessionCost > 0) {
139        setStatus("pi_cost", formatCost(sessionCost), "dollarsign.circle", GREEN);
140      }
141  
142      const usage = ctx.getContextUsage();
143      if (usage && usage.tokens != null && usage.tokens > 0) {
144        setStatus("pi_tokens", formatTokens(usage.tokens), "number", BLUE);
145      }
146    });
147  
148    pi.on("session_shutdown", async (_event, ctx) => {
149      if (!ctx.hasUI) return;
150      for (const key of STATUS_KEYS) {
151        clearStatus(key);
152      }
153    });
154  
155    // --- Agent working state ---
156  
157    pi.on("agent_start", async (_event, ctx) => {
158      if (!ctx.hasUI) return;
159      setStatus("pi_state", "Working", "arrow.circlepath", AMBER);
160    });
161  
162    pi.on("agent_end", async (_event, ctx) => {
163      if (!ctx.hasUI) return;
164      setStatus("pi_state", "Idle", "checkmark.circle", GREEN);
165      clearStatus("pi_tool");
166      await syncWorkspaceName();
167  
168      // Update final token count
169      const usage = ctx.getContextUsage();
170      if (usage && usage.tokens != null && usage.tokens > 0) {
171        setStatus("pi_tokens", formatTokens(usage.tokens), "number", BLUE);
172      }
173  
174      // Update cost
175      if (sessionCost > 0) {
176        setStatus("pi_cost", formatCost(sessionCost), "dollarsign.circle", GREEN);
177      }
178  
179      // Notify user that agent needs attention — use empty body so it
180      // triggers the blue ring and tab highlight without leaving persistent
181      // text in the sidebar.
182      run("notify", "--title", "Needs attention");
183    });
184  
185    // --- Turn tracking (tokens + cost) ---
186  
187    pi.on("turn_end", async (event, ctx) => {
188      if (!ctx.hasUI) return;
189  
190      // Accumulate cost from the assistant message
191      const msg = event.message;
192      if (msg?.role === "assistant" && (msg as any).usage?.cost?.total) {
193        sessionCost += (msg as any).usage.cost.total;
194        setStatus("pi_cost", formatCost(sessionCost), "dollarsign.circle", GREEN);
195      }
196  
197      // Update token count
198      const usage = ctx.getContextUsage();
199      if (usage && usage.tokens != null && usage.tokens > 0) {
200        setStatus("pi_tokens", formatTokens(usage.tokens), "number", BLUE);
201      }
202    });
203  
204    // --- Model / thinking changes ---
205  
206    pi.on("model_select", async (event, ctx) => {
207      if (!ctx.hasUI) return;
208      setStatus("pi_model", shortModel(event.model.id), "brain", PURPLE);
209      const thinking = pi.getThinkingLevel();
210      setStatus(
211        "pi_thinking",
212        thinking === "off" ? "off" : thinking,
213        "sparkles",
214        thinking === "off" ? GRAY : AMBER
215      );
216    });
217  
218    // --- Tool execution tracking ---
219  
220    pi.on("tool_execution_start", async (event, ctx) => {
221      if (!ctx.hasUI) return;
222      setStatus("pi_tool", event.toolName, "wrench", GRAY);
223    });
224  
225    pi.on("tool_execution_end", async (_event, ctx) => {
226      if (!ctx.hasUI) return;
227      clearStatus("pi_tool");
228    });
229  }