/ extensions / fork-from-first.ts
fork-from-first.ts
  1  import { readFileSync } from "node:fs";
  2  import { access } from "node:fs/promises";
  3  import os from "node:os";
  4  import path from "node:path";
  5  
  6  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
  7  
  8  const AGENT_DIR = path.join(os.homedir(), ".pi", "agent");
  9  
 10  const REWIND_EXTENSION_DIR = path.join(AGENT_DIR, "extensions", "rewind");
 11  const REWIND_EXTENSION_CANDIDATES = [
 12    "index.ts",
 13    "index.js",
 14    path.join("dist", "index.js"),
 15    path.join("build", "index.js"),
 16    "package.json",
 17  ];
 18  
 19  interface RewindForkPendingData {
 20    v: 2;
 21    current: string;
 22    undo?: string;
 23  }
 24  
 25  async function isRewindInstalled(): Promise<boolean> {
 26    try {
 27      await access(REWIND_EXTENSION_DIR);
 28    } catch {
 29      return false;
 30    }
 31  
 32    for (const relPath of REWIND_EXTENSION_CANDIDATES) {
 33      try {
 34        await access(path.join(REWIND_EXTENSION_DIR, relPath));
 35        return true;
 36      } catch {
 37        // keep looking
 38      }
 39    }
 40  
 41    return false;
 42  }
 43  
 44  function extractUserMessageText(content: string | Array<{ type: string; text?: string }>): string {
 45    if (typeof content === "string") {
 46      return content;
 47    }
 48  
 49    return content
 50      .filter((part): part is { type: "text"; text: string } => part.type === "text" && typeof part.text === "string")
 51      .map((part) => part.text)
 52      .join("");
 53  }
 54  
 55  function isRewindForkPendingData(value: unknown): value is RewindForkPendingData {
 56    if (!value || typeof value !== "object") {
 57      return false;
 58    }
 59  
 60    const data = value as Partial<RewindForkPendingData>;
 61    return data.v === 2 && typeof data.current === "string" && data.current.length > 0;
 62  }
 63  
 64  function loadLatestRewindForkPending(sessionFile: string): RewindForkPendingData | null {
 65    const raw = readFileSync(sessionFile, "utf8");
 66    const lines = raw.split("\n");
 67  
 68    for (let index = lines.length - 1; index >= 0; index -= 1) {
 69      const line = lines[index]?.trim();
 70      if (!line) {
 71        continue;
 72      }
 73  
 74      try {
 75        const entry = JSON.parse(line) as {
 76          type?: string;
 77          customType?: string;
 78          data?: unknown;
 79        };
 80  
 81        if (
 82          entry.type === "custom" &&
 83          entry.customType === "rewind-fork-pending" &&
 84          isRewindForkPendingData(entry.data)
 85        ) {
 86          return entry.data;
 87        }
 88      } catch {
 89        // ignore malformed lines and keep scanning backward
 90      }
 91    }
 92  
 93    return null;
 94  }
 95  
 96  function seedChildRewindStateFromParent(
 97    parentSessionFile: string,
 98    sessionManager: { appendCustomEntry(customType: string, data?: unknown): string },
 99  ): void {
100    let pending: RewindForkPendingData | null = null;
101  
102    try {
103      pending = loadLatestRewindForkPending(parentSessionFile);
104    } catch {
105      return;
106    }
107  
108    if (!pending) {
109      return;
110    }
111  
112    const snapshots = [pending.current];
113    const data: { v: 2; snapshots: string[]; current: number; undo?: number } = {
114      v: 2,
115      snapshots,
116      current: 0,
117    };
118  
119    if (typeof pending.undo === "string" && pending.undo.length > 0) {
120      data.snapshots.push(pending.undo);
121      data.undo = 1;
122    }
123  
124    sessionManager.appendCustomEntry("rewind-op", data);
125  }
126  
127  async function requestConversationOnlyForkWhenRewindIsInstalled(pi: ExtensionAPI): Promise<boolean> {
128    if (!(await isRewindInstalled())) {
129      return false;
130    }
131  
132    pi.events.emit("rewind:fork-preference", {
133      mode: "conversation-only",
134      source: "fork-from-first",
135    });
136  
137    return true;
138  }
139  
140  export default function (pi: ExtensionAPI) {
141    pi.registerCommand("fork-from-first", {
142      description: "Fork current session from its first user message",
143      handler: async (_args, ctx) => {
144        await ctx.waitForIdle();
145  
146        const previousSessionFile = ctx.sessionManager.getSessionFile();
147        if (!previousSessionFile) {
148          if (ctx.hasUI) {
149            ctx.ui.notify("/fork-from-first requires a persisted session file", "error");
150          }
151          return;
152        }
153  
154        const firstUserEntry = ctx.sessionManager
155          .getEntries()
156          .find(
157            (entry) =>
158              entry.type === "message" &&
159              entry.message?.role === "user"
160          );
161  
162        if (!firstUserEntry) {
163          if (ctx.hasUI) {
164            ctx.ui.notify("No user message found to fork from", "warning");
165          }
166          return;
167        }
168  
169        const selectedText = extractUserMessageText(firstUserEntry.message.content);
170        const rewindInstalled = await requestConversationOnlyForkWhenRewindIsInstalled(pi);
171        if (ctx.hasUI && rewindInstalled) {
172          ctx.ui.notify("Rewind detected: forcing conversation-only fork", "info");
173        }
174  
175        const result = await ctx.newSession({
176          parentSession: previousSessionFile,
177          setup: async (sessionManager) => {
178            if (!rewindInstalled) {
179              return;
180            }
181  
182            seedChildRewindStateFromParent(previousSessionFile, sessionManager);
183          },
184        });
185  
186        if (result.cancelled) {
187          if (ctx.hasUI) {
188            ctx.ui.notify("Fork cancelled", "warning");
189          }
190          return;
191        }
192  
193        if (ctx.hasUI) {
194          ctx.ui.setEditorText(selectedText);
195          ctx.ui.notify("Forked from first message and switched to new session", "info");
196        }
197      },
198    });
199  }