@ssweens/pi-handoff 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,7 +20,12 @@
20
20
  */
21
21
 
22
22
  import { complete, type Message } from "@mariozechner/pi-ai";
23
- import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@mariozechner/pi-coding-agent";
23
+ import type {
24
+ ExtensionAPI,
25
+ ExtensionCommandContext,
26
+ ExtensionContext,
27
+ SessionEntry,
28
+ } from "@mariozechner/pi-coding-agent";
24
29
  import { BorderedLoader, convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
25
30
  import { Type } from "@sinclair/typebox";
26
31
 
@@ -28,6 +33,11 @@ import { Type } from "@sinclair/typebox";
28
33
  // Key: parent session file path, Value: handoff text to set in editor
29
34
  const pendingHandoffText = new Map<string, string>();
30
35
 
36
+ /** @internal Test-only: clear all pending handoff state between tests. */
37
+ export function __clearPendingHandoffText(): void {
38
+ pendingHandoffText.clear();
39
+ }
40
+
31
41
  // Handoff generation system prompt.
32
42
  //
33
43
  // Combines Pi's structured compaction format (Goal, Progress, Decisions,
@@ -81,7 +91,7 @@ Rules:
81
91
 
82
92
  // System prompt fragment injected via before_agent_start.
83
93
  // Teaches the model about handoffs so it can suggest them proactively.
84
- const HANDOFF_SYSTEM_HINT = `
94
+ export const HANDOFF_SYSTEM_HINT = `
85
95
  ## Handoff
86
96
 
87
97
  Use \`/handoff <goal>\` to transfer context to a new focused session.
@@ -89,9 +99,10 @@ Handoffs are especially effective after planning — clear the context and start
89
99
  At high context usage, suggest a handoff rather than losing important context.`;
90
100
 
91
101
  /**
92
- * Generate a session name from the goal (slug format)
102
+ * Generate a session name from the goal (slug format).
103
+ * Exported for testing.
93
104
  */
94
- function goalToSessionName(goal: string): string {
105
+ export function goalToSessionName(goal: string): string {
95
106
  return goal
96
107
  .toLowerCase()
97
108
  .replace(/[^a-z0-9\s-]/g, "")
@@ -100,13 +111,38 @@ function goalToSessionName(goal: string): string {
100
111
  .slice(0, 50);
101
112
  }
102
113
 
114
+ /**
115
+ * Build the full handoff prompt from goal, session file, and generated summary.
116
+ * Includes parent session reference and skill prefix when applicable.
117
+ * Exported for testing.
118
+ */
119
+ export function buildFullPrompt(
120
+ goal: string,
121
+ currentSessionFile: string | null,
122
+ summary: string,
123
+ ): string {
124
+ let fullPrompt = `# ${goal}\n\n`;
125
+
126
+ if (currentSessionFile) {
127
+ fullPrompt += `**Parent session:** \`${currentSessionFile}\`\n\n`;
128
+ }
129
+
130
+ fullPrompt += summary;
131
+
132
+ // Prepend session-query skill if parent session present
133
+ return /\*\*Parent session:\*\*/.test(fullPrompt)
134
+ ? `/skill:pi-session-query ${fullPrompt}`
135
+ : fullPrompt;
136
+ }
137
+
103
138
  /**
104
139
  * Handoff modes:
105
140
  * - "command": User-initiated via /handoff
106
141
  * - "tool": Agent-initiated via handoff tool
107
142
  * - "compactHook": Triggered from session_before_compact
108
143
  *
109
- * All modes follow the same flow: generate summary → editor review → new session → input box → user sends
144
+ * Command mode has ExtensionCommandContext (with newSession).
145
+ * Tool and compactHook modes have ExtensionContext (ReadonlySessionManager, no newSession).
110
146
  */
111
147
  type HandoffMode = "command" | "tool" | "compactHook";
112
148
 
@@ -115,6 +151,13 @@ type HandoffMode = "command" | "tool" | "compactHook";
115
151
  * and the auto-handoff compaction hook.
116
152
  *
117
153
  * Returns an error string on failure, or undefined on success.
154
+ *
155
+ * Session creation behavior:
156
+ * - "command" mode: ctx has newSession() — creates new session immediately.
157
+ * - "tool"/"compactHook" mode: ctx is ReadonlySessionManager — cannot create
158
+ * sessions. Instead, pre-fills the editor with the generated prompt and notifies
159
+ * the user. The session_switch handler picks up pendingHandoffText when they
160
+ * manually start a new session.
118
161
  */
119
162
  async function performHandoff(
120
163
  pi: ExtensionAPI,
@@ -201,48 +244,46 @@ async function performHandoff(
201
244
  return "Handoff cancelled.";
202
245
  }
203
246
 
204
- // Build the full prompt with parent reference
205
- let fullPrompt = `# ${goal}\n\n`;
206
-
207
- if (currentSessionFile) {
208
- fullPrompt += `**Parent session:** \`${currentSessionFile}\`\n\n`;
209
- }
210
-
211
- fullPrompt += result;
247
+ const messageToSend = buildFullPrompt(goal, currentSessionFile ?? null, result);
212
248
 
213
- // Prepend session-query skill if parent session present
214
- const messageToSend = /\*\*Parent session:\*\*/.test(fullPrompt)
215
- ? `/skill:pi-session-query ${fullPrompt}`
216
- : fullPrompt;
217
-
218
- // Store the handoff text for the session_switch event to pick up
219
- // We use the parent session file as key since that's what we pass to newSession
249
+ // Store the handoff text for the session_switch event to pick up.
250
+ // Key: parent session file (passed to newSession as parentSession).
220
251
  if (currentSessionFile) {
221
252
  pendingHandoffText.set(currentSessionFile, messageToSend);
222
253
  }
223
254
 
224
- // Create new session immediately
225
- // Use ctx.newSession if available (command mode), otherwise use sessionManager directly
226
- if ("newSession" in ctx && typeof ctx.newSession === "function") {
227
- const newSessionResult = await ctx.newSession({
228
- parentSession: currentSessionFile,
255
+ // Session creation: only possible with ExtensionCommandContext (command mode).
256
+ // Hook and tool modes have ReadonlySessionManager — newSession() does not exist.
257
+ // In those modes, pre-fill the editor so the user can start a new session manually.
258
+ const hasNewSession =
259
+ "newSession" in ctx && typeof (ctx as ExtensionCommandContext).newSession === "function";
260
+
261
+ if (hasNewSession) {
262
+ const cmdCtx = ctx as ExtensionCommandContext;
263
+ const newSessionResult = await cmdCtx.newSession({
264
+ parentSession: currentSessionFile ?? undefined,
229
265
  });
230
266
 
231
267
  if (newSessionResult.cancelled) {
232
- // Clean up pending text if cancelled
268
+ // Clean up pending text if session creation was cancelled
233
269
  if (currentSessionFile) {
234
270
  pendingHandoffText.delete(currentSessionFile);
235
271
  }
236
272
  return "New session cancelled.";
237
273
  }
274
+
275
+ pi.setSessionName(goalToSessionName(goal));
238
276
  } else {
239
- // Tool/hook contexts: create session directly via session manager
240
- const sessionManager = ctx.sessionManager as any;
241
- sessionManager.newSession({ parentSession: currentSessionFile });
277
+ // Hook / tool mode: set editor text so the user can see the generated prompt.
278
+ // The session_switch handler will auto-set it in the new session when they
279
+ // start one (Ctrl+N or equivalent).
280
+ ctx.ui.setEditorText(messageToSend);
281
+ ctx.ui.notify(
282
+ "Handoff ready! Start a new session to automatically send the generated prompt.",
283
+ "info",
284
+ );
242
285
  }
243
286
 
244
- pi.setSessionName(goalToSessionName(goal));
245
-
246
287
  return undefined;
247
288
  }
248
289
 
@@ -291,7 +332,7 @@ export default function (pi: ExtensionAPI) {
291
332
  }
292
333
 
293
334
  const usage = ctx.getContextUsage();
294
- const pctStr = usage ? `${Math.round(usage.percent)}%` : "high";
335
+ const pctStr = usage?.percent != null ? `${Math.round(usage.percent)}%` : "high";
295
336
 
296
337
  const choice = await ctx.ui.select(
297
338
  `Context is ${pctStr} full. What would you like to do?`,
@@ -313,9 +354,23 @@ export default function (pi: ExtensionAPI) {
313
354
  }
314
355
  contextForHandoff += `## Recent Conversation\n\n${conversationText}`;
315
356
 
316
- const error = await performHandoff(pi, ctx, "Continue current work", "compactHook", contextForHandoff);
317
- if (error) {
318
- ctx.ui.notify(`Handoff failed: ${error}. Compacting instead.`, "warning");
357
+ try {
358
+ const error = await performHandoff(
359
+ pi,
360
+ ctx,
361
+ "Continue current work",
362
+ "compactHook",
363
+ contextForHandoff,
364
+ );
365
+ if (error) {
366
+ ctx.ui.notify(`Handoff failed: ${error}. Compacting instead.`, "warning");
367
+ return;
368
+ }
369
+ } catch (err) {
370
+ ctx.ui.notify(
371
+ `Handoff error: ${err instanceof Error ? err.message : String(err)}. Compacting instead.`,
372
+ "warning",
373
+ );
319
374
  return;
320
375
  }
321
376
 
@@ -355,11 +410,12 @@ export default function (pi: ExtensionAPI) {
355
410
  content: [
356
411
  {
357
412
  type: "text" as const,
358
- text: error ?? "Handoff queued. Switching to a new session with the generated prompt.",
413
+ text:
414
+ error ??
415
+ "Handoff queued. The generated prompt has been placed in the editor — start a new session to send it.",
359
416
  },
360
417
  ],
361
418
  };
362
419
  },
363
420
  });
364
-
365
421
  }
package/package.json CHANGED
@@ -1,6 +1,10 @@
1
1
  {
2
2
  "name": "@ssweens/pi-handoff",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
+ "scripts": {
5
+ "test": "bun test tests/",
6
+ "test:watch": "bun test --watch tests/"
7
+ },
4
8
  "description": "Enhanced handoff extension for pi - context management for agentic coding workflows",
5
9
  "keywords": [
6
10
  "pi-package"