@ssweens/pi-handoff 1.0.0 → 1.1.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/README.md CHANGED
@@ -6,62 +6,27 @@
6
6
  pi install @ssweens/pi-handoff
7
7
  ```
8
8
 
9
- Context handoff extension for [pi](https://github.com/badlogic/pi-mono). Transfer context to a new session with a structured summary — the agent can trigger handoffs, or they happen automatically on compaction.
9
+ Context handoff extension for [pi](https://github.com/badlogic/pi-mono). Transfer context to a new session with a structured summary — three entry points, one UX.
10
10
 
11
11
  ## Features
12
12
 
13
- - **User preview/editing**Review and edit the handoff draft before submission
14
- - **Agent-callable handoff tool** — The model can initiate handoffs when explicitly asked
15
- - **Auto-handoff on compaction** — Uses Pi's preparation data so summaries won't overflow
16
- - **Structured format** — Bullet list with code pointers (path:line or path#Symbol)
17
- - **Parent session query** — `session_query` tool for looking up details from parent sessions
18
- - **Auto-inject skill** — Detects `Parent session:` references and enables query instructions automatically
13
+ - **`/handoff <goal>`**User-initiated context transfer to a focused new session
14
+ - **Agent-callable tool** — The model can initiate handoffs when explicitly asked
15
+ - **Auto-handoff on compaction** — Offered as an alternative when context gets full
16
+ - **Parent session query** — `session_query` tool for looking up details from prior sessions
17
+ - **Programmatic file tracking** — Read/modified files extracted from tool calls (same as pi's compaction)
18
+ - **Structured format** — Aligned with pi's compaction format (Goal, Constraints, Progress, Key Decisions, Next Steps, Critical Context)
19
19
  - **System prompt hints** — The model knows about handoffs and suggests them proactively
20
- - **Session naming** — New sessions named based on handoff goal
21
20
 
22
21
  ## Installation
23
22
 
24
- ### From npm
25
-
26
23
  ```bash
27
24
  pi install @ssweens/pi-handoff
28
25
  ```
29
26
 
30
- ### From git (global)
31
-
32
- ```bash
33
- pi install git:github.com/ssweens/pi-handoff
34
- ```
35
-
36
- ### From git (project-local)
37
-
38
- ```bash
39
- pi install -l git:github.com/ssweens/pi-handoff
40
- ```
41
-
42
- ### Try without installing
43
-
44
- ```bash
45
- pi -e git:github.com/ssweens/pi-handoff
46
- ```
47
-
48
- ### From local path (development)
27
+ ## Usage
49
28
 
50
- Add to your settings (`~/.pi/agent/settings.json` or `.pi/settings.json`):
51
-
52
- ```json
53
- {
54
- "packages": [
55
- "/path/to/pi-handoff"
56
- ]
57
- }
58
- ```
59
-
60
- ## Features
61
-
62
- ### `/handoff <goal>` — Context Transfer
63
-
64
- When your conversation gets long or you want to branch off to a focused task:
29
+ ### `/handoff <goal>`
65
30
 
66
31
  ```
67
32
  /handoff now implement this for teams as well
@@ -69,155 +34,114 @@ When your conversation gets long or you want to branch off to a focused task:
69
34
  /handoff check other places that need this fix
70
35
  ```
71
36
 
72
- This:
73
- 1. Analyzes your current conversation
74
- 2. Generates a structured handoff summary with:
75
- - Key decisions and approaches (as bullet points)
76
- - Relevant files with code pointers
77
- - Clear task description based on your goal
78
- 3. Opens an editor for you to review/modify the draft
79
- 4. Creates a new session with parent tracking
80
- 5. Sets up the prompt ready to submit
37
+ **What happens:**
38
+ 1. LLM generates a structured handoff prompt from your conversation
39
+ 2. New session opens
40
+ 3. Prompt appears in the editor for review
41
+ 4. Press Enter to send — agent starts working
81
42
 
82
43
  ### Agent-Initiated Handoff
83
44
 
84
- The model can also create handoffs when you explicitly ask:
45
+ Ask the model directly:
85
46
 
86
47
  ```
