@ssweens/pi-handoff 1.0.1 → 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,22 +1,16 @@
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";
@@ -29,26 +23,10 @@ import type {
29
23
  import { BorderedLoader, convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
30
24
  import { Type } from "@sinclair/typebox";
31
25
 
32
- // Store pending handoff text to be set in new session after switch
33
- // Key: parent session file path, Value: handoff text to set in editor
34
- const pendingHandoffText = new Map<string, string>();
35
-
36
- /** @internal Test-only: clear all pending handoff state between tests. */
37
- export function __clearPendingHandoffText(): void {
38
- pendingHandoffText.clear();
39
- }
26
+ // ---------------------------------------------------------------------------
27
+ // System prompts
28
+ // ---------------------------------------------------------------------------
40
29
 
41
- // Handoff generation system prompt.
42
- //
43
- // Combines Pi's structured compaction format (Goal, Progress, Decisions,
44
- // Constraints) with handoff-specific goal filtering, code pointers from
45
- // mina, and an explicit Task section.
46
- //
47
- // Key differences from Pi compaction:
48
- // - Goal-directed: everything is filtered through the user's stated goal
49
- // - Code pointers: path:line and path#Symbol references in context
50
- // - Task section: actionable next steps framed by the goal
51
- // - Anti-continuation guard: prevent the summarizer from responding to the history
52
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.
53
31
 
54
32
  Do NOT continue the conversation. Do NOT respond to any questions in the history. ONLY output the structured summary.
@@ -58,10 +36,6 @@ Use this EXACT format:
58
36
  ## Goal
59
37
  [The user's goal for the new thread — what they want to accomplish.]
60
38
 
61
- ## Key Decisions
62
- - **[Decision]**: [Brief rationale]
63
- - Use code pointers (path/to/file.ts:42 or path/to/file.ts#functionName) where relevant
64
-
65
39
  ## Constraints & Preferences
66
40
  - [Any requirements, constraints, or preferences the user stated]
67
41
  - [Or "(none)" if none were mentioned]
@@ -76,12 +50,16 @@ Use this EXACT format:
76
50
  ### Blocked
77
51
  - [Open issues or blockers, if any]
78
52
 
79
- ## Files
80
- - path/to/file1.ts (modified)
81
- - 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]
82
59
 
83
- ## Task
84
- [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]
85
63
 
86
64
  Rules:
87
65
  - Be concise. Every bullet earns its place.
@@ -89,8 +67,6 @@ Rules:
89
67
  - Only include information relevant to the stated goal — discard unrelated context.
90
68
  - Output the formatted content only. No preamble, no filler.`;
91
69
 
92
- // System prompt fragment injected via before_agent_start.
93
- // Teaches the model about handoffs so it can suggest them proactively.
94
70
  export const HANDOFF_SYSTEM_HINT = `
95
71
  ## Handoff
96
72
 
@@ -98,109 +74,82 @@ Use \`/handoff <goal>\` to transfer context to a new focused session.
98
74
  Handoffs are especially effective after planning — clear the context and start a new session with the plan you just created.
99
75
  At high context usage, suggest a handoff rather than losing important context.`;
100
76
 
101
- /**
102
- * Generate a session name from the goal (slug format).
103
- * Exported for testing.
104
- */
105
- export function goalToSessionName(goal: string): string {
106
- return goal
107
- .toLowerCase()
108
- .replace(/[^a-z0-9\s-]/g, "")
109
- .trim()
110
- .replace(/\s+/g, "-")
111
- .slice(0, 50);
77
+ // ---------------------------------------------------------------------------
78
+ // File operation tracking (mirrors pi's compaction/utils.ts approach)
79
+ // ---------------------------------------------------------------------------
80
+
81
+ interface FileOps {
82
+ read: Set<string>;
83
+ written: Set<string>;
84
+ edited: Set<string>;
112
85
  }
113
86
 
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`;
87
+ function createFileOps(): FileOps {
88
+ return { read: new Set(), written: new Set(), edited: new Set() };
89
+ }
125
90
 
126
- if (currentSessionFile) {
127
- fullPrompt += `**Parent session:** \`${currentSessionFile}\`\n\n`;
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
+ }
128
112
  }
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
113
  }
137
114
 
138
- /**
139
- * Handoff modes:
140
- * - "command": User-initiated via /handoff
141
- * - "tool": Agent-initiated via handoff tool
142
- * - "compactHook": Triggered from session_before_compact
143
- *
144
- * Command mode has ExtensionCommandContext (with newSession).
145
- * Tool and compactHook modes have ExtensionContext (ReadonlySessionManager, no newSession).
146
- */
147
- type HandoffMode = "command" | "tool" | "compactHook";
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);
148
119
 
