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}