87
- "Please hand this off to a new session to implement the fix"
88
- "Create a handoff to execute phase one"
48
+ "Please hand this off to a new session"
89
49
  ```
90
50
 
91
- The agent uses the `handoff` tool, which defers the handoff until after the current turn completes (so the tool result is properly recorded in the old session). In tool/hook contexts, it creates a new session file and rebases the active agent context to start at the handoff prompt, so the next turn runs with the handed-off context instead of the old oversized history.
92
-
93
- ### System Prompt Awareness
94
-
95
- The extension injects handoff awareness into the system prompt. The model knows:
96
- - `/handoff` exists and when to suggest it
97
- - Handoffs after planning sessions are especially effective — clear context and start fresh with the plan
98
- - At high context usage, it should suggest a handoff rather than losing context
51
+ The agent calls the `handoff` tool. Session switch is deferred until the current turn completes, then the same flow: new session prompt in editor press Enter.
99
52
 
100
53
  ### Auto-Handoff on Compaction
101
54
 
102
- When auto-compaction triggers (context exceeds the compaction threshold), the extension intercepts and offers a choice: **handoff to a new session** or **compact in place**.
55
+ When context gets full and auto-compaction triggers, you're offered a choice:
56
+
57
+ ```
58
+ Context is 92% full. What would you like to do?
59
+ > Handoff to new session
60
+ Compact context
61
+ Continue without either
62
+ ```
103
63
 
104
- If you choose handoff:
105
- 1. A summary is generated (same structured format as `/handoff`)
106
- 2. You review/edit the handoff prompt
107
- 3. A new session is created with the summary, old session preserved
108
- 4. The agent continues in the new session
64
+ Select "Handoff" → same flow: LLM generates prompt → new session → prompt in editor → press Enter. If you cancel or it fails, compaction proceeds as normal.
109
65
 
110
- If you decline, normal compaction proceeds as usual.
66
+ ### Querying Parent Sessions
111
67
 
112
- **Requires `compaction.enabled = true`** (the default). When auto-compaction is disabled, this hook never fires — use `/handoff` manually instead.
68
+ Handoff prompts include a parent session reference:
113
69
 
114
- ### `session_query` Tool — Cross-Session Context
70
+ ```
71
+ /skill:pi-session-query
115
72
 
116
- The model can query parent sessions for details not in the handoff summary:
73
+ **Parent session:** `/path/to/old-session.jsonl`
117
74
 
118
- ```typescript
119
- session_query("/path/to/parent/session.jsonl", "What files were modified?")
120
- session_query("/path/to/parent/session.jsonl", "What approach was chosen for authentication?")
75
+ ## Goal
76
+ ...
121
77
  ```
122
78
 
123
- **Auto-injection:** When a user message contains a `**Parent session:**` reference, the extension prepends `/skill:pi-session-query` inline with the prompt body (single-submit flow, no extra Enter round-trip). No manual directive needed in handoff prompts.
79
+ The `session_query` tool lets the model look up details from the parent session without loading the full conversation:
124
80
 
125
- **Size guard:** Large parent sessions are truncated (keeping the most recent context) to prevent exceeding context limits during the query.
81
+ ```typescript
82
+ session_query("/path/to/session.jsonl", "What files were modified?")
83
+ session_query("/path/to/session.jsonl", "What approach was chosen?")
84
+ ```
126
85
 
127
86
  ## Handoff Format
128
87
 
129
- Generated handoffs follow a structured format adapted from Pi's compaction system, filtered through the user's stated goal:
88
+ Aligned with pi's compaction format, with programmatic file tracking appended:
130
89
 
131
90
  ```markdown
132
- # <goal>
133
-
134
- **Parent session:** `/path/to/session.jsonl`
135
-
136
91
  ## Goal
137
- What the user wants to accomplish in the new thread.
138
-
139
- ## Key Decisions
140
- - **Decision 1**: Rationale (path/to/file.ts:42)
141
- - **Decision 2**: Rationale
92
+ What the user wants to accomplish.
142
93
 
143
94
  ## Constraints & Preferences
144
- - Requirements or preferences the user stated
95
+ - Requirements or preferences stated
145
96
 
146
97
  ## Progress
147
98
  ### Done
148
- - [x] Completed work relevant to the goal
99
+ - [x] Completed work
149
100
 
150
101
  ### In Progress
151
- - [ ] Partially completed work
102
+ - [ ] Current work
152
103
 
153
104
  ### Blocked
154
- - Open issues or blockers
105
+ - Open issues
155
106
 
156
- ## Files
157
- - path/to/file1.ts (modified)
158
- - path/to/file2.ts (read)
159
-
160
- ## Task
161
- Clear, actionable next steps based on the goal.
162
- ```
163
-
164
- The `/skill:pi-session-query` directive is auto-injected when this prompt is submitted (detected via the `**Parent session:**` marker).
165
-
166
- ## Architecture Comparison
167
-
168
- | Feature | pi-amplike | mina | pi-handoff |
169
- |---------|-----------|------|------------|
170
- | `/handoff` command | ✅ | ✅ | ✅ |
171
- | Agent-callable tool | ✅ | ❌ | ✅ |
172
- | User preview/edit | ❌ | ✅ | ✅ |
173
- | Auto-handoff on compact | ❌ | ❌ | ✅ |
174
- | Parent query tool | ✅ | ✅ | ✅ |
175
- | Structured bullets | ❌ | ✅ | ✅ |
176
- | Code pointers | ❌ | ✅ | ✅ |
177
- | Auto-detect parent ref | ❌ | ✅ | ✅ |
178
- | System prompt hints | ❌ | ✅ | ✅ |
179
- | Session naming | ❌ | ❌ | ✅ |
180
- | Query size guard | ❌ | ✅ | ✅ |
181
- | Deferred tool switch | ✅ | N/A | ✅ |
107
+ ## Key Decisions
108
+ - **Decision**: Rationale (path/to/file.ts:42)
182
109
 
