@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 +69 -145
- package/extensions/handoff.ts +288 -184
- package/package.json +5 -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,44 +1,32 @@
|
|
|
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";
|
|
23
|
-
import type {
|
|
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
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
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
|
-
##
|
|
70
|
-
-
|
|
71
|
-
- 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]
|
|
72
59
|
|
|
73
|
-
##
|
|
74
|
-
[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Shared helpers
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
154
138
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
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
|
|
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
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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"
|