@ssweens/pi-handoff 1.1.1 → 1.3.0
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.
- package/extensions/handoff.ts +181 -44
- package/package.json +1 -1
- package/skills/pi-session-query/SKILL.md +3 -2
package/extensions/handoff.ts
CHANGED
|
@@ -13,21 +13,23 @@
|
|
|
13
13
|
* User presses Enter to send it.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
16
17
|
import { complete, type Message } from "@mariozechner/pi-ai";
|
|
17
18
|
import type {
|
|
18
19
|
ExtensionAPI,
|
|
19
20
|
ExtensionCommandContext,
|
|
20
21
|
ExtensionContext,
|
|
21
22
|
SessionEntry,
|
|
23
|
+
SessionHeader,
|
|
22
24
|
} from "@mariozechner/pi-coding-agent";
|
|
23
|
-
import { BorderedLoader, convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
|
|
25
|
+
import { BorderedLoader, buildSessionContext, convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
|
|
24
26
|
import { Type } from "@sinclair/typebox";
|
|
25
27
|
|
|
26
28
|
// ---------------------------------------------------------------------------
|
|
27
29
|
// System prompts
|
|
28
30
|
// ---------------------------------------------------------------------------
|
|
29
31
|
|
|
30
|
-
const SYSTEM_PROMPT = `You are a context transfer assistant. Read the conversation and produce a structured handoff summary for the stated goal. The new thread must be able to proceed without the old conversation.
|
|
32
|
+
export const SYSTEM_PROMPT = `You are a context transfer assistant. Read the conversation and produce a structured handoff summary for the stated goal. The new thread must be able to proceed without the old conversation.
|
|
31
33
|
|
|
32
34
|
Do NOT continue the conversation. Do NOT respond to any questions in the history. ONLY output the structured summary.
|
|
33
35
|
|
|
@@ -112,8 +114,25 @@ function extractFileOpsFromMessage(message: any, fileOps: FileOps): void {
|
|
|
112
114
|
}
|
|
113
115
|
}
|
|
114
116
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Collapsed file markers
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// File lists are shown as compact markers in the editor (e.g. "[📂 12 read files]")
|
|
121
|
+
// and expanded to full XML tags when the user submits via the input event hook.
|
|
122
|
+
|
|
123
|
+
/** Pending file lists keyed by marker text → expanded XML content. */
|
|
124
|
+
type FileMarkerStore = Map<string, string>;
|
|
125
|
+
|
|
126
|
+
function createReadMarker(count: number): string {
|
|
127
|
+
return `[+${count} read filename${count === 1 ? "" : "s"}]`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function createModifiedMarker(count: number): string {
|
|
131
|
+
return `[+${count} modified filename${count === 1 ? "" : "s"}]`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Build collapsed markers + expansion map from tool-call messages. */
|
|
135
|
+
function buildFileOperations(messages: any[]): { markers: string; expansions: FileMarkerStore } | null {
|
|
117
136
|
const fileOps = createFileOps();
|
|
118
137
|
for (const msg of messages) extractFileOpsFromMessage(msg, fileOps);
|
|
119
138
|
|
|
@@ -121,31 +140,50 @@ function appendFileOperations(summary: string, messages: any[]): string {
|
|
|
121
140
|
const readFiles = [...fileOps.read].filter((f) => !modified.has(f)).sort();
|
|
122
141
|
const modifiedFiles = [...modified].sort();
|
|
123
142
|
|
|
124
|
-
|
|
143
|
+
if (readFiles.length === 0 && modifiedFiles.length === 0) return null;
|
|
144
|
+
|
|
145
|
+
const expansions: FileMarkerStore = new Map();
|
|
146
|
+
const markerLines: string[] = [];
|
|
147
|
+
|
|
125
148
|
if (readFiles.length > 0) {
|
|
126
|
-
|
|
149
|
+
const marker = createReadMarker(readFiles.length);
|
|
150
|
+
expansions.set(marker, `<read-files>\n${readFiles.join("\n")}\n</read-files>`);
|
|
151
|
+
markerLines.push(marker);
|
|
127
152
|
}
|
|
128
153
|
if (modifiedFiles.length > 0) {
|
|
129
|
-
|
|
154
|
+
const marker = createModifiedMarker(modifiedFiles.length);
|
|
155
|
+
expansions.set(marker, `<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`);
|
|
156
|
+
markerLines.push(marker);
|
|
130
157
|
}
|
|
131
158
|
|
|
132
|
-
return
|
|
159
|
+
return { markers: markerLines.join("\n"), expansions };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Expand all file markers in text using the stored expansions. */
|
|
163
|
+
function expandFileMarkers(text: string, store: FileMarkerStore): string {
|
|
164
|
+
let result = text;
|
|
165
|
+
for (const [marker, expanded] of store) {
|
|
166
|
+
result = result.replaceAll(marker, expanded);
|
|
167
|
+
}
|
|
168
|
+
return result;
|
|
133
169
|
}
|
|
134
170
|
|
|
135
171
|
// ---------------------------------------------------------------------------
|
|
136
172
|
// Shared helpers
|
|
137
173
|
// ---------------------------------------------------------------------------
|
|
138
174
|
|
|
175
|
+
type HandoffResult = { type: "prompt"; text: string } | { type: "error"; message: string } | null;
|
|
176
|
+
|
|
139
177
|
/**
|
|
140
178
|
* Generate a handoff prompt via LLM with a loader UI.
|
|
141
|
-
* Returns
|
|
179
|
+
* Returns { type: "prompt", text } on success, { type: "error", message } on failure, or null if user cancelled.
|
|
142
180
|
*/
|
|
143
181
|
async function generateHandoffPrompt(
|
|
144
182
|
conversationText: string,
|
|
145
183
|
goal: string,
|
|
146
184
|
ctx: ExtensionContext,
|
|
147
|
-
): Promise<
|
|
148
|
-
return ctx.ui.custom<
|
|
185
|
+
): Promise<HandoffResult> {
|
|
186
|
+
return ctx.ui.custom<HandoffResult>((tui, theme, _kb, done) => {
|
|
149
187
|
const loader = new BorderedLoader(tui, theme, "Generating handoff prompt...");
|
|
150
188
|
loader.onAbort = () => done(null);
|
|
151
189
|
|
|
@@ -171,11 +209,11 @@ async function generateHandoffPrompt(
|
|
|
171
209
|
|
|
172
210
|
if (response.stopReason === "aborted") return null;
|
|
173
211
|
if (response.stopReason === "error") {
|
|
174
|
-
|
|
212
|
+
const msg =
|
|
175
213
|
"errorMessage" in response && typeof (response as any).errorMessage === "string"
|
|
176
214
|
? (response as any).errorMessage
|
|
177
|
-
: "
|
|
178
|
-
|
|
215
|
+
: "LLM request failed";
|
|
216
|
+
return { type: "error" as const, message: msg };
|
|
179
217
|
}
|
|
180
218
|
|
|
181
219
|
const text = response.content
|
|
@@ -184,14 +222,14 @@ async function generateHandoffPrompt(
|
|
|
184
222
|
.join("\n")
|
|
185
223
|
.trim();
|
|
186
224
|
|
|
187
|
-
return text.length > 0 ? text :
|
|
225
|
+
return text.length > 0 ? { type: "prompt" as const, text } : { type: "error" as const, message: "LLM returned empty response" };
|
|
188
226
|
};
|
|
189
227
|
|
|
190
228
|
run()
|
|
191
229
|
.then(done)
|
|
192
230
|
.catch((err) => {
|
|
193
|
-
|
|
194
|
-
done(
|
|
231
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
232
|
+
done({ type: "error" as const, message });
|
|
195
233
|
});
|
|
196
234
|
|
|
197
235
|
return loader;
|
|
@@ -203,24 +241,78 @@ async function generateHandoffPrompt(
|
|
|
203
241
|
* Returns serialized text + raw messages (for file op extraction), or null if empty.
|
|
204
242
|
*/
|
|
205
243
|
function gatherConversation(ctx: ExtensionContext): { text: string; messages: any[] } | null {
|
|
244
|
+
// Use buildSessionContext instead of raw getBranch so we only get what the
|
|
245
|
+
// agent actually sees: compaction summary + kept/recent messages.
|
|
246
|
+
// Raw getBranch returns the entire session history including messages that
|
|
247
|
+
// were already compacted away, which can exceed the model's context window.
|
|
206
248
|
const branch = ctx.sessionManager.getBranch();
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
.map((entry) => entry.message);
|
|
249
|
+
const leafId = ctx.sessionManager.getLeafId();
|
|
250
|
+
const { messages } = buildSessionContext(branch, leafId);
|
|
210
251
|
|
|
211
252
|
if (messages.length === 0) return null;
|
|
212
253
|
|
|
213
254
|
return { text: serializeConversation(convertToLlm(messages)), messages };
|
|
214
255
|
}
|
|
215
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Read a session file's header to extract parentSession.
|
|
259
|
+
* Only reads the first line (the header is always line 1 in a .jsonl session file).
|
|
260
|
+
*/
|
|
261
|
+
function getSessionHeader(sessionFile: string): SessionHeader | null {
|
|
262
|
+
try {
|
|
263
|
+
if (!existsSync(sessionFile)) return null;
|
|
264
|
+
const content = readFileSync(sessionFile, "utf-8");
|
|
265
|
+
const firstLine = content.slice(0, content.indexOf("\n")).trim();
|
|
266
|
+
if (!firstLine) return null;
|
|
267
|
+
const parsed = JSON.parse(firstLine);
|
|
268
|
+
return parsed.type === "session" ? parsed : null;
|
|
269
|
+
} catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Walk the session ancestry chain (parent → grandparent → …).
|
|
276
|
+
* Returns an ordered list of session file paths, starting with the immediate parent.
|
|
277
|
+
* Stops at the first missing/unreadable file or when there's no parentSession.
|
|
278
|
+
* Guards against cycles with a visited set.
|
|
279
|
+
*/
|
|
280
|
+
function getSessionAncestry(parentSessionFile: string): string[] {
|
|
281
|
+
const ancestry: string[] = [];
|
|
282
|
+
const visited = new Set<string>();
|
|
283
|
+
let current: string | undefined = parentSessionFile;
|
|
284
|
+
|
|
285
|
+
while (current && !visited.has(current)) {
|
|
286
|
+
visited.add(current);
|
|
287
|
+
ancestry.push(current);
|
|
288
|
+
const header = getSessionHeader(current);
|
|
289
|
+
current = header?.parentSession;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return ancestry;
|
|
293
|
+
}
|
|
294
|
+
|
|
216
295
|
/**
|
|
217
296
|
* Wrap a handoff prompt with the parent session reference and session-query skill.
|
|
218
|
-
*
|
|
297
|
+
* Includes the full ancestry chain so the new session can query any ancestor.
|
|
219
298
|
*/
|
|
220
299
|
function wrapWithParentSession(prompt: string, parentSessionFile: string | null): string {
|
|
221
300
|
if (!parentSessionFile) return prompt;
|
|
222
301
|
|
|
223
|
-
|
|
302
|
+
const ancestry = getSessionAncestry(parentSessionFile);
|
|
303
|
+
|
|
304
|
+
const lines = [`/skill:pi-session-query`, ""];
|
|
305
|
+
lines.push(`**Parent session:** \`${ancestry[0]}\``);
|
|
306
|
+
if (ancestry.length > 1) {
|
|
307
|
+
lines.push("");
|
|
308
|
+
lines.push(`**Ancestor sessions:**`);
|
|
309
|
+
for (let i = 1; i < ancestry.length; i++) {
|
|
310
|
+
lines.push(`- \`${ancestry[i]}\``);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
lines.push("");
|
|
314
|
+
|
|
315
|
+
return `${lines.join("\n")}${prompt}`;
|
|
224
316
|
}
|
|
225
317
|
|
|
226
318
|
// ---------------------------------------------------------------------------
|
|
@@ -250,6 +342,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
250
342
|
// Store prompt keyed by parent session for the session_switch handler.
|
|
251
343
|
const pendingHandoffText = new Map<string, string>();
|
|
252
344
|
|
|
345
|
+
// -- Collapsed file marker expansion state --------------------------------
|
|
346
|
+
// Stores marker→XML mappings so the input hook can expand them on submit.
|
|
347
|
+
let activeFileMarkers: FileMarkerStore = new Map();
|
|
348
|
+
|
|
253
349
|
// ── session_switch ──────────────────────────────────────────────────────
|
|
254
350
|
// Set editor text for command-path handoffs + clear context filter.
|
|
255
351
|
pi.on("session_switch", async (event, ctx) => {
|
|
@@ -283,6 +379,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
283
379
|
}
|
|
284
380
|
});
|
|
285
381
|
|
|
382
|
+
// ── input: expand collapsed file markers before LLM sees the text ───────
|
|
383
|
+
pi.on("input", (event) => {
|
|
384
|
+
if (activeFileMarkers.size === 0) return;
|
|
385
|
+
|
|
386
|
+
// Check if any markers are present in the input text
|
|
387
|
+
let hasMarkers = false;
|
|
388
|
+
for (const marker of activeFileMarkers.keys()) {
|
|
389
|
+
if (event.text.includes(marker)) {
|
|
390
|
+
hasMarkers = true;
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (!hasMarkers) return;
|
|
395
|
+
|
|
396
|
+
const expanded = expandFileMarkers(event.text, activeFileMarkers);
|
|
397
|
+
// Clear after first expansion — markers are single-use (one handoff prompt)
|
|
398
|
+
activeFileMarkers = new Map();
|
|
399
|
+
return { action: "transform" as const, text: expanded, images: event.images };
|
|
400
|
+
});
|
|
401
|
+
|
|
286
402
|
// ── agent_end: deferred session switch for tool path ────────────────────
|
|
287
403
|
pi.on("agent_end", (_event, ctx) => {
|
|
288
404
|
if (!pendingHandoff) return;
|
|
@@ -335,24 +451,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
335
451
|
contextForHandoff += `## Recent Conversation\n\n${conversationText}`;
|
|
336
452
|
|
|
337
453
|
// Generate handoff prompt
|
|
338
|
-
|
|
339
|
-
try {
|
|
340
|
-
prompt = await generateHandoffPrompt(contextForHandoff, "Continue current work", ctx);
|
|
341
|
-
} catch (err) {
|
|
342
|
-
ctx.ui.notify(
|
|
343
|
-
`Handoff failed: ${err instanceof Error ? err.message : String(err)}. Compacting instead.`,
|
|
344
|
-
"warning",
|
|
345
|
-
);
|
|
346
|
-
return;
|
|
347
|
-
}
|
|
454
|
+
const handoffResult = await generateHandoffPrompt(contextForHandoff, "Continue current work", ctx);
|
|
348
455
|
|
|
349
|
-
if (!
|
|
456
|
+
if (!handoffResult) {
|
|
350
457
|
ctx.ui.notify("Handoff cancelled. Compacting instead.", "warning");
|
|
351
458
|
return;
|
|
352
459
|
}
|
|
460
|
+
if (handoffResult.type === "error") {
|
|
461
|
+
ctx.ui.notify(`Handoff failed: ${handoffResult.message}. Compacting instead.`, "warning");
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
353
464
|
|
|
354
|
-
//
|
|
355
|
-
|
|
465
|
+
// Build collapsed file markers from the messages being summarized
|
|
466
|
+
const fileOps = buildFileOperations(preparation.messagesToSummarize);
|
|
467
|
+
let prompt = fileOps
|
|
468
|
+
? `${handoffResult.text}\n\n${fileOps.markers}`
|
|
469
|
+
: handoffResult.text;
|
|
356
470
|
|
|
357
471
|
// Switch session via raw sessionManager (safe — no agent loop running)
|
|
358
472
|
const currentSessionFile = ctx.sessionManager.getSessionFile();
|
|
@@ -372,6 +486,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
372
486
|
return;
|
|
373
487
|
}
|
|
374
488
|
|
|
489
|
+
// Activate markers for input hook expansion, then set editor text
|
|
490
|
+
if (fileOps) activeFileMarkers = fileOps.expansions;
|
|
375
491
|
ctx.ui.setEditorText(prompt);
|
|
376
492
|
ctx.ui.notify("Handoff ready — edit if needed, press Enter to send", "info");
|
|
377
493
|
|
|
@@ -399,14 +515,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
399
515
|
return;
|
|
400
516
|
}
|
|
401
517
|
|
|
402
|
-
|
|
403
|
-
if (!
|
|
518
|
+
const result = await generateHandoffPrompt(conv.text, goal, ctx);
|
|
519
|
+
if (!result) {
|
|
404
520
|
ctx.ui.notify("Handoff cancelled.", "info");
|
|
405
521
|
return;
|
|
406
522
|
}
|
|
523
|
+
if (result.type === "error") {
|
|
524
|
+
ctx.ui.notify(`Handoff failed: ${result.message}`, "error");
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
407
527
|
|
|
408
|
-
//
|
|
409
|
-
|
|
528
|
+
// Build collapsed file markers from tool calls
|
|
529
|
+
const fileOps = buildFileOperations(conv.messages);
|
|
530
|
+
let prompt = fileOps
|
|
531
|
+
? `${result.text}\n\n${fileOps.markers}`
|
|
532
|
+
: result.text;
|
|
410
533
|
|
|
411
534
|
const currentSessionFile = ctx.sessionManager.getSessionFile();
|
|
412
535
|
|
|
@@ -416,14 +539,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
416
539
|
if (currentSessionFile) {
|
|
417
540
|
pendingHandoffText.set(currentSessionFile, prompt);
|
|
418
541
|
}
|
|
542
|
+
// Stage markers — they'll be activated in session_switch after editor text is set
|
|
543
|
+
const pendingMarkers = fileOps?.expansions;
|
|
419
544
|
|
|
420
|
-
const
|
|
545
|
+
const sessionResult = await ctx.newSession({ parentSession: currentSessionFile ?? undefined });
|
|
421
546
|
|
|
422
|
-
if (
|
|
547
|
+
if (sessionResult.cancelled) {
|
|
423
548
|
if (currentSessionFile) pendingHandoffText.delete(currentSessionFile);
|
|
424
549
|
ctx.ui.notify("New session cancelled.", "info");
|
|
425
550
|
return;
|
|
426
551
|
}
|
|
552
|
+
|
|
553
|
+
// Activate markers for the new session's input hook
|
|
554
|
+
if (pendingMarkers) activeFileMarkers = pendingMarkers;
|
|
427
555
|
},
|
|
428
556
|
});
|
|
429
557
|
|
|
@@ -450,16 +578,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
450
578
|
return { content: [{ type: "text" as const, text: "No conversation to hand off." }] };
|
|
451
579
|
}
|
|
452
580
|
|
|
453
|
-
|
|
454
|
-
if (!
|
|
581
|
+
const result = await generateHandoffPrompt(conv.text, params.goal, ctx);
|
|
582
|
+
if (!result) {
|
|
455
583
|
return { content: [{ type: "text" as const, text: "Handoff cancelled." }] };
|
|
456
584
|
}
|
|
585
|
+
if (result.type === "error") {
|
|
586
|
+
return { content: [{ type: "text" as const, text: `Handoff failed: ${result.message}` }] };
|
|
587
|
+
}
|
|
457
588
|
|
|
458
|
-
|
|
589
|
+
const fileOps = buildFileOperations(conv.messages);
|
|
590
|
+
let prompt = fileOps
|
|
591
|
+
? `${result.text}\n\n${fileOps.markers}`
|
|
592
|
+
: result.text;
|
|
459
593
|
|
|
460
594
|
const currentSessionFile = ctx.sessionManager.getSessionFile();
|
|
461
595
|
prompt = wrapWithParentSession(prompt, currentSessionFile ?? null);
|
|
462
596
|
|
|
597
|
+
// Stage markers for activation after session switch
|
|
598
|
+
if (fileOps) activeFileMarkers = fileOps.expansions;
|
|
599
|
+
|
|
463
600
|
// Defer session switch to agent_end
|
|
464
601
|
pendingHandoff = {
|
|
465
602
|
prompt,
|
package/package.json
CHANGED
|
@@ -12,9 +12,9 @@ This skill is automatically invoked in handed-off sessions when you need to look
|
|
|
12
12
|
|
|
13
13
|
## When to Use
|
|
14
14
|
|
|
15
|
-
- When the handoff summary references a "Parent session" path
|
|
15
|
+
- When the handoff summary references a "Parent session" or "Ancestor sessions" path
|
|
16
16
|
- When you need specific details not included in the handoff summary
|
|
17
|
-
- When you need to verify a decision or approach from the parent session
|
|
17
|
+
- When you need to verify a decision or approach from the parent or an ancestor session
|
|
18
18
|
- When you need file paths or code snippets from earlier work
|
|
19
19
|
|
|
20
20
|
## Usage
|
|
@@ -51,6 +51,7 @@ session_query("/path/to/session.jsonl", "Summarize the key decisions made")
|
|
|
51
51
|
2. **Reference code** - Ask about specific files or functions when relevant
|
|
52
52
|
3. **Verify before assuming** - If the handoff summary seems incomplete, query for details
|
|
53
53
|
4. **Don't over-query** - The handoff summary should have most context; query only when needed
|
|
54
|
+
5. **Check ancestors** - If the parent session doesn't have the info, try ancestor sessions listed in the handoff
|
|
54
55
|
|
|
55
56
|
## How It Works
|
|
56
57
|
|