183
- ## How It Differs from Compaction
110
+ ## Next Steps
111
+ 1. What should happen next
184
112
 
185
- | | Compaction (`/compact`) | Handoff (`/handoff`) | Auto-Handoff |
186
- |---|---|---|---|
187
- | **Purpose** | Reduce context size | Transfer to focused task | Context full → new session |
188
- | **Trigger** | Automatic or `/compact` | User types `/handoff` | Intercepts auto-compaction |
189
- | **Continues** | Same session | New session | New session |
190
- | **Context** | Lossy summary | Goal-directed summary | Goal-directed summary |
191
- | **Parent access** | Lost | Queryable via `session_query` | Queryable via `session_query` |
192
- | **Use case** | General context overflow | Task branching | Preserve old session on overflow |
113
+ ## Critical Context
114
+ - Data or references needed to continue
193
115
 
194
- ## Session Navigation
116
+ <read-files>
117
+ src/config.ts
118
+ </read-files>
195
119
 
196
- Use pi's built-in `/resume` command to switch between sessions. Handoff creates sessions with descriptive names based on your goal.
120
+ <modified-files>
121
+ src/handler.ts
122
+ src/auth.ts
123
+ </modified-files>
124
+ ```
197
125
 
198
126
  ## Components
199
127
 
200
128
  | Component | Type | Description |
201
129
  |-----------|------|-------------|
202
- | [handoff.ts](extensions/handoff.ts) | Extension | `/handoff` command, `handoff` tool, auto-handoff on compaction, system prompt hints |
203
- | [session-query.ts](extensions/session-query.ts) | Extension | `session_query` tool for the model (with size guard) |
204
- | [pi-session-query/SKILL.md](skills/pi-session-query/SKILL.md) | Skill | Instructions for using `session_query` |
205
-
206
- ## Configuration
130
+ | [handoff.ts](extensions/handoff.ts) | Extension | `/handoff` command, `handoff` tool, compact hook, system prompt hints |
131
+ | [session-query.ts](extensions/session-query.ts) | Extension | `session_query` tool for querying parent sessions |
132
+ | [pi-session-query/](skills/pi-session-query/SKILL.md) | Skill | Instructions for using `session_query` |
207
133
 
208
- No configuration required. The extension uses your current model for both handoff generation and session queries.
134
+ ## Architecture
209
135
 
210
- ### Optional: Dedicated Query Model
136
+ Three entry points, one outcome:
211
137
 
212
- To use a smaller/faster model for session queries (reducing cost), you can modify `session-query.ts` to use a different model:
138
+ | Entry Point | Context Type | Session Creation |
139
+ |-------------|-------------|-----------------|
140
+ | `/handoff` command | `ExtensionCommandContext` | `ctx.newSession()` (full reset) |
141
+ | `handoff` tool | `ExtensionContext` | Deferred to `agent_end` via raw `sessionManager.newSession()` |
142
+ | Compact hook | `ExtensionContext` | Raw `sessionManager.newSession()` (no agent loop running) |
213
143
 
214
- ```typescript
215
- // In session-query.ts execute function, replace:
216
- const model = ctx.model;
217
-
218
- // With a specific model lookup:
219
- const model = ctx.modelRegistry.find("anthropic", "claude-3-haiku") ?? ctx.model;
220
- ```
144
+ All three end the same way: prompt in editor of new session → user presses Enter → agent starts.
221
145
 
222
146
  ## License
223
147
 
@@ -1,44 +1,32 @@
1
1
  /**
2
2
  * Handoff Extension
3
3
  *
4
- * Transfers conversation context to a new focused session via:
5
- * - /handoff <goal> command
6
- * - Agent-callable handoff tool
7
- * - Auto-handoff option when Pi triggers compaction
4
+ * Transfers conversation context to a new focused session.
5
+ * Three entry points, one UX: generate prompt → new session → prompt in editor → user sends.
8
6
  *
9
- * The compaction hook uses Pi's preparation data (messagesToSummarize,
10
- * previousSummary) instead of the full conversation, so the summary
11
- * generation won't overflow the context window.
7
+ * Entry points:
8
+ * /handoff <goal> — user-initiated command
9
+ * handoff tool — agent-initiated (deferred to agent_end)
10
+ * session_before_compact — offered when context is full (deferred via raw sessionManager)
12
11
  *
13
- * Usage:
14
- * /handoff now implement this for teams as well
15
- * /handoff execute phase one of the plan
16
- * /handoff check other places that need this fix
17
- *
18
- * The generated prompt appears as a draft in the editor for review/editing.
19
- * The agent can also invoke the handoff tool when the user explicitly requests it.
12
+ * The generated prompt always lands in the editor of the new session for review.
13
+ * User presses Enter to send it.
20
14
  */
21
15
 
22
16
  import { complete, type Message } from "@mariozechner/pi-ai";
23
- import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@mariozechner/pi-coding-agent";
17
+ import type {
18
+ ExtensionAPI,
19
+ ExtensionCommandContext,
20
+ ExtensionContext,
21
+ SessionEntry,
22
+ } from "@mariozechner/pi-coding-agent";
24
23
  import { BorderedLoader, convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
25
24
  import { Type } from "@sinclair/typebox";
26
25
 
27
- // Store pending handoff text to be set in new session after switch
28
- // Key: parent session file path, Value: handoff text to set in editor
29
- const pendingHandoffText = new Map<string, string>();
30
-
31
- // Handoff generation system prompt.
32
- //
33
- // Combines Pi's structured compaction format (Goal, Progress, Decisions,
34
- // Constraints) with handoff-specific goal filtering, code pointers from
35
- // mina, and an explicit Task section.
36
- //
37
- // Key differences from Pi compaction:
38
- // - Goal-directed: everything is filtered through the user's stated goal
39
- // - Code pointers: path:line and path#Symbol references in context
40
- // - Task section: actionable next steps framed by the goal
41
- // - Anti-continuation guard: prevent the summarizer from responding to the history
26
+ // ---------------------------------------------------------------------------
27
+ // System prompts
28
+ // ---------------------------------------------------------------------------
29
+
42
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.
43
31
 
44
32
  Do NOT continue the conversation. Do NOT respond to any questions in the history. ONLY output the structured summary.
@@ -48,10 +36,6 @@ Use this EXACT format:
48
36
  ## Goal
49
37
  [The user's goal for the new thread — what they want to accomplish.]
50
38
 
51
- ## Key Decisions
52
- - **[Decision]**: [Brief rationale]
53
- - Use code pointers (path/to/file.ts:42 or path/to/file.ts#functionName) where relevant
54
-
55
39
  ## Constraints & Preferences
56
40
  - [Any requirements, constraints, or preferences the user stated]
57
41
  - [Or "(none)" if none were mentioned]
@@ -66,12 +50,16 @@ Use this EXACT format:
66
50
  ### Blocked
67
51
  - [Open issues or blockers, if any]
68
52
 
69
- ## Files
70
- - path/to/file1.ts (modified)
71
- - path/to/file2.ts (read)
53
+ ## Key Decisions
54
+ - **[Decision]**: [Brief rationale]
55
+ - Use code pointers (path/to/file.ts:42 or path/to/file.ts#functionName) where relevant
56
+
57
+ ## Next Steps
58
+ 1. [Ordered list of what should happen next, filtered by the stated goal]
72
59
 
73
- ## Task
74
- [Clear, actionable description of what to do next based on the goal. Ordered steps if appropriate.]
60
+ ## Critical Context
61
+ - [Any data, examples, or references needed to continue]
62
+ - [Or "(none)" if not applicable]
75
63
 
76
64
  Rules:
77
65
  - Be concise. Every bullet earns its place.
@@ -79,85 +67,89 @@ Rules:
79
67
  - Only include information relevant to the stated goal — discard unrelated context.
80
68
  - Output the formatted content only. No preamble, no filler.`;