149
- /**
150
- * Core handoff logic shared by the /handoff command, the handoff tool,
151
- * and the auto-handoff compaction hook.
152
- *
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.
161
- */
162
- async function performHandoff(
163
- pi: ExtensionAPI,
164
- ctx: ExtensionContext,
165
- goal: string,
166
- mode: HandoffMode = "command",
167
- preBuiltContext?: string,
168
- ): Promise<string | undefined> {
169
- if (!ctx.hasUI) {
170
- return "Handoff requires interactive mode.";
171
- }
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();
172
123
 
173
- if (!ctx.model) {
174
- return "No model selected.";
124
+ const sections: string[] = [];
125
+ if (readFiles.length > 0) {
126
+ sections.push(`<read-files>\n${readFiles.join("\n")}\n</read-files>`);
175
127
  }
176
-
177
- let conversationText: string;
178
-
179
- if (preBuiltContext) {
180
- // compactHook: context already built from preparation data
181
- conversationText = preBuiltContext;
182
- } else {
183
- // command/tool: gather full conversation (context isn't full yet)
184
- const branch = ctx.sessionManager.getBranch();
185
- const messages = branch
186
- .filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
187
- .map((entry) => entry.message);
188
-
189
- if (messages.length === 0) {
190
- return "No conversation to hand off.";
191
- }
192
-
193
- conversationText = serializeConversation(convertToLlm(messages));
128
+ if (modifiedFiles.length > 0) {
129
+ sections.push(`<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`);
194
130
  }
195
131
 
196
- const currentSessionFile = ctx.sessionManager.getSessionFile();
132
+ return sections.length > 0 ? `${summary}\n\n${sections.join("\n\n")}` : summary;
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Shared helpers
137
+ // ---------------------------------------------------------------------------
197
138
 
198
- // Generate the handoff prompt with loader UI
199
- const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
200
- 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...");
201
150
  loader.onAbort = () => done(null);
202
151
 
203
- const doGenerate = async () => {
152
+ const run = async () => {
204
153
  const apiKey = await ctx.modelRegistry.getApiKey(ctx.model!);
205
154
 
206
155
  const userMessage: Message = {
@@ -208,7 +157,7 @@ async function performHandoff(
208
157
  content: [
209
158
  {
210
159
  type: "text",
211
- 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}`,
212
161
  },
213
162
  ],
214
163
  timestamp: Date.now(),
@@ -220,9 +169,7 @@ async function performHandoff(
220
169
  { apiKey, signal: loader.signal },
221
170
  );
222
171
 
223
- if (response.stopReason === "aborted") {
224
- return null;
225
- }
172
+ if (response.stopReason === "aborted") return null;
226
173
 
227
174
  return response.content
228
175
  .filter((c): c is { type: "text"; text: string } => c.type === "text")
@@ -230,7 +177,7 @@ async function performHandoff(
230
177
  .join("\n");
231
178
  };
232
179
 
233
- doGenerate()
180
+ run()
234
181
  .then(done)
235
182
  .catch((err) => {
236
183
  console.error("Handoff generation failed:", err);
@@ -239,98 +186,121 @@ async function performHandoff(
239
186
 
240
187
  return loader;
241
188
  });
189
+ }
242
190
 
243
- if (result === null) {
244
- return "Handoff cancelled.";
245
- }
246
-
247
- const messageToSend = buildFullPrompt(goal, currentSessionFile ?? null, result);
248
-
249
- // Store the handoff text for the session_switch event to pick up.
250
- // Key: parent session file (passed to newSession as parentSession).
251
- if (currentSessionFile) {
252
- pendingHandoffText.set(currentSessionFile, messageToSend);
253
- }
254
-
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";
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);
260
200
 
261
- if (hasNewSession) {
262
- const cmdCtx = ctx as ExtensionCommandContext;
263
- const newSessionResult = await cmdCtx.newSession({
264
- parentSession: currentSessionFile ?? undefined,
265
- });
201
+ if (messages.length === 0) return null;
266
202
 
267
- if (newSessionResult.cancelled) {
268
- // Clean up pending text if session creation was cancelled
269
- if (currentSessionFile) {
270
- pendingHandoffText.delete(currentSessionFile);
271
- }
272
- return "New session cancelled.";
273
- }
203
+ return { text: serializeConversation(convertToLlm(messages)), messages };
204
+ }
274
205
 
275
- pi.setSessionName(goalToSessionName(goal));
276
- } else {
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
- );
285
- }
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;
286
212
 
287
- return undefined;
213
+ return `/skill:pi-session-query\n\n**Parent session:** \`${parentSessionFile}\`\n\n${prompt}`;
288
214
  }
289
215
 
216
+ // ---------------------------------------------------------------------------
217
+ // Extension
218
+ // ---------------------------------------------------------------------------
219
+
290
220
  export default function (pi: ExtensionAPI) {
291
- // --- Session switch handler ---
292
- // When switching to a new session (e.g., after handoff), check if there's
293
- // 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.
294
245
  pi.on("session_switch", async (event, ctx) => {
246
+ // Any proper session switch clears the context filter
247
+ handoffTimestamp = null;
248
+
295
249
  if (event.reason !== "new" || !ctx.hasUI) return;
296
250
 
297
- // Get the parent session from the session header
298
251
  const header = ctx.sessionManager.getHeader();
299
252
  const parentSession = header?.parentSession;
300
253
  if (!parentSession) return;
301
254
 
302
- // Check if there's pending handoff text for this parent session
303
255
  const text = pendingHandoffText.get(parentSession);
304
256
  if (text) {
305
257
  ctx.ui.setEditorText(text);
306
- 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");
307
259
  pendingHandoffText.delete(parentSession);
308
260
  }
309
261
  });
310
262
 
311
- // --- System prompt hint ---
312
- // Inject handoff awareness into the system prompt so the model
313
- // 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 ──────────────────────────────
314
296
  pi.on("before_agent_start", async (event, _ctx) => {
315
- return {
316
- systemPrompt: event.systemPrompt + HANDOFF_SYSTEM_HINT,
317
- };
297
+ return { systemPrompt: event.systemPrompt + HANDOFF_SYSTEM_HINT };
318
298
  });
319
299
 
320
- // --- Auto-handoff on compaction ---
321
- // When auto-compaction triggers, offer handoff as an alternative.
322
- // Uses event.preparation (messagesToSummarize, previousSummary) — the
323
- // manageable subset Pi already prepared — instead of re-gathering the
324
- // full conversation that caused the compaction in the first place.
300
+ // ── session_before_compact: offer handoff ───────────────────────────────
325
301
  pi.on("session_before_compact", async (event, ctx) => {
326
302
  if (!ctx.hasUI || !ctx.model) return;
327
303
 
328
- // Skip if a handoff was just initiated - the new session is already being created
329
- const currentSessionFile = ctx.sessionManager.getSessionFile();
330
- if (currentSessionFile && pendingHandoffText.has(currentSessionFile)) {
331
- return;
332
- }
333
-
334
304
  const usage = ctx.getContextUsage();
335
305
  const pctStr = usage?.percent != null ? `${Math.round(usage.percent)}%` : "high";
336
306
 
@@ -342,7 +312,7 @@ export default function (pi: ExtensionAPI) {
342
312
  if (choice === "Compact context" || choice === undefined) return;
343
313
  if (choice === "Continue without either") return { cancel: true };
344
314
 
345
- // Build context from preparation data — already the right subset
315
+ // Build context from preparation data
346
316
  const { preparation } = event;
347
317
  const conversationText = serializeConversation(
348
318
  convertToLlm(preparation.messagesToSummarize),
@@ -354,30 +324,51 @@ export default function (pi: ExtensionAPI) {
354
324
  }
355
325
  contextForHandoff += `## Recent Conversation\n\n${conversationText}`;
356
326
 
327
+ // Generate handoff prompt
328
+ let prompt: string | null;
357
329
  try {
358
- const error = await performHandoff(
359
- pi,
360
- ctx,
361
- "Continue current work",
362
- "compactHook",
363
- contextForHandoff,
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",
364
335
  );
365
- if (error) {
366
- ctx.ui.notify(`Handoff failed: ${error}. Compacting instead.`, "warning");
367
- return;
368
- }
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 });
369
356
  } catch (err) {
357
+ handoffTimestamp = null;
370
358
  ctx.ui.notify(
371
- `Handoff error: ${err instanceof Error ? err.message : String(err)}. Compacting instead.`,
359
+ `Session switch failed: ${err instanceof Error ? err.message : String(err)}. Compacting instead.`,
372
360
  "warning",
373
361
  );
374
362
  return;
375
363
  }
376
364
 
365
+ ctx.ui.setEditorText(prompt);
366
+ ctx.ui.notify("Handoff ready — edit if needed, press Enter to send", "info");
367
+
377
368
  return { cancel: true };
378
369
  });
379
370
 
380
- // --- /handoff command ---
371
+ // ── /handoff command ─────────────────────────────────────────────────────
381
372
  pi.registerCommand("handoff", {
382
373
  description: "Transfer context to a new focused session",
383
374
  handler: async (args, ctx) => {
@@ -387,14 +378,46 @@ export default function (pi: ExtensionAPI) {
387
378
  return;
388
379
  }
389
380
 
390
- const error = await performHandoff(pi, ctx, goal);
391
- if (error) {
392
- 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;
393
416
  }
394
417
  },
395
418
  });
396
419
 
397
- // --- handoff tool (agent-callable) ---
420
+ // ── handoff tool ─────────────────────────────────────────────────────────
398
421
  pi.registerTool({
399
422
  name: "handoff",
400
423
  label: "Handoff",
@@ -405,14 +428,39 @@ export default function (pi: ExtensionAPI) {
405
428
  }),
406
429
 
407
430
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
408
- 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
+
409
459
  return {
410
460
  content: [
411
461
  {
412
462
  type: "text" as const,
413
- text:
414
- error ??
415
- "Handoff queued. The generated prompt has been placed in the editor — start a new session to send it.",
463
+ text: "Handoff initiated. The session will switch after the current turn completes.",
416
464
  },
417
465
  ],
418
466
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssweens/pi-handoff",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "scripts": {
5
5
  "test": "bun test tests/",
6
6
  "test:watch": "bun test --watch tests/"