@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 +69 -145
- package/extensions/handoff.ts +271 -223
- package/package.json +1 -1
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 —
|
|
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
|
-
-
|
|
14
|
-
- **Agent-callable
|
|
15
|
-
- **Auto-handoff on compaction** —
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
1.
|
|
74
|
-
2.
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
45
|
+
Ask the model directly:
|
|
85
46
|
|
|
86
47
|
```
|
|
87
|
-
"Please hand this off to a new session
|
|
88
|
-
"Create a handoff to execute phase one"
|
|
48
|
+
"Please hand this off to a new session"
|
|
89
49
|
```
|
|
90
50
|
|
|
91
|
-
The agent
|
|
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
|
|
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
|
|
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
|
-
|
|
66
|
+
### Querying Parent Sessions
|
|
111
67
|
|
|
112
|
-
|
|
68
|
+
Handoff prompts include a parent session reference:
|
|
113
69
|
|
|
114
|
-
|
|
70
|
+
```
|
|
71
|
+
/skill:pi-session-query
|
|
115
72
|
|
|
116
|
-
|
|
73
|
+
**Parent session:** `/path/to/old-session.jsonl`
|
|
117
74
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
session_query("/path/to/parent/session.jsonl", "What approach was chosen for authentication?")
|
|
75
|
+
## Goal
|
|
76
|
+
...
|
|
121
77
|
```
|
|
122
78
|
|
|
123
|
-
|
|
79
|
+
The `session_query` tool lets the model look up details from the parent session without loading the full conversation:
|
|
124
80
|
|
|
125
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
95
|
+
- Requirements or preferences stated
|
|
145
96
|
|
|
146
97
|
## Progress
|
|
147
98
|
### Done
|
|
148
|
-
- [x] Completed work
|
|
99
|
+
- [x] Completed work
|
|
149
100
|
|
|
150
101
|
### In Progress
|
|
151
|
-
- [ ]
|
|
102
|
+
- [ ] Current work
|
|
152
103
|
|
|
153
104
|
### Blocked
|
|
154
|
-
- Open issues
|
|
105
|
+
- Open issues
|
|
155
106
|
|
|
156
|
-
##
|
|
157
|
-
- path/to/
|
|
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
|
-
##
|
|
110
|
+
## Next Steps
|
|
111
|
+
1. What should happen next
|
|
184
112
|
|
|
185
|
-
|
|
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
|
-
|
|
116
|
+
<read-files>
|
|
117
|
+
src/config.ts
|
|
118
|
+
</read-files>
|
|
195
119
|
|
|
196
|
-
|
|
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,
|
|
203
|
-
| [session-query.ts](extensions/session-query.ts) | Extension | `session_query` tool for
|
|
204
|
-
| [pi-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
|
-
|
|
134
|
+
## Architecture
|
|
209
135
|
|
|
210
|
-
|
|
136
|
+
Three entry points, one outcome:
|
|
211
137
|
|
|
212
|
-
|
|
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
|
-
|
|
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
|
|
package/extensions/handoff.ts
CHANGED
|
@@ -1,22 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Handoff Extension
|
|
3
3
|
*
|
|
4
|
-
* Transfers conversation context to a new focused session
|
|
5
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
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
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
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
|
-
##
|
|
80
|
-
-
|
|
81
|
-
- path/to/
|
|
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
|
-
##
|
|
84
|
-
[
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
+
return sections.length > 0 ? `${summary}\n\n${sections.join("\n\n")}` : summary;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Shared helpers
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
197
138
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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 (
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
|
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
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
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
|
|
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
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
"
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
`
|
|
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
|
-
//
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
};
|