Parent directory

index.ts

5031 bytes
  1import type {
  2  ExtensionAPI,
  3  SessionEntry,
  4} from "@earendil-works/pi-coding-agent";
  5
  6const HANDOFF_AGENT_START_TIMEOUT_MS = 30_000;
  7const HANDOFF_CONTEXT_INSTRUCTIONS =
  8  "If the handoff contains specific instructions to reingest context, use available session or tree inspection capabilities to recover the needed context from that branch, then resume.";
  9
 10function buildContinueFromHandoffPrompt(
 11  sessionFile: string | undefined,
 12  sourceLeafEntryId: string,
 13): string {
 14  const sourceBranchInstructions =
 15    sessionFile === undefined
 16      ? `The source branch ends at session tree entry ${JSON.stringify(sourceLeafEntryId)}. ${HANDOFF_CONTEXT_INSTRUCTIONS}`
 17      : `The source branch ends at session tree entry ${JSON.stringify(sourceLeafEntryId)} in ${JSON.stringify(sessionFile)}.  ${HANDOFF_CONTEXT_INSTRUCTIONS}`;
 18
 19  return `Open the handoff document identified in the branch summary. Resume the work by performing its next unfinished step.\n\n${sourceBranchInstructions}`;
 20}
 21
 22function findFirstUserMessageEntryId(
 23  entries: readonly SessionEntry[],
 24): string | undefined {
 25  for (const entry of entries) {
 26    if (entry.type === "message" && entry.message.role === "user") {
 27      return entry.id;
 28    }
 29  }
 30  return undefined;
 31}
 32
 33function buildHandoffSkillCommand(focus: string): string {
 34  return focus.length === 0 ? "/skill:handoff" : `/skill:handoff ${focus}`;
 35}
 36
 37function buildBranchSummaryInstructions(focus: string): string {
 38  const focusInstruction =
 39    focus.length === 0 ? "" : ` The next turn's focus is: ${focus}`;
 40
 41  return `The source branch produced a handoff document. Include its exact absolute path so the next turn can open it. Keep the document as the source of truth; use the branch summary to orient the next turn toward continuing the work.${focusInstruction}`;
 42}
 43
 44/** Registers `/handoff`, which writes a handoff document and continues from it on a summarized root branch. */
 45export default function registerHandoffExtension(pi: ExtensionAPI): void {
 46  const agentStartWaiters = new Set<() => void>();
 47  let handoffInProgress = false;
 48
 49  pi.on("agent_start", () => {
 50    for (const resolveAgentStart of agentStartWaiters) {
 51      resolveAgentStart();
 52    }
 53    agentStartWaiters.clear();
 54  });
 55
 56  const waitForNextAgentStart = (): Promise<void> =>
 57    new Promise((resolve, reject) => {
 58      const resolveAgentStart = (): void => {
 59        clearTimeout(timeout);
 60        resolve();
 61      };
 62      const timeout = setTimeout(() => {
 63        agentStartWaiters.delete(resolveAgentStart);
 64        reject(new Error("Handoff agent turn did not start within 30 seconds"));
 65      }, HANDOFF_AGENT_START_TIMEOUT_MS);
 66
 67      agentStartWaiters.add(resolveAgentStart);
 68    });
 69
 70  pi.registerCommand("handoff", {
 71    description:
 72      "Write a handoff document, summarize back to the first message, and continue",
 73    handler: async (args, ctx) => {
 74      if (handoffInProgress) {
 75        ctx.ui.notify("A handoff is already in progress", "warning");
 76        return;
 77      }
 78      handoffInProgress = true;
 79
 80      try {
 81        await ctx.waitForIdle();
 82
 83        if (!ctx.model) {
 84          ctx.ui.notify("Handoff requires a selected model", "error");
 85          return;
 86        }
 87
 88        const providerAuth = await ctx.modelRegistry.getProviderAuth(
 89          ctx.model.provider,
 90        );
 91        if (!providerAuth) {
 92          ctx.ui.notify(
 93            "Handoff requires authentication for the selected model",
 94            "error",
 95          );
 96          return;
 97        }
 98
 99        const firstUserMessageEntryId = findFirstUserMessageEntryId(
100          ctx.sessionManager.getBranch(),
101        );
102        if (!firstUserMessageEntryId) {
103          ctx.ui.notify("There is no conversation to hand off", "warning");
104          return;
105        }
106
107        const focus = args.trim();
108        const agentStarted = waitForNextAgentStart();
109
110        pi.sendUserMessage(buildHandoffSkillCommand(focus), {
111          expandPromptTemplates: true,
112        });
113
114        await agentStarted;
115        await ctx.waitForIdle();
116
117        const sourceLeafEntryId = ctx.sessionManager.getLeafId();
118        if (!sourceLeafEntryId) {
119          ctx.ui.notify("Handoff source branch has no leaf entry", "error");
120          return;
121        }
122        const sessionFile = ctx.sessionManager.getSessionFile();
123
124        const navigation = await ctx.navigateTree(firstUserMessageEntryId, {
125          summarize: true,
126          customInstructions: buildBranchSummaryInstructions(focus),
127        });
128        if (navigation.cancelled) {
129          ctx.ui.notify("Handoff tree navigation was cancelled", "warning");
130          return;
131        }
132
133        ctx.ui.setEditorText("");
134        pi.sendUserMessage(
135          buildContinueFromHandoffPrompt(sessionFile, sourceLeafEntryId),
136        );
137      } catch (error) {
138        const message = error instanceof Error ? error.message : String(error);
139        ctx.ui.notify(`Handoff failed: ${message}`, "error");
140      } finally {
141        handoffInProgress = false;
142      }
143    },
144  });
145}