81
69
 
82
- // System prompt fragment injected via before_agent_start.
83
- // Teaches the model about handoffs so it can suggest them proactively.
84
- const HANDOFF_SYSTEM_HINT = `
70
+ export const HANDOFF_SYSTEM_HINT = `
85
71
  ## Handoff
86
72
 
87
73
  Use \`/handoff <goal>\` to transfer context to a new focused session.
88
74
  Handoffs are especially effective after planning — clear the context and start a new session with the plan you just created.
89
75
  At high context usage, suggest a handoff rather than losing important context.`;
90
76
 
91
- /**
92
- * Generate a session name from the goal (slug format)
93
- */
94
- function goalToSessionName(goal: string): string {
95
- return goal
96
- .toLowerCase()
97
- .replace(/[^a-z0-9\s-]/g, "")
98
- .trim()
99
- .replace(/\s+/g, "-")
100
- .slice(0, 50);
101
- }
77
+ // ---------------------------------------------------------------------------
78
+ // File operation tracking (mirrors pi's compaction/utils.ts approach)
79
+ // ---------------------------------------------------------------------------
102
80
 
103
- /**
104
- * Handoff modes:
105
- * - "command": User-initiated via /handoff
106
- * - "tool": Agent-initiated via handoff tool
107
- * - "compactHook": Triggered from session_before_compact
108
- *
109
- * All modes follow the same flow: generate summary → editor review → new session → input box → user sends
110
- */
111
- type HandoffMode = "command" | "tool" | "compactHook";
81
+ interface FileOps {
82
+ read: Set<string>;
83
+ written: Set<string>;
84
+ edited: Set<string>;
85
+ }
112
86
 
