/ extensions / roam / index.ts
index.ts
  1  /**
  2   * Roam Extension
  3   *
  4   * Move the current Pi session into a tmux window for remote access
  5   *
  6   * Usage:
  7   *   /roam [window-name]    (default: cwd basename)
  8   *
  9   * Optional config (~/.pi/agent/extensions/roam/config.json):
 10   *   - copy from extensions/roam/config.json.example
 11   *   {
 12   *     "tailscale": {
 13   *       "account": "you@example.com",
 14   *       "binary": "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
 15   *     }
 16   *   }
 17   *
 18   * All Pi sessions share a single tmux session ("pi") on a dedicated socket (-L pi),
 19   * each in its own window. Uses a custom tmux config with dual prefix keys:
 20   *   - Ctrl+S (available on iOS Termius toolbar)
 21   *   - Ctrl+B (tmux default, for local use on Mac)
 22   *
 23   * The dedicated socket ensures the custom config is always applied, regardless
 24   * of other tmux servers that may be running.
 25   *
 26   * From Termius:
 27   *   - Attach: tmux -L pi -f ~/.config/pi-tmux/tmux.conf -u attach -t pi
 28   *   - Window list: Ctrl+S, then w
 29   *   - Next/prev window: Ctrl+S, then n/p
 30   *   - Detach: Ctrl+S, then d
 31   *   - No time limit between prefix and command key
 32   *
 33   * Flow:
 34   *   1. Pre-flight: TTY check, not already in tmux, session exists, tmux installed
 35   *   2. Optionally switch Tailscale account, then ensure Tailscale is up (non-fatal, macOS only)
 36   *   3. Fork the current Pi session to a new file (parentSession cleared)
 37   *   4. Create a tmux window (or session if first time) running the fork
 38   *   5. Tear down parent terminal, attach to tmux
 39   *   6. Trash original session file if in standard sessions dir (no duplicates)
 40   *   7. Parent becomes inert, forwarding exit code
 41   */
 42  
 43  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
 44  import { SessionManager } from "@mariozechner/pi-coding-agent";
 45  import { spawn } from "node:child_process";
 46  import { basename, join, resolve } from "node:path";
 47  import { homedir } from "node:os";
 48  import {
 49      writeFileSync, existsSync, mkdirSync, realpathSync, readFileSync,
 50      openSync, readSync, writeSync, closeSync, renameSync, unlinkSync,
 51  } from "node:fs";
 52  
 53  const TMUX_SESSION = "pi";
 54  const TMUX_SOCKET = "pi";
 55  const TAILSCALE_BIN = "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
 56  const TAILSCALE_TIMEOUT_MS = 10_000;
 57  const TRASH_TIMEOUT_MS = 5_000;
 58  const HEADER_READ_MAX = 8192;
 59  const COPY_CHUNK_SIZE = 65_536;
 60  
 61  function getBranchSelectionWarning(
 62      sourceSessionFile: string,
 63      currentLeafId: string | null,
 64      commandName: string,
 65      actionName: string,
 66  ): { warning: string | null; hasPersistedEntries: boolean | null } {
 67      try {
 68          const persistedSession = SessionManager.open(sourceSessionFile);
 69          const hasPersistedEntries = persistedSession.getEntries().length > 0;
 70          if (!hasPersistedEntries) {
 71              return { warning: null, hasPersistedEntries };
 72          }
 73  
 74          const persistedLeafId = persistedSession.getLeafId() as string | null;
 75          if (currentLeafId === null) {
 76              return {
 77                  warning:
 78                      `${commandName} will not preserve the current /tree root selection. ` +
 79                      `It reopens at the session file's default branch tip. ` +
 80                      `Consider /fork first or continue from the branch tip before ${actionName}.`,
 81                  hasPersistedEntries,
 82              };
 83          }
 84  
 85          if (currentLeafId !== persistedLeafId) {
 86              return {
 87                  warning:
 88                      `${commandName} will not preserve the current /tree selection. ` +
 89                      `It reopens at the session file's default branch tip. ` +
 90                      `Consider /fork first or continue from the branch tip before ${actionName}.`,
 91                  hasPersistedEntries,
 92              };
 93          }
 94  
 95          return { warning: null, hasPersistedEntries };
 96      } catch {
 97          // Ignore warning-detection failures
 98          return { warning: null, hasPersistedEntries: null };
 99      }
100  }
101  
102  const TMUX_CONFIG_CONTENT = [
103      "# Pi roam config — only used by /roam sessions (dedicated socket: -L pi)",
104      "# Does not affect your global ~/.tmux.conf or other tmux servers",
105      "",
106      "# Dual prefix: Ctrl+S (iOS Termius toolbar) and Ctrl+B (default, for local use)",
107      "set -g prefix C-s",
108      "set -g prefix2 C-b",
109      "bind C-s send-prefix",
110      "bind C-b send-prefix -2",
111      "",
112      "# UTF-8 and modern terminal support",
113      "set -g default-terminal 'screen-256color'",
114      "set -ga terminal-overrides ',xterm-256color:Tc'",
115      "",
116      "# Mouse support (useful for Termius touch scrolling)",
117      "set -g mouse on",
118      "",
119      "# Start window numbering at 1 (easier to reach on mobile)",
120      "set -g base-index 1",
121      "setw -g pane-base-index 1",
122      "",
123      "# Window status shows the name clearly",
124      "set -g status-left '[pi] '",
125      "set -g status-right ''",
126      "",
127  ].join("\n");
128  
129  type RoamConfig = {
130      tailscale?: {
131          account?: string;
132          binary?: string;
133      };
134  };
135  
136  type ResolvedRoamConfig = {
137      tailscaleAccount: string | null;
138      tailscaleBinary: string;
139  };
140  
141  function getAgentDir(): string {
142      return process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
143  }
144  
145  function getRoamConfigPath(): string {
146      return join(getAgentDir(), "extensions", "roam", "config.json");
147  }
148  
149  /**
150   * Load optional /roam config and validate shape.
151   * Missing config is treated as defaults.
152   */
153  function loadRoamConfig(configPath: string): ResolvedRoamConfig {
154      const defaults: ResolvedRoamConfig = {
155          tailscaleAccount: null,
156          tailscaleBinary: TAILSCALE_BIN,
157      };
158  
159      if (!existsSync(configPath)) {
160          return defaults;
161      }
162  
163      let parsed: RoamConfig;
164      try {
165          parsed = JSON.parse(readFileSync(configPath, "utf-8")) as RoamConfig;
166      } catch (error: any) {
167          throw new Error(`Invalid JSON in ${configPath}: ${error?.message ?? String(error)}`);
168      }
169  
170      const rawAccount = parsed.tailscale?.account;
171      if (rawAccount !== undefined && typeof rawAccount !== "string") {
172          throw new Error(`Invalid tailscale.account in ${configPath}; expected a string`);
173      }
174  
175      const rawBinary = parsed.tailscale?.binary;
176      if (rawBinary !== undefined && typeof rawBinary !== "string") {
177          throw new Error(`Invalid tailscale.binary in ${configPath}; expected a string`);
178      }
179  
180      return {
181          tailscaleAccount: rawAccount?.trim() || null,
182          tailscaleBinary: rawBinary?.trim() || TAILSCALE_BIN,
183      };
184  }
185  
186  /**
187   * Write the tmux config file; throws on FS errors (caller must handle)
188   */
189  function ensureTmuxConfig(): string {
190      const configDir = join(
191          process.env.HOME || process.env.USERPROFILE || "/tmp",
192          ".config", "pi-tmux"
193      );
194      const configPath = join(configDir, "tmux.conf");
195  
196      if (!existsSync(configDir)) {
197          mkdirSync(configDir, { recursive: true });
198      }
199      writeFileSync(configPath, TMUX_CONFIG_CONTENT);
200  
201      return configPath;
202  }
203  
204  /**
205   * Remove the parentSession field from a forked session's JSONL header
206   * without reading the entire file into memory. Reads only the first line
207   * (header), and if modification is needed, rewrites the file via a temp
208   * file using chunked streaming for the remaining content.
209   *
210   * Throws on FS errors (caller must handle).
211   */
212  function clearParentSession(sessionFile: string): void {
213      const fd = openSync(sessionFile, "r");
214      const buf = Buffer.alloc(HEADER_READ_MAX);
215      const bytesRead = readSync(fd, buf, 0, HEADER_READ_MAX, 0);
216      const headerChunk = buf.toString("utf-8", 0, bytesRead);
217      const newlineIdx = headerChunk.indexOf("\n");
218  
219      if (newlineIdx === -1) {
220          closeSync(fd);
221          return;
222      }
223  
224      const header = JSON.parse(headerChunk.slice(0, newlineIdx));
225      if (!header.parentSession) {
226          closeSync(fd);
227          return;
228      }
229  
230      // parentSession exists — stream-rewrite with modified header
231      delete header.parentSession;
232      const newHeaderLine = JSON.stringify(header) + "\n";
233      const originalHeaderBytes = Buffer.byteLength(
234          headerChunk.slice(0, newlineIdx + 1), "utf-8"
235      );
236  
237      const tmpPath = sessionFile + ".roam-tmp";
238      let wfd: number | undefined;
239      try {
240          wfd = openSync(tmpPath, "w");
241          const headerBuf = Buffer.from(newHeaderLine, "utf-8");
242          writeSync(wfd, headerBuf, 0, headerBuf.length);
243  
244          // Copy rest of file in chunks (avoids loading full session into memory)
245          const copyBuf = Buffer.alloc(COPY_CHUNK_SIZE);
246          let pos = originalHeaderBytes;
247          while (true) {
248              const n = readSync(fd, copyBuf, 0, COPY_CHUNK_SIZE, pos);
249              if (n === 0) break;
250              writeSync(wfd, copyBuf, 0, n);
251              pos += n;
252          }
253          closeSync(wfd);
254          wfd = undefined;
255          closeSync(fd);
256          renameSync(tmpPath, sessionFile);
257      } catch (error) {
258          // Clean up temp file on failure
259          if (wfd !== undefined) try { closeSync(wfd); } catch {}
260          closeSync(fd);
261          try { unlinkSync(tmpPath); } catch {}
262          throw error;
263      }
264  }
265  
266  /**
267   * Strip control characters from a window name to prevent tmux breakage
268   * and stdout parsing issues. Returns null if the result is empty.
269   */
270  function sanitizeWindowName(name: string): string | null {
271      const cleaned = name.replace(/[\x00-\x1f\x7f]/g, "").trim();
272      return cleaned || null;
273  }
274  
275  /**
276   * Check if a session file is inside the standard Pi sessions directory (~/.pi/).
277   * Custom --session paths (e.g. /some/custom/path.jsonl) should not be trashed.
278   * Handles symlinks (e.g. ~/.pi/agent -> ~/dot314/agent) via realpathSync.
279   */
280  function isInStandardSessionsDir(sessionFile: string): boolean {
281      const home = process.env.HOME || process.env.USERPROFILE;
282      if (!home) return false;
283      const piDir = join(home, ".pi");
284      try {
285          const resolvedFile = realpathSync(sessionFile);
286          const resolvedPiDir = realpathSync(piDir);
287          return resolvedFile.startsWith(resolvedPiDir + "/");
288      } catch {
289          // realpathSync can fail if file/dir doesn't exist; fall back to resolve()
290          return resolve(sessionFile).startsWith(resolve(piDir) + "/");
291      }
292  }
293  
294  export default function (pi: ExtensionAPI) {
295      const trashFileBestEffort = async (filePath: string): Promise<boolean> => {
296          try {
297              const { code } = await pi.exec("trash", [filePath], { timeout: TRASH_TIMEOUT_MS });
298              return code === 0;
299          } catch {
300              return false;
301          }
302      };
303  
304      pi.registerCommand("roam", {
305          description: "Move session into a tmux window for remote access via Tailscale",
306          handler: async (args, ctx) => {
307              await ctx.waitForIdle();
308  
309              // --- Pre-flight checks ---
310  
311              if (!ctx.hasUI || !process.stdin.isTTY || !process.stdout.isTTY) {
312                  if (ctx.hasUI) {
313                      ctx.ui.notify("/roam requires an interactive terminal", "error");
314                  }
315                  return;
316              }
317  
318              if (process.env.TMUX) {
319                  ctx.ui.notify("Already inside tmux. Use Ctrl+S d (or Ctrl+B d) to detach.", "error");
320                  return;
321              }
322  
323              const tmuxCheck = await pi.exec("which", ["tmux"]);
324              if (tmuxCheck.code !== 0) {
325                  ctx.ui.notify("tmux is not installed", "error");
326                  return;
327              }
328  
329              const sourceSessionFile = ctx.sessionManager.getSessionFile();
330              if (!sourceSessionFile) {
331                  ctx.ui.notify("No persistent session (started with --no-session?)", "error");
332                  return;
333              }
334  
335              const leafId = (ctx.sessionManager.getLeafId?.() as string | null) ?? null;
336              const { warning: branchSelectionWarning, hasPersistedEntries } = getBranchSelectionWarning(
337                  sourceSessionFile,
338                  leafId,
339                  "/roam",
340                  "roaming",
341              );
342              if (leafId === null && hasPersistedEntries === false) {
343                  ctx.ui.notify("No messages yet — nothing to roam", "error");
344                  return;
345              }
346              if (branchSelectionWarning) {
347                  ctx.ui.notify(branchSelectionWarning, "warning");
348              }
349  
350              const cwd = ctx.cwd;
351  
352              // Window name: from args or cwd basename, sanitized for tmux safety
353              const rawName = args.trim() || basename(cwd);
354              const windowName = sanitizeWindowName(rawName);
355              if (!windowName) {
356                  ctx.ui.notify("Invalid window name (empty after sanitization)", "error");
357                  return;
358              }
359  
360              let tailscaleAccount: string | null = null;
361              let tailscaleBinary = TAILSCALE_BIN;
362              const roamConfigPath = getRoamConfigPath();
363              try {
364                  const roamConfig = loadRoamConfig(roamConfigPath);
365                  tailscaleAccount = roamConfig.tailscaleAccount;
366                  tailscaleBinary = roamConfig.tailscaleBinary;
367              } catch (error: any) {
368                  ctx.ui.notify(
369                      `Ignoring invalid /roam config (${roamConfigPath}): ${error?.message ?? String(error)}`,
370                      "warning"
371                  );
372              }
373  
374              // Ensure dedicated tmux config exists and is up to date
375              let tmuxConfig: string;
376              try {
377                  tmuxConfig = ensureTmuxConfig();
378              } catch (error: any) {
379                  ctx.ui.notify(`Failed to write tmux config: ${error?.message ?? String(error)}`, "error");
380                  return;
381              }
382  
383              // Common tmux flags: dedicated socket + config file
384              const tmuxBase = ["-L", TMUX_SOCKET, "-f", tmuxConfig];
385  
386              // Check tmux state on our dedicated socket
387              let sessionExists = false;
388              try {
389                  const { code } = await pi.exec("tmux", [...tmuxBase, "has-session", "-t", TMUX_SESSION]);
390                  sessionExists = code === 0;
391              } catch {
392                  // tmux server not running on this socket — we'll create a new session
393              }
394  
395              // Source the latest config unconditionally — covers the case where the
396              // server is running but our session doesn't exist yet. If the server
397              // isn't running, this fails harmlessly. If the config has errors, we warn.
398              {
399                  const { code: srcCode, stderr: srcStderr } = await pi.exec(
400                      "tmux", [...tmuxBase, "source-file", tmuxConfig]
401                  );
402                  if (srcCode !== 0 && sessionExists) {
403                      // Only warn if we know the server is running (otherwise failure
404                      // just means "no server" which is expected and fine)
405                      ctx.ui.notify(
406                          `tmux config warning: ${srcStderr || "source-file failed"}`,
407                          "warning"
408                      );
409                  }
410              }
411  
412              if (sessionExists) {
413                  // Check for duplicate window name
414                  const { code, stdout } = await pi.exec("tmux", [
415                      ...tmuxBase,
416                      "list-windows", "-t", TMUX_SESSION, "-F", "#{window_name}",
417                  ]);
418                  if (code !== 0) {
419                      ctx.ui.notify("Failed to list tmux windows — tmux may be in an unexpected state", "error");
420                      return;
421                  }
422                  const existingWindows = stdout.trim().split("\n").filter(Boolean);
423                  if (existingWindows.includes(windowName)) {
424                      ctx.ui.notify(
425                          `Window "${windowName}" already exists in tmux session "${TMUX_SESSION}". ` +
426                              `Use: /roam <different-name>`,
427                          "error"
428                      );
429                      return;
430                  }
431              }
432  
433              // --- Tailscale (non-fatal, macOS only) ---
434  
435              if (process.platform === "darwin") {
436                  if (tailscaleAccount) {
437                      ctx.ui.notify(`Switching Tailscale account: ${tailscaleAccount}`, "info");
438                      try {
439                          const { code, stderr } = await pi.exec(
440                              tailscaleBinary,
441                              ["switch", tailscaleAccount],
442                              { timeout: TAILSCALE_TIMEOUT_MS }
443                          );
444                          if (code !== 0) {
445                              ctx.ui.notify(
446                                  `Tailscale switch warning: ${stderr || "switch command failed"}`,
447                                  "warning"
448                              );
449                          }
450                      } catch {
451                          ctx.ui.notify("Tailscale switch unavailable — continuing", "warning");
452                      }
453                  }
454  
455                  ctx.ui.notify("Bringing up Tailscale...", "info");
456                  try {
457                      const { code, stderr } = await pi.exec(tailscaleBinary, ["up"], {
458                          timeout: TAILSCALE_TIMEOUT_MS,
459                      });
460                      if (code !== 0) {
461                          ctx.ui.notify(`Tailscale warning: ${stderr || "failed to start"}`, "warning");
462                      }
463                  } catch {
464                      ctx.ui.notify("Tailscale not available — continuing without it", "warning");
465                  }
466              }
467  
468              // --- Fork session ---
469              // waitForIdle() above ensures the agent has finished streaming. Pi persists
470              // entries via synchronous appendFileSync, so by the time waitForIdle() resolves
471              // and the command handler runs, all entries should be flushed to disk.
472  
473              let destSessionFile: string;
474              try {
475                  const forked = SessionManager.forkFrom(sourceSessionFile, cwd);
476                  const dest = forked.getSessionFile();
477                  if (!dest) {
478                      ctx.ui.notify("Fork produced no session file", "error");
479                      return;
480                  }
481                  destSessionFile = dest;
482              } catch (error: any) {
483                  ctx.ui.notify(`Failed to fork session: ${error?.message ?? String(error)}`, "error");
484                  return;
485              }
486  
487              // Remove parentSession pointer since we intend to trash the original.
488              // A dangling parentSession would break session_lineage and session_ask.
489              try {
490                  clearParentSession(destSessionFile);
491              } catch (error: any) {
492                  // Non-fatal: the session still works, just has a dangling parentSession
493                  ctx.ui.notify(
494                      `Warning: could not clear parent session reference: ${error?.message ?? String(error)}`,
495                      "warning"
496                  );
497              }
498  
499              // --- Create tmux window if session already exists ---
500              // (must happen before terminal teardown so pi.exec still works)
501  
502              let tmuxArgs: string[];
503  
504              if (sessionExists) {
505                  // Add a new window to the existing "pi" session
506                  const { code, stderr, stdout } = await pi.exec("tmux", [
507                      ...tmuxBase,
508                      "new-window", "-t", TMUX_SESSION, "-n", windowName, "-c", cwd,
509                      "pi", "--session", destSessionFile,
510                  ]);
511                  if (code !== 0) {
512                      ctx.ui.notify(
513                          `Failed to create tmux window: ${stderr || stdout || "unknown error"}`,
514                          "error"
515                      );
516                      return;
517                  }
518                  // Attach to the session (new window is now current)
519                  tmuxArgs = [...tmuxBase, "-u", "attach", "-t", TMUX_SESSION];
520              } else {
521                  // Create new session with first window (attaches automatically)
522                  tmuxArgs = [
523                      ...tmuxBase, "-u", "new-session",
524                      "-s", TMUX_SESSION, "-n", windowName, "-c", cwd,
525                      "--", "pi", "--session", destSessionFile,
526                  ];
527              }
528  
529              // --- Tear down parent terminal ---
530  
531              process.stdout.write("\x1b[<u");      // Pop kitty keyboard protocol
532              process.stdout.write("\x1b[?2004l");  // Disable bracketed paste
533              process.stdout.write("\x1b[?25h");    // Show cursor
534              process.stdout.write("\r\n");
535  
536              if (process.stdin.isTTY && process.stdin.setRawMode) {
537                  process.stdin.setRawMode(false);
538              }
539  
540              // --- Spawn tmux ---
541  
542              const child = spawn("tmux", tmuxArgs, { stdio: "inherit" });
543  
544              child.once("spawn", () => {
545                  // Trash the original session file to prevent duplicates in /resume,
546                  // but only if it's in the standard Pi sessions directory. Custom
547                  // --session paths should not be trashed as that would be surprising.
548                  if (isInStandardSessionsDir(sourceSessionFile)) {
549                      void trashFileBestEffort(sourceSessionFile).then((trashed) => {
550                          if (!trashed) {
551                              process.stderr.write(
552                                  `\nNote: Could not trash original session file. Remove manually:\n  ${sourceSessionFile}\n`
553                              );
554                          }
555                      });
556                  } else {
557                      process.stderr.write(
558                          `\nNote: Session file is at a custom path and was not trashed:\n  ${sourceSessionFile}\n` +
559                              `The roamed session in tmux is independent. You may see duplicates in /resume.\n`
560                      );
561                  }
562  
563                  // Stop the parent from stealing keypresses
564                  process.stdin.removeAllListeners();
565                  process.stdin.destroy();
566  
567                  // Parent should not react to signals
568                  process.removeAllListeners("SIGINT");
569                  process.removeAllListeners("SIGTERM");
570                  process.on("SIGINT", () => {});
571                  process.on("SIGTERM", () => {});
572              });
573  
574              child.on("exit", (code) => process.exit(code ?? 0));
575              child.on("error", (err) => {
576                  process.stderr.write(`Failed to launch tmux: ${err.message}\n`);
577                  process.exit(1);
578              });
579          },
580      });
581  }