/ extensions / move-session.ts
move-session.ts
  1  /**
  2   * Session Move Extension
  3   *
  4   * Move the current session into another cwd bucket and relaunch pi in that directory
  5   *
  6   * Implementation strategy:
  7   *  1) Fork the current session file into the target cwd bucket using SessionManager.forkFrom()
  8   *  2) Clear the fork header's parentSession pointer
  9   *  3) Tear down the parent's terminal usage (pop kitty protocol, reset modes)
 10   *  4) Spawn a new pi process in the target cwd with inherited stdio
 11   *  5) Once the child has spawned, trash the old session file
 12   *  6) Once the child has spawned, destroy the parent's stdin so it cannot steal key presses
 13   *  7) Parent stays alive as an inert wrapper, forwarding the child's exit code
 14   *
 15   * Usage:
 16   *   /move-session <targetCwd>
 17   */
 18  
 19  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
 20  import { SessionManager } from "@mariozechner/pi-coding-agent";
 21  import { spawn } from "node:child_process";
 22  import {
 23      statSync,
 24      openSync,
 25      readSync,
 26      writeSync,
 27      closeSync,
 28      renameSync,
 29      unlinkSync,
 30  } from "node:fs";
 31  
 32  import { normalizeTargetCwd } from "./_shared/normalize-target-cwd";
 33  
 34  const TRASH_TIMEOUT_MS = 5000;
 35  const HEADER_READ_MAX = 8192;
 36  const COPY_CHUNK_SIZE = 65_536;
 37  
 38  function getBranchSelectionWarning(
 39      sourceSessionFile: string,
 40      currentLeafId: string | null,
 41      commandName: string,
 42      actionName: string,
 43  ): string | null {
 44      try {
 45          const persistedSession = SessionManager.open(sourceSessionFile);
 46          const hasPersistedEntries = persistedSession.getEntries().length > 0;
 47          if (!hasPersistedEntries) {
 48              return null;
 49          }
 50  
 51          const persistedLeafId = persistedSession.getLeafId() as string | null;
 52          if (currentLeafId === null) {
 53              return `${commandName} will not preserve the current /tree root selection. It reopens at the session file's default branch tip. Consider /fork first or continue from the branch tip before ${actionName}.`;
 54          }
 55  
 56          if (currentLeafId !== persistedLeafId) {
 57              return `${commandName} will not preserve the current /tree selection. It reopens at the session file's default branch tip. Consider /fork first or continue from the branch tip before ${actionName}.`;
 58          }
 59      } catch {
 60          // Ignore warning-detection failures
 61      }
 62  
 63      return null;
 64  }
 65  
 66  /**
 67   * Remove parentSession from the first JSONL header line without loading
 68   * the entire file into memory
 69   */
 70  function clearParentSession(sessionFile: string): void {
 71      const fd = openSync(sessionFile, "r");
 72      const headerBuffer = Buffer.alloc(HEADER_READ_MAX);
 73      const bytesRead = readSync(fd, headerBuffer, 0, HEADER_READ_MAX, 0);
 74      const headerChunk = headerBuffer.toString("utf-8", 0, bytesRead);
 75      const newlineIndex = headerChunk.indexOf("\n");
 76  
 77      if (newlineIndex === -1) {
 78          closeSync(fd);
 79          return;
 80      }
 81  
 82      const header = JSON.parse(headerChunk.slice(0, newlineIndex));
 83      if (!header.parentSession) {
 84          closeSync(fd);
 85          return;
 86      }
 87  
 88      delete header.parentSession;
 89      const newHeaderLine = JSON.stringify(header) + "\n";
 90      const originalHeaderBytes = Buffer.byteLength(headerChunk.slice(0, newlineIndex + 1), "utf-8");
 91  
 92      const temporaryPath = sessionFile + ".move-session-tmp";
 93      let writeFd: number | undefined;
 94      try {
 95          writeFd = openSync(temporaryPath, "w");
 96          const newHeaderBuffer = Buffer.from(newHeaderLine, "utf-8");
 97          writeSync(writeFd, newHeaderBuffer, 0, newHeaderBuffer.length);
 98  
 99          const copyBuffer = Buffer.alloc(COPY_CHUNK_SIZE);
100          let position = originalHeaderBytes;
101          while (true) {
102              const readCount = readSync(fd, copyBuffer, 0, COPY_CHUNK_SIZE, position);
103              if (readCount === 0) break;
104              writeSync(writeFd, copyBuffer, 0, readCount);
105              position += readCount;
106          }
107  
108          closeSync(writeFd);
109          writeFd = undefined;
110          closeSync(fd);
111          renameSync(temporaryPath, sessionFile);
112      } catch (error) {
113          if (writeFd !== undefined) {
114              try {
115                  closeSync(writeFd);
116              } catch {
117                  // ignore cleanup close errors
118              }
119          }
120  
121          closeSync(fd);
122          try {
123              unlinkSync(temporaryPath);
124          } catch {
125              // ignore cleanup unlink errors
126          }
127          throw error;
128      }
129  }
130  
131  export default function (pi: ExtensionAPI) {
132      const trashFileBestEffort = async (filePath: string) => {
133          try {
134              const { code } = await pi.exec("trash", [filePath], { timeout: TRASH_TIMEOUT_MS });
135              if (code === 0) {
136                  return;
137              }
138          } catch {
139              // ignore
140          }
141  
142          // If "trash" isn't available, do not fall back to unlink.
143          // This extension should never permanently delete session files.
144      };
145  
146      pi.registerCommand("move-session", {
147          description: "Move session to another directory and relaunch pi there",
148          handler: async (args, ctx) => {
149              await ctx.waitForIdle();
150  
151              const rawTargetCwd = args.trim();
152              if (!rawTargetCwd) {
153                  ctx.ui.notify("Usage: /move-session <targetCwd>", "error");
154                  return;
155              }
156  
157              let targetCwd: string;
158              try {
159                  targetCwd = normalizeTargetCwd(rawTargetCwd);
160              } catch (error: any) {
161                  ctx.ui.notify(error?.message ?? String(error), "error");
162                  return;
163              }
164  
165              let targetCwdStat;
166              try {
167                  targetCwdStat = statSync(targetCwd);
168              } catch (error: any) {
169                  const code = error?.code;
170                  if (code === "ENOENT") {
171                      ctx.ui.notify(`Path does not exist: ${targetCwd}`, "error");
172                  } else {
173                      ctx.ui.notify(`Cannot access path: ${targetCwd}`, "error");
174                  }
175                  return;
176              }
177  
178              if (!targetCwdStat.isDirectory()) {
179                  ctx.ui.notify(`Not a directory: ${targetCwd}`, "error");
180                  return;
181              }
182  
183              const sourceSessionFile = ctx.sessionManager.getSessionFile();
184              if (!sourceSessionFile) {
185                  ctx.ui.notify("No persistent session file (maybe started with --no-session)", "error");
186                  return;
187              }
188  
189              const branchSelectionWarning = getBranchSelectionWarning(
190                  sourceSessionFile,
191                  (ctx.sessionManager.getLeafId?.() as string | null) ?? null,
192                  "/move-session",
193                  "moving",
194              );
195              if (branchSelectionWarning) {
196                  ctx.ui.notify(branchSelectionWarning, "warning");
197              }
198  
199              try {
200                  const forked = SessionManager.forkFrom(sourceSessionFile, targetCwd);
201                  const destSessionFile = forked.getSessionFile();
202  
203                  if (!destSessionFile) {
204                      ctx.ui.notify("Internal error: forkFrom() produced no session file", "error");
205                      return;
206                  }
207  
208                  // We intend to move/replace the original session, so avoid leaving
209                  // a parentSession pointer that may dangle after trashing the source.
210                  try {
211                      clearParentSession(destSessionFile);
212                  } catch (error: any) {
213                      ctx.ui.notify(
214                          `Warning: could not clear parent session reference: ${error?.message ?? String(error)}`,
215                          "warning"
216                      );
217                  }
218  
219                  // --- Tear down the parent's terminal usage ---
220                  // We do this BEFORE spawning, to avoid nesting Kitty protocol flags.
221                  process.stdout.write("\x1b[<u");      // Pop kitty keyboard protocol
222                  process.stdout.write("\x1b[?2004l");  // Disable bracketed paste
223                  process.stdout.write("\x1b[?25h");    // Show cursor
224                  process.stdout.write("\r\n");         // Ensure child starts on a clean line
225  
226                  if (process.stdin.isTTY && process.stdin.setRawMode) {
227                      process.stdin.setRawMode(false);
228                  }
229  
230                  // Spawn new pi in the target directory
231                  const child = spawn("pi", ["--session", destSessionFile], {
232                      cwd: targetCwd,
233                      stdio: "inherit",
234                  });
235  
236                  child.once("spawn", () => {
237                      // Trash the old session file *after* the new process is actually running
238                      void trashFileBestEffort(sourceSessionFile);
239  
240                      // Stop the parent from stealing keypresses.
241                      // destroy() is important; pause() was not sufficient.
242                      process.stdin.removeAllListeners();
243                      process.stdin.destroy();
244  
245                      // Avoid the parent reacting to Ctrl+C / termination signals.
246                      // (The child is the process the user is interacting with.)
247                      process.removeAllListeners("SIGINT");
248                      process.removeAllListeners("SIGTERM");
249                      process.on("SIGINT", () => {});
250                      process.on("SIGTERM", () => {});
251                  });
252  
253                  child.on("exit", (code) => process.exit(code ?? 0));
254                  child.on("error", (err) => {
255                      process.stderr.write(`Failed to launch pi: ${err.message}\n`);
256                      process.exit(1);
257                  });
258              } catch (error: any) {
259                  ctx.ui.notify(`Failed to move session: ${error?.message ?? String(error)}`, "error");
260              }
261          },
262      });
263  }