113
- /**
114
- * Core handoff logic shared by the /handoff command, the handoff tool,
115
- * and the auto-handoff compaction hook.
116
- *
117
- * Returns an error string on failure, or undefined on success.
118
- */
119
- async function performHandoff(
120
- pi: ExtensionAPI,
121
- ctx: ExtensionContext,
122
- goal: string,
123
- mode: HandoffMode = "command",
124
- preBuiltContext?: string,
125
- ): Promise<string | undefined> {
126
- if (!ctx.hasUI) {
127
- return "Handoff requires interactive mode.";
128
- }
87
+ function createFileOps(): FileOps {
88
+ return { read: new Set(), written: new Set(), edited: new Set() };
89
+ }
129
90
 
130
- if (!ctx.model) {
131
- return "No model selected.";
91
+ /** Extract file paths from tool calls in assistant messages. */
92
+ function extractFileOpsFromMessage(message: any, fileOps: FileOps): void {
93
+ if (message.role !== "assistant") return;
94
+ if (!Array.isArray(message.content)) return;
95
+
96
+ for (const block of message.content) {
97
+ if (block?.type !== "toolCall" || !block.arguments || !block.name) continue;
98
+ const path = typeof block.arguments.path === "string" ? block.arguments.path : undefined;
99
+ if (!path) continue;
100
+
101
+ switch (block.name) {
102
+ case "read":
103
+ fileOps.read.add(path);
104
+ break;
105
+ case "write":
106
+ fileOps.written.add(path);
107
+ break;
108
+ case "edit":
109
+ fileOps.edited.add(path);
110
+ break;
111
+ }
132
112
  }
113
+ }
133
114
 
134
- let conversationText: string;
135
-
136
- if (preBuiltContext) {
137
- // compactHook: context already built from preparation data
138
- conversationText = preBuiltContext;
139
- } else {
140
- // command/tool: gather full conversation (context isn't full yet)
141
- const branch = ctx.sessionManager.getBranch();
142
- const messages = branch
143
- .filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
144
- .map((entry) => entry.message);
115
+ /** Compute read-only and modified file lists, append to summary as XML tags. */
116
+ function appendFileOperations(summary: string, messages: any[]): string {
117
+ const fileOps = createFileOps();
118
+ for (const msg of messages) extractFileOpsFromMessage(msg, fileOps);
145
119
 
146
- if (messages.length === 0) {
147
- return "No conversation to hand off.";
148
- }
120
+ const modified = new Set([...fileOps.edited, ...fileOps.written]);
121
+ const readFiles = [...fileOps.read].filter((f) => !modified.has(f)).sort();
122
+ const modifiedFiles = [...modified].sort();
149
123
 
150
- conversationText = serializeConversation(convertToLlm(messages));
124
+ const sections: string[] = [];
125
+ if (readFiles.length > 0) {
126
+ sections.push(`<read-files>\n${readFiles.join("\n")}\n</read-files>`);
151
127
  }
128
+ if (modifiedFiles.length > 0) {
129
+ sections.push(`<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`);
130
+ }
131
+
132
+ return sections.length > 0 ? `${summary}\n\n${sections.join("\n\n")}` : summary;
133
+ }
152
134
 
153
- const currentSessionFile = ctx.sessionManager.getSessionFile();
135
+ // ---------------------------------------------------------------------------
136
+ // Shared helpers
137
+ // ---------------------------------------------------------------------------
154
138
 
155
- // Generate the handoff prompt with loader UI
156
- const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
157
- const loader = new BorderedLoader(tui, theme, `Generating handoff summary...`);
139
+ /**
140
+ * Generate a handoff prompt via LLM with a loader UI.
141
+ * Returns the prompt text, or null if cancelled/failed.
142
+ */
143
+ async function generateHandoffPrompt(
144
+ conversationText: string,
145
+ goal: string,
146
+ ctx: ExtensionContext,
147
+ ): Promise<string | null> {
148
+ return ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
149
+ const loader = new BorderedLoader(tui, theme, "Generating handoff prompt...");
158
150
  loader.onAbort = () => done(null);
159
151
 
160
- const doGenerate = async () => {
152
+ const run = async () => {
161
153
  const apiKey = await ctx.modelRegistry.getApiKey(ctx.model!);
162
154
 
163
155
  const userMessage: Message = {
@@ -165,7 +157,7 @@ async function performHandoff(
165
157
  content: [
166
158
  {
167
159
  type: "text",
168
- text: `## Conversation History\n\n${conversationText}\n\n## Goal for New Thread\n\n${goal}`,
160
+ text: `## Conversation History\n\n${conversationText}\n\n## User's Goal for New Thread\n\n${goal}`,
169
161
  },
170
162
  ],
171
163
  timestamp: Date.now(),
@@ -177,9 +169,7 @@ async function performHandoff(
177
169
  { apiKey, signal: loader.signal },
178
170
  );
179
171
 
180
- if (response.stopReason === "aborted") {
181
- return null;
182
- }
172
+ if (response.stopReason === "aborted") return null;
183
173
 
184
174
  return response.content
185
175
  .filter((c): c is { type: "text"; text: string } => c.type === "text")
@@ -187,7 +177,7 @@ async function performHandoff(
187
177
  .join("\n");
188
178
  };
189
179
 
190
- doGenerate()
180
+ run()
191
181
  .then(done)
192
182
  .catch((err) => {
193
183
  console.error("Handoff generation failed:", err);
@@ -196,102 +186,123 @@ async function performHandoff(
196
186
 
197
187
  return loader;
198
188
  });
189
+ }
199
190
 
200
- if (result === null) {
201
- return "Handoff cancelled.";
202
- }
203
-
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;
212
-
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
220
- if (currentSessionFile) {
221
- pendingHandoffText.set(currentSessionFile, messageToSend);
222
- }
191
+ /**
192
+ * Gather conversation from the current branch.
193
+ * Returns serialized text + raw messages (for file op extraction), or null if empty.
194
+ */
195
+ function gatherConversation(ctx: ExtensionContext): { text: string; messages: any[] } | null {
196
+ const branch = ctx.sessionManager.getBranch();
197
+ const messages = branch
198
+ .filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
199
+ .map((entry) => entry.message);
223
200
 
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,
229
- });
201
+ if (messages.length === 0) return null;
230
202
 
231
- if (newSessionResult.cancelled) {
232
- // Clean up pending text if cancelled
233
- if (currentSessionFile) {
234
- pendingHandoffText.delete(currentSessionFile);
235
- }
236
- return "New session cancelled.";
237
- }
238
- } else {
239
- // Tool/hook contexts: create session directly via session manager
240
- const sessionManager = ctx.sessionManager as any;
241
- sessionManager.newSession({ parentSession: currentSessionFile });
242
- }
203
+ return { text: serializeConversation(convertToLlm(messages)), messages };
204
+ }
243
205
 
244
- pi.setSessionName(goalToSessionName(goal));
206
+ /**
207
+ * Wrap a handoff prompt with the parent session reference and session-query skill.
208
+ * Enables the new session to query the old one for details not in the summary.
209
+ */
210
+ function wrapWithParentSession(prompt: string, parentSessionFile: string | null): string {
211
+ if (!parentSessionFile) return prompt;
245
212
 
246
- return undefined;
213
+ return `/skill:pi-session-query\n\n**Parent session:** \`${parentSessionFile}\`\n\n${prompt}`;
247
214
  }
248
215
 
216
+ // ---------------------------------------------------------------------------
217
+ // Extension
218
+ // ---------------------------------------------------------------------------
219
+
249
220
  export default function (pi: ExtensionAPI) {
250
- // --- Session switch handler ---
251
- // When switching to a new session (e.g., after handoff), check if there's
252
- // pending handoff text to set in the editor.
221
+ // -- Shared state for tool/hook deferred handoff (pi-amplike pattern) -----
222
+ //
223
+ // Tool and compact-hook contexts have ExtensionContext (ReadonlySessionManager),
224
+ // not ExtensionCommandContext. They can't call ctx.newSession().
225
+ //
226
+ // Instead they store the prompt and defer the session switch:
227
+ // - Tool: deferred to agent_end (after agent loop completes)
228
+ // - Compact hook: deferred immediately via raw sessionManager.newSession()
229
+ // (safe because no agent loop is running during compaction)
230
+ //
231
+ // Both paths use handoffTimestamp + context event filter to hide old messages
232
+ // from the LLM after the raw session switch (since agent.state.messages
233
+ // isn't cleared by sessionManager.newSession()).
234
+
235
+ let pendingHandoff: { prompt: string; parentSession: string | undefined } | null = null;
236
+ let handoffTimestamp: number | null = null;
237
+
238
+ // -- State for command path (full ctx.newSession() reset) -----------------
239
+ // Command path uses ctx.newSession() which fires session_switch properly.
240
+ // Store prompt keyed by parent session for the session_switch handler.
241
+ const pendingHandoffText = new Map<string, string>();
242
+
243
+ // ── session_switch ──────────────────────────────────────────────────────
244
+ // Set editor text for command-path handoffs + clear context filter.
253
245
  pi.on("session_switch", async (event, ctx) => {
246
+ // Any proper session switch clears the context filter
247
+ handoffTimestamp = null;
248
+
254
249
  if (event.reason !== "new" || !ctx.hasUI) return;
255
250
 
256
- // Get the parent session from the session header
257
251
  const header = ctx.sessionManager.getHeader();
258
252
  const parentSession = header?.parentSession;
259
253
  if (!parentSession) return;
260
254
 
261
- // Check if there's pending handoff text for this parent session
262
255
  const text = pendingHandoffText.get(parentSession);
263
256
  if (text) {
264
257
  ctx.ui.setEditorText(text);
265
- ctx.ui.notify("Handoff ready - edit if needed and press Enter to send", "info");
258
+ ctx.ui.notify("Handoff ready edit if needed, press Enter to send", "info");
266
259
  pendingHandoffText.delete(parentSession);
267
260
  }
268
261
  });
269
262
 
270
- // --- System prompt hint ---
271
- // Inject handoff awareness into the system prompt so the model
272
- // can proactively suggest handoffs at high context usage.
263
+ // ── context filter ──────────────────────────────────────────────────────
264
+ // After a raw sessionManager.newSession() (tool/hook path), old messages
265
+ // remain in agent.state.messages. Filter them by timestamp so the LLM
266
+ // only sees new-session messages.
267
+ pi.on("context", (event) => {
268
+ if (handoffTimestamp === null) return;
269
+
270
+ const newMessages = event.messages.filter((m: any) => m.timestamp >= handoffTimestamp);
271
+ if (newMessages.length > 0) {
272
+ return { messages: newMessages };
273
+ }
274
+ });
275
+
276
+ // ── agent_end: deferred session switch for tool path ────────────────────
277
+ pi.on("agent_end", (_event, ctx) => {
278
+ if (!pendingHandoff) return;
279
+
280
+ const { prompt, parentSession } = pendingHandoff;
281
+ pendingHandoff = null;
282
+
283
+ handoffTimestamp = Date.now();
284
+ (ctx.sessionManager as any).newSession({ parentSession });
285
+
286
+ // Defer to next macrotask so the agent loop cleanup completes first
287
+ setTimeout(() => {
288
+ if (ctx.hasUI) {
289
+ ctx.ui.setEditorText(prompt);
290
+ ctx.ui.notify("Handoff ready — edit if needed, press Enter to send", "info");
291
+ }
292
+ }, 0);
293
+ });
294
+
295
+ // ── before_agent_start: system prompt hint ──────────────────────────────
273
296
  pi.on("before_agent_start", async (event, _ctx) => {
274
- return {
275
- systemPrompt: event.systemPrompt + HANDOFF_SYSTEM_HINT,
276
- };
297
+ return { systemPrompt: event.systemPrompt + HANDOFF_SYSTEM_HINT };
277
298
  });
278
299
 
279
- // --- Auto-handoff on compaction ---
280
- // When auto-compaction triggers, offer handoff as an alternative.
281
- // Uses event.preparation (messagesToSummarize, previousSummary) — the
282
- // manageable subset Pi already prepared — instead of re-gathering the
283
- // full conversation that caused the compaction in the first place.
300
+ // ── session_before_compact: offer handoff ───────────────────────────────
284
301
  pi.on("session_before_compact", async (event, ctx) => {
285
302
  if (!ctx.hasUI || !ctx.model) return;
286
303
 
287
- // Skip if a handoff was just initiated - the new session is already being created
288
- const currentSessionFile = ctx.sessionManager.getSessionFile();
289
- if (currentSessionFile && pendingHandoffText.has(currentSessionFile)) {
290
- return;
291
- }
292
-
293
304
  const usage = ctx.getContextUsage();
294
- const pctStr = usage ? `${Math.round(usage.percent)}%` : "high";
305
+ const pctStr = usage?.percent != null ? `${Math.round(usage.percent)}%` : "high";
295
306
 
296
307
  const choice = await ctx.ui.select(
297
308
  `Context is ${pctStr} full. What would you like to do?`,
@@ -301,7 +312,7 @@ export default function (pi: ExtensionAPI) {
301
312
  if (choice === "Compact context" || choice === undefined) return;
302
313
  if (choice === "Continue without either") return { cancel: true };
303
314
 
304
- // Build context from preparation data — already the right subset
315
+ // Build context from preparation data
305
316
  const { preparation } = event;
306
317
  const conversationText = serializeConversation(
307
318
  convertToLlm(preparation.messagesToSummarize),
@@ -313,16 +324,51 @@ export default function (pi: ExtensionAPI) {
313
324
  }
314
325
  contextForHandoff += `## Recent Conversation\n\n${conversationText}`;
315
326
 
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");
327
+ // Generate handoff prompt
328
+ let prompt: string | null;
329
+ try {
330
+ prompt = await generateHandoffPrompt(contextForHandoff, "Continue current work", ctx);
331
+ } catch (err) {
332
+ ctx.ui.notify(
333
+ `Handoff failed: ${err instanceof Error ? err.message : String(err)}. Compacting instead.`,
334
+ "warning",
335
+ );
336
+ return;
337
+ }
338
+
339
+ if (prompt === null) {
340
+ ctx.ui.notify("Handoff cancelled. Compacting instead.", "warning");
341
+ return;
342
+ }
343
+
344
+ // Append programmatic file tracking from the messages being summarized
345
+ prompt = appendFileOperations(prompt, preparation.messagesToSummarize);
346
+
347
+ // Switch session via raw sessionManager (safe — no agent loop running)
348
+ const currentSessionFile = ctx.sessionManager.getSessionFile();
349
+
350
+ // Wrap with parent session reference + session-query skill
351
+ prompt = wrapWithParentSession(prompt, currentSessionFile ?? null);
352
+
353
+ try {
354
+ handoffTimestamp = Date.now();
355
+ (ctx.sessionManager as any).newSession({ parentSession: currentSessionFile });
356
+ } catch (err) {
357
+ handoffTimestamp = null;
358
+ ctx.ui.notify(
359
+ `Session switch failed: ${err instanceof Error ? err.message : String(err)}. Compacting instead.`,
360
+ "warning",
361
+ );
319
362
  return;
320
363
  }
321
364
 
365
+ ctx.ui.setEditorText(prompt);
366
+ ctx.ui.notify("Handoff ready — edit if needed, press Enter to send", "info");
367
+
322
368
  return { cancel: true };
323
369
  });
324
370
 
325
- // --- /handoff command ---
371
+ // ── /handoff command ─────────────────────────────────────────────────────
326
372
  pi.registerCommand("handoff", {
327
373
  description: "Transfer context to a new focused session",
328
374
  handler: async (args, ctx) => {
@@ -332,14 +378,46 @@ export default function (pi: ExtensionAPI) {
332
378
  return;
333
379
  }
334
380
 
335
- const error = await performHandoff(pi, ctx, goal);
336
- if (error) {
337
- ctx.ui.notify(error, "error");
381
+ if (!ctx.model) {
382
+ ctx.ui.notify("No model selected.", "error");
383
+ return;
384
+ }
385
+
386
+ const conv = gatherConversation(ctx);
387
+ if (!conv) {
388
+ ctx.ui.notify("No conversation to hand off.", "error");
389
+ return;
390
+ }
391
+
392
+ let prompt = await generateHandoffPrompt(conv.text, goal, ctx);
393
+ if (prompt === null) {
394
+ ctx.ui.notify("Handoff cancelled.", "info");
395
+ return;
396
+ }
397
+
398
+ // Append programmatic file tracking (read/modified from tool calls)
399
+ prompt = appendFileOperations(prompt, conv.messages);
400
+
401
+ const currentSessionFile = ctx.sessionManager.getSessionFile();
402
+
403
+ // Wrap with parent session reference + session-query skill
404
+ prompt = wrapWithParentSession(prompt, currentSessionFile ?? null);
405
+
406
+ if (currentSessionFile) {
407
+ pendingHandoffText.set(currentSessionFile, prompt);
408
+ }
409
+
410
+ const result = await ctx.newSession({ parentSession: currentSessionFile ?? undefined });
411
+
412
+ if (result.cancelled) {
413
+ if (currentSessionFile) pendingHandoffText.delete(currentSessionFile);
414
+ ctx.ui.notify("New session cancelled.", "info");
415
+ return;
338
416
  }
339
417
  },
340
418
  });
341
419
 
342
- // --- handoff tool (agent-callable) ---
420
+ // ── handoff tool ─────────────────────────────────────────────────────────
343
421
  pi.registerTool({
344
422
  name: "handoff",
345
423
  label: "Handoff",
@@ -350,16 +428,42 @@ export default function (pi: ExtensionAPI) {
350
428
  }),
351
429
 
352
430
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
353
- const error = await performHandoff(pi, ctx, params.goal, "tool");
431
+ if (!ctx.hasUI) {
432
+ return { content: [{ type: "text" as const, text: "Handoff requires interactive mode." }] };
433
+ }
434
+ if (!ctx.model) {
435
+ return { content: [{ type: "text" as const, text: "No model selected." }] };
436
+ }
437
+
438
+ const conv = gatherConversation(ctx);
439
+ if (!conv) {
440
+ return { content: [{ type: "text" as const, text: "No conversation to hand off." }] };
441
+ }
442
+
443
+ let prompt = await generateHandoffPrompt(conv.text, params.goal, ctx);
444
+ if (prompt === null) {
445
+ return { content: [{ type: "text" as const, text: "Handoff cancelled." }] };
446
+ }
447
+
448
+ prompt = appendFileOperations(prompt, conv.messages);
449
+
450
+ const currentSessionFile = ctx.sessionManager.getSessionFile();
451
+ prompt = wrapWithParentSession(prompt, currentSessionFile ?? null);
452
+
453
+ // Defer session switch to agent_end
454
+ pendingHandoff = {
455
+ prompt,
456
+ parentSession: currentSessionFile ?? undefined,
457
+ };
458
+
354
459
  return {
355
460
  content: [
356
461
  {
357
462
  type: "text" as const,
358
- text: error ?? "Handoff queued. Switching to a new session with the generated prompt.",
463
+ text: "Handoff initiated. The session will switch after the current turn completes.",
359
464
  },
360
465
  ],
361
466
  };
362
467
  },
363
468
  });
364
-
365
469
  }
package/package.json CHANGED
@@ -1,6 +1,10 @@
1
1
  {
2
2
  "name": "@ssweens/pi-handoff",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
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"