pi-sessions 0.4.1 → 0.5.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 +5 -1
- package/extensions/session-handoff/extract.ts +24 -4
- package/extensions/session-handoff/metadata.ts +40 -2
- package/extensions/session-handoff/review.ts +23 -6
- package/extensions/session-handoff/spawn.ts +62 -17
- package/extensions/session-handoff.ts +373 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ What session did I implement the db layer?
|
|
|
55
55
|
| ------------------ | ------------------------------------- | ------------------------------------------------------- |
|
|
56
56
|
| Session Search | `session_search` pi tool | Search through old sessions |
|
|
57
57
|
| Session Ask | `session_ask` pi tool | Ask questions about old sessions |
|
|
58
|
-
| Session Handoff | `/handoff`,
|
|
58
|
+
| Session Handoff | `/handoff`, `session_handoff` pi tool | Start a focused new session; alternative to compaction |
|
|
59
59
|
| Session Picker | `Alt+O` | Reference old sessions in your prompt |
|
|
60
60
|
| Session Index | `/session-index` slash command | Shows index status and rebuilds the local session index |
|
|
61
61
|
| Session Auto Title | in background, `/title` slash command | Give sessions titles |
|
|
@@ -82,6 +82,10 @@ Flow:
|
|
|
82
82
|
|
|
83
83
|
If you do nothing, the preview autostarts after a short countdown.
|
|
84
84
|
|
|
85
|
+
When running in Ghostty on macOS, pi-sessions also exposes a `session_handoff` tool. This lets the agent start a background handoff after you choose a split direction. The current session keeps running, while the child session opens in the requested split, gathers context, and shows the same review countdown before starting.
|
|
86
|
+
|
|
87
|
+
If background handoffs ever target the wrong pane, run `/handoff --identify` from the intended source pane to refresh the in-memory Ghostty terminal binding.
|
|
88
|
+
|
|
85
89
|
## Session picker
|
|
86
90
|
|
|
87
91
|
Directly reference prior sessions by looking them up by contents.
|
|
@@ -93,10 +93,30 @@ export async function generateHandoffDraft(
|
|
|
93
93
|
throw new Error("No model is available for handoff.");
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
return generateHandoffDraftFromSessionManager(
|
|
97
|
+
ctx,
|
|
98
|
+
ctx.sessionManager,
|
|
99
|
+
goal,
|
|
100
|
+
thinkingLevel,
|
|
101
|
+
signal,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function generateHandoffDraftFromSessionManager(
|
|
106
|
+
ctx: ExtensionContext,
|
|
107
|
+
sourceSessionManager: ExtensionContext["sessionManager"],
|
|
108
|
+
goal: string,
|
|
109
|
+
thinkingLevel: ThinkingLevel | undefined,
|
|
110
|
+
signal?: AbortSignal,
|
|
111
|
+
): Promise<HandoffDraftResult | undefined> {
|
|
112
|
+
if (!ctx.model) {
|
|
113
|
+
throw new Error("No model is available for handoff.");
|
|
114
|
+
}
|
|
115
|
+
|
|
96
116
|
const model = ctx.model;
|
|
97
117
|
const sessionContext = buildSessionContext(
|
|
98
|
-
|
|
99
|
-
|
|
118
|
+
sourceSessionManager.getEntries(),
|
|
119
|
+
sourceSessionManager.getLeafId(),
|
|
100
120
|
);
|
|
101
121
|
if (sessionContext.messages.length === 0) {
|
|
102
122
|
throw new Error("No conversation is available to hand off.");
|
|
@@ -115,8 +135,8 @@ export async function generateHandoffDraft(
|
|
|
115
135
|
return undefined;
|
|
116
136
|
}
|
|
117
137
|
|
|
118
|
-
const sessionId =
|
|
119
|
-
const sessionPath =
|
|
138
|
+
const sessionId = sourceSessionManager.getSessionId();
|
|
139
|
+
const sessionPath = sourceSessionManager.getSessionFile();
|
|
120
140
|
|
|
121
141
|
return {
|
|
122
142
|
draft: assembleHandoffDraft(sessionId, sessionPath, handoffContext, goal),
|
|
@@ -16,7 +16,7 @@ export const HANDOFF_SESSION_METADATA_SCHEMA = Type.Object({
|
|
|
16
16
|
initial_prompt: Type.String(),
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
-
export const
|
|
19
|
+
export const IMMEDIATE_HANDOFF_BOOTSTRAP_SCHEMA = Type.Object({
|
|
20
20
|
sessionId: Type.String(),
|
|
21
21
|
goal: Type.String(),
|
|
22
22
|
nextTask: Type.String(),
|
|
@@ -24,7 +24,24 @@ export const HANDOFF_BOOTSTRAP_SCHEMA = Type.Object({
|
|
|
24
24
|
initialPrompt: Type.String(),
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
+
export const CHILD_GENERATED_HANDOFF_BOOTSTRAP_SCHEMA = Type.Object({
|
|
28
|
+
mode: Type.Literal("generate"),
|
|
29
|
+
sessionId: Type.String(),
|
|
30
|
+
goal: Type.String(),
|
|
31
|
+
title: Type.String(),
|
|
32
|
+
parentSessionFile: Type.String(),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export const HANDOFF_BOOTSTRAP_SCHEMA = Type.Union([
|
|
36
|
+
IMMEDIATE_HANDOFF_BOOTSTRAP_SCHEMA,
|
|
37
|
+
CHILD_GENERATED_HANDOFF_BOOTSTRAP_SCHEMA,
|
|
38
|
+
]);
|
|
39
|
+
|
|
27
40
|
export type HandoffSessionMetadata = Static<typeof HANDOFF_SESSION_METADATA_SCHEMA>;
|
|
41
|
+
export type ImmediateHandoffBootstrap = Static<typeof IMMEDIATE_HANDOFF_BOOTSTRAP_SCHEMA>;
|
|
42
|
+
export type ChildGeneratedHandoffBootstrap = Static<
|
|
43
|
+
typeof CHILD_GENERATED_HANDOFF_BOOTSTRAP_SCHEMA
|
|
44
|
+
>;
|
|
28
45
|
export type HandoffBootstrap = Static<typeof HANDOFF_BOOTSTRAP_SCHEMA>;
|
|
29
46
|
|
|
30
47
|
export function createHandoffSessionMetadata(
|
|
@@ -48,7 +65,7 @@ export function createHandoffSessionMetadata(
|
|
|
48
65
|
export function createHandoffBootstrap(
|
|
49
66
|
sessionId: string,
|
|
50
67
|
metadata: HandoffSessionMetadata,
|
|
51
|
-
):
|
|
68
|
+
): ImmediateHandoffBootstrap {
|
|
52
69
|
return {
|
|
53
70
|
sessionId,
|
|
54
71
|
goal: metadata.goal,
|
|
@@ -58,6 +75,27 @@ export function createHandoffBootstrap(
|
|
|
58
75
|
};
|
|
59
76
|
}
|
|
60
77
|
|
|
78
|
+
export function createChildGeneratedHandoffBootstrap(options: {
|
|
79
|
+
sessionId: string;
|
|
80
|
+
goal: string;
|
|
81
|
+
title: string;
|
|
82
|
+
parentSessionFile: string;
|
|
83
|
+
}): ChildGeneratedHandoffBootstrap {
|
|
84
|
+
return {
|
|
85
|
+
mode: "generate",
|
|
86
|
+
sessionId: options.sessionId,
|
|
87
|
+
goal: options.goal.trim(),
|
|
88
|
+
title: options.title.trim(),
|
|
89
|
+
parentSessionFile: options.parentSessionFile,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function isChildGeneratedHandoffBootstrap(
|
|
94
|
+
bootstrap: HandoffBootstrap,
|
|
95
|
+
): bootstrap is ChildGeneratedHandoffBootstrap {
|
|
96
|
+
return "mode" in bootstrap && bootstrap.mode === "generate";
|
|
97
|
+
}
|
|
98
|
+
|
|
61
99
|
export function encodeHandoffBootstrap(bootstrap: HandoffBootstrap): string {
|
|
62
100
|
return Buffer.from(JSON.stringify(bootstrap), "utf8").toString("base64");
|
|
63
101
|
}
|
|
@@ -17,6 +17,11 @@ const PREVIEW_BODY_LINE_LIMIT = 16;
|
|
|
17
17
|
|
|
18
18
|
type ReviewAction = "accept" | "edit" | "cancel";
|
|
19
19
|
|
|
20
|
+
export type HandoffReviewResult =
|
|
21
|
+
| { action: "send"; prompt: string }
|
|
22
|
+
| { action: "prefill"; prompt: string }
|
|
23
|
+
| { action: "cancel" };
|
|
24
|
+
|
|
20
25
|
interface TimerHandle {
|
|
21
26
|
stop(): void;
|
|
22
27
|
}
|
|
@@ -234,21 +239,33 @@ export async function reviewHandoffDraft(
|
|
|
234
239
|
ctx: ExtensionCommandContext,
|
|
235
240
|
draft: string,
|
|
236
241
|
): Promise<string | undefined> {
|
|
237
|
-
const
|
|
238
|
-
if (action
|
|
242
|
+
const result = await reviewHandoffDraftForSend(ctx.ui, draft);
|
|
243
|
+
if (result.action !== "send") {
|
|
239
244
|
return undefined;
|
|
240
245
|
}
|
|
241
246
|
|
|
247
|
+
return result.prompt;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function reviewHandoffDraftForSend(
|
|
251
|
+
ui: ExtensionUIContext,
|
|
252
|
+
draft: string,
|
|
253
|
+
): Promise<HandoffReviewResult> {
|
|
254
|
+
const action = await runPreviewGate(ui, draft);
|
|
255
|
+
if (action === "cancel") {
|
|
256
|
+
return { action: "prefill", prompt: draft };
|
|
257
|
+
}
|
|
258
|
+
|
|
242
259
|
if (action === "accept") {
|
|
243
|
-
return draft;
|
|
260
|
+
return { action: "send", prompt: draft };
|
|
244
261
|
}
|
|
245
262
|
|
|
246
|
-
const editedDraft = await
|
|
263
|
+
const editedDraft = await ui.editor("Edit handoff prompt", draft);
|
|
247
264
|
if (!editedDraft?.trim()) {
|
|
248
|
-
return
|
|
265
|
+
return { action: "prefill", prompt: draft };
|
|
249
266
|
}
|
|
250
267
|
|
|
251
|
-
return editedDraft;
|
|
268
|
+
return { action: "send", prompt: editedDraft };
|
|
252
269
|
}
|
|
253
270
|
|
|
254
271
|
async function runPreviewGate(ui: ExtensionUIContext, draft: string): Promise<ReviewAction> {
|
|
@@ -4,7 +4,7 @@ import { join } from "node:path";
|
|
|
4
4
|
import {
|
|
5
5
|
CURRENT_SESSION_VERSION,
|
|
6
6
|
type ExtensionAPI,
|
|
7
|
-
type
|
|
7
|
+
type ExtensionContext,
|
|
8
8
|
type SessionHeader,
|
|
9
9
|
type SessionInfoEntry,
|
|
10
10
|
} from "@earendil-works/pi-coding-agent";
|
|
@@ -22,9 +22,13 @@ export interface CreatedHandoffSession {
|
|
|
22
22
|
sessionFile: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
export function isGhosttyHandoffAvailable(): boolean {
|
|
26
|
+
return process.platform === "darwin" && process.env.TERM_PROGRAM === "ghostty";
|
|
27
|
+
}
|
|
28
|
+
|
|
25
29
|
export async function validateSplitHandoffPrerequisites(
|
|
26
30
|
_pi: ExtensionAPI,
|
|
27
|
-
ctx:
|
|
31
|
+
ctx: ExtensionContext,
|
|
28
32
|
): Promise<string | undefined> {
|
|
29
33
|
if (!ctx.sessionManager.getSessionFile()) {
|
|
30
34
|
return "Split handoff requires a persisted current session.";
|
|
@@ -77,6 +81,27 @@ export function createHandoffSession(options: {
|
|
|
77
81
|
return { sessionId, sessionFile };
|
|
78
82
|
}
|
|
79
83
|
|
|
84
|
+
export async function getFocusedGhosttyTerminalId(
|
|
85
|
+
pi: ExtensionAPI,
|
|
86
|
+
cwd: string,
|
|
87
|
+
): Promise<string | undefined> {
|
|
88
|
+
const appleScript = [
|
|
89
|
+
'tell application "Ghostty"',
|
|
90
|
+
" get id of focused terminal of selected tab of front window",
|
|
91
|
+
"end tell",
|
|
92
|
+
].join("\n");
|
|
93
|
+
const result = await pi.exec(OSASCRIPT_PATH, ["-e", appleScript], {
|
|
94
|
+
cwd,
|
|
95
|
+
timeout: GHOSTTY_SPLIT_TIMEOUT_MS,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (result.code !== 0) {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return result.stdout.trim() || undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
80
105
|
export async function launchSplitHandoffSession(
|
|
81
106
|
pi: ExtensionAPI,
|
|
82
107
|
options: {
|
|
@@ -86,24 +111,32 @@ export async function launchSplitHandoffSession(
|
|
|
86
111
|
sessionId: string;
|
|
87
112
|
bootstrapValue: string;
|
|
88
113
|
title: string;
|
|
114
|
+
terminalId?: string | undefined;
|
|
115
|
+
restoreFocus?: boolean | undefined;
|
|
116
|
+
model?: string | undefined;
|
|
89
117
|
},
|
|
90
118
|
): Promise<{ success: true } | { success: false; error: string }> {
|
|
91
|
-
const piCommand = buildPiLaunchCommand(
|
|
92
|
-
options.sessionDir,
|
|
93
|
-
options.sessionId,
|
|
94
|
-
options.bootstrapValue,
|
|
95
|
-
options.title,
|
|
96
|
-
|
|
119
|
+
const piCommand = buildPiLaunchCommand({
|
|
120
|
+
sessionDir: options.sessionDir,
|
|
121
|
+
sessionId: options.sessionId,
|
|
122
|
+
bootstrapValue: options.bootstrapValue,
|
|
123
|
+
title: options.title,
|
|
124
|
+
model: options.model,
|
|
125
|
+
});
|
|
97
126
|
const escapedCwd = escapeAppleScriptString(options.cwd);
|
|
98
127
|
const escapedCommand = escapeAppleScriptString(piCommand);
|
|
128
|
+
const targetTerminalLine = options.terminalId
|
|
129
|
+
? ` set targetTerminal to item 1 of (every terminal whose id is "${escapeAppleScriptString(options.terminalId)}")`
|
|
130
|
+
: " set targetTerminal to focused terminal of selected tab of front window";
|
|
131
|
+
const focusLine = options.restoreFocus === false ? [] : [" focus targetTerminal"];
|
|
99
132
|
const appleScript = [
|
|
100
133
|
'tell application "Ghostty"',
|
|
101
|
-
|
|
134
|
+
targetTerminalLine,
|
|
102
135
|
" set cfg to new surface configuration",
|
|
103
136
|
` set initial working directory of cfg to "${escapedCwd}"`,
|
|
104
137
|
` set command of cfg to "${escapedCommand}"`,
|
|
105
138
|
` set newTerminal to split targetTerminal direction ${options.direction} with configuration cfg`,
|
|
106
|
-
|
|
139
|
+
...focusLine,
|
|
107
140
|
"end tell",
|
|
108
141
|
].join("\n");
|
|
109
142
|
const result = await pi.exec(OSASCRIPT_PATH, ["-e", appleScript], {
|
|
@@ -129,6 +162,7 @@ export function buildPiResumeCommand(
|
|
|
129
162
|
sessionId: string,
|
|
130
163
|
bootstrapValue: string,
|
|
131
164
|
title: string,
|
|
165
|
+
model?: string | undefined,
|
|
132
166
|
): string {
|
|
133
167
|
const args = [
|
|
134
168
|
`${HANDOFF_BOOTSTRAP_ENV}=${shellQuote(bootstrapValue)}`,
|
|
@@ -141,16 +175,27 @@ export function buildPiResumeCommand(
|
|
|
141
175
|
shellQuote(title),
|
|
142
176
|
];
|
|
143
177
|
|
|
178
|
+
if (model) {
|
|
179
|
+
args.push("--model", shellQuote(model));
|
|
180
|
+
}
|
|
181
|
+
|
|
144
182
|
return args.join(" ");
|
|
145
183
|
}
|
|
146
184
|
|
|
147
|
-
export function buildPiLaunchCommand(
|
|
148
|
-
sessionDir: string
|
|
149
|
-
sessionId: string
|
|
150
|
-
bootstrapValue: string
|
|
151
|
-
title: string
|
|
152
|
-
|
|
153
|
-
|
|
185
|
+
export function buildPiLaunchCommand(options: {
|
|
186
|
+
sessionDir: string;
|
|
187
|
+
sessionId: string;
|
|
188
|
+
bootstrapValue: string;
|
|
189
|
+
title: string;
|
|
190
|
+
model?: string | undefined;
|
|
191
|
+
}): string {
|
|
192
|
+
const payload = `${buildPiResumeCommand(
|
|
193
|
+
options.sessionDir,
|
|
194
|
+
options.sessionId,
|
|
195
|
+
options.bootstrapValue,
|
|
196
|
+
options.title,
|
|
197
|
+
options.model,
|
|
198
|
+
)}; exec /bin/zsh -il`;
|
|
154
199
|
return `/bin/zsh -ilc ${shellQuote(payload)}`;
|
|
155
200
|
}
|
|
156
201
|
|
|
@@ -1,8 +1,24 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { isAbsolute, resolve } from "node:path";
|
|
4
|
+
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
5
|
+
import type {
|
|
6
|
+
ExtensionAPI,
|
|
7
|
+
ExtensionCommandContext,
|
|
8
|
+
ExtensionContext,
|
|
9
|
+
ExtensionUIContext,
|
|
10
|
+
} from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { buildSessionContext, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
3
12
|
import { Key, matchesKey } from "@earendil-works/pi-tui";
|
|
4
|
-
import {
|
|
13
|
+
import { Type } from "typebox";
|
|
5
14
|
import {
|
|
15
|
+
generateHandoffDraft,
|
|
16
|
+
generateHandoffDraftFromSessionManager,
|
|
17
|
+
type HandoffDraftResult,
|
|
18
|
+
} from "./session-handoff/extract.js";
|
|
19
|
+
import {
|
|
20
|
+
type ChildGeneratedHandoffBootstrap,
|
|
21
|
+
createChildGeneratedHandoffBootstrap,
|
|
6
22
|
createHandoffBootstrap,
|
|
7
23
|
createHandoffSessionMetadata,
|
|
8
24
|
encodeHandoffBootstrap,
|
|
@@ -11,15 +27,22 @@ import {
|
|
|
11
27
|
HANDOFF_METADATA_CUSTOM_TYPE,
|
|
12
28
|
HANDOFF_STALE_SESSION_MESSAGE,
|
|
13
29
|
hasUserMessages,
|
|
30
|
+
isChildGeneratedHandoffBootstrap,
|
|
14
31
|
parseHandoffBootstrap,
|
|
15
32
|
} from "./session-handoff/metadata.js";
|
|
16
33
|
import { openSessionReferencePicker } from "./session-handoff/picker.js";
|
|
17
34
|
import { SESSION_TOKEN_PREFIX } from "./session-handoff/query.js";
|
|
18
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
renderStrongModal,
|
|
37
|
+
reviewHandoffDraft,
|
|
38
|
+
reviewHandoffDraftForSend,
|
|
39
|
+
} from "./session-handoff/review.js";
|
|
19
40
|
import {
|
|
20
41
|
buildPiResumeCommand,
|
|
21
42
|
createHandoffSession,
|
|
43
|
+
getFocusedGhosttyTerminalId,
|
|
22
44
|
type HandoffSplitDirection,
|
|
45
|
+
isGhosttyHandoffAvailable,
|
|
23
46
|
launchSplitHandoffSession,
|
|
24
47
|
validateSplitHandoffPrerequisites,
|
|
25
48
|
} from "./session-handoff/spawn.js";
|
|
@@ -27,14 +50,72 @@ import { isTuiMode } from "./shared/pi-mode.js";
|
|
|
27
50
|
import { loadSettings } from "./shared/settings.js";
|
|
28
51
|
|
|
29
52
|
const HANDOFF_USAGE = "Usage: /handoff [--left|--right|--up|--down] <goal for new thread>";
|
|
53
|
+
const TOOL_HANDOFF_PROVISIONAL_TITLE = "Session handoff";
|
|
54
|
+
const NO_IDENTIFIED_TERMINAL_MESSAGE =
|
|
55
|
+
"No Ghostty source terminal identified. Run /handoff --identify from the intended source pane.";
|
|
56
|
+
|
|
57
|
+
interface HandoffToolParams {
|
|
58
|
+
goal: string;
|
|
59
|
+
splitDirection: HandoffSplitDirection;
|
|
60
|
+
cwd?: string | undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface HandoffToolDetails {
|
|
64
|
+
error: boolean;
|
|
65
|
+
sessionId?: string | undefined;
|
|
66
|
+
title?: string | undefined;
|
|
67
|
+
splitDirection?: HandoffSplitDirection | undefined;
|
|
68
|
+
cwd?: string | undefined;
|
|
69
|
+
}
|
|
30
70
|
|
|
31
71
|
interface HandoffPromptContext {
|
|
32
|
-
ui:
|
|
72
|
+
ui: ExtensionUIContext;
|
|
33
73
|
sendUserMessage(content: string): Promise<void>;
|
|
34
74
|
}
|
|
35
75
|
|
|
36
76
|
export default function sessionHandoffExtension(pi: ExtensionAPI): void {
|
|
37
77
|
const settings = loadSettings();
|
|
78
|
+
let identifiedGhosttyTerminalId: string | undefined;
|
|
79
|
+
|
|
80
|
+
if (isGhosttyHandoffAvailable()) {
|
|
81
|
+
pi.registerTool({
|
|
82
|
+
name: "session_handoff",
|
|
83
|
+
label: "Session Handoff",
|
|
84
|
+
description:
|
|
85
|
+
"Start a new background Pi session with directed instructions based on current work. The current session continues after launch.",
|
|
86
|
+
promptSnippet:
|
|
87
|
+
"Start a background pi session in a terminal split based on the current session",
|
|
88
|
+
promptGuidelines: [
|
|
89
|
+
"Use session_handoff only when it is clear the work should be forked to a new context.",
|
|
90
|
+
"Prefer using session_handoff by direction of the user, not as an unsolicited default.",
|
|
91
|
+
"The goal should capture enough detail to encompass the ask and include any directions the next sessions should consider.",
|
|
92
|
+
"Only capable of a background handoff; to replace the current session, tell the user to run /handoff instead.",
|
|
93
|
+
],
|
|
94
|
+
executionMode: "sequential",
|
|
95
|
+
parameters: Type.Object({
|
|
96
|
+
goal: Type.String({
|
|
97
|
+
description:
|
|
98
|
+
"Goal for the new session, including enough detail to capture the user's ask and any directions the next prompt should consider.",
|
|
99
|
+
}),
|
|
100
|
+
splitDirection: Type.Union(
|
|
101
|
+
[Type.Literal("left"), Type.Literal("right"), Type.Literal("up"), Type.Literal("down")],
|
|
102
|
+
{
|
|
103
|
+
description:
|
|
104
|
+
"Direction to split the Ghostty terminal. Must be explicitly provided by the user.",
|
|
105
|
+
},
|
|
106
|
+
),
|
|
107
|
+
cwd: Type.Optional(
|
|
108
|
+
Type.String({
|
|
109
|
+
description:
|
|
110
|
+
"Optional target working directory. Relative paths resolve from the current session cwd.",
|
|
111
|
+
}),
|
|
112
|
+
),
|
|
113
|
+
}),
|
|
114
|
+
async execute(_toolCallId, params: HandoffToolParams, _signal, _onUpdate, ctx) {
|
|
115
|
+
return executeSessionHandoffTool(pi, params, ctx, identifiedGhosttyTerminalId);
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
}
|
|
38
119
|
|
|
39
120
|
pi.registerCommand("handoff", {
|
|
40
121
|
description: "Transfer context to a new focused session",
|
|
@@ -44,17 +125,29 @@ export default function sessionHandoffExtension(pi: ExtensionAPI): void {
|
|
|
44
125
|
return;
|
|
45
126
|
}
|
|
46
127
|
|
|
47
|
-
if (!ctx.model) {
|
|
48
|
-
ctx.ui.notify("No model selected", "error");
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
128
|
const parsedArgs = parseHandoffCommandArgs(args);
|
|
53
129
|
if (parsedArgs.kind === "error") {
|
|
54
130
|
ctx.ui.notify(parsedArgs.message, "error");
|
|
55
131
|
return;
|
|
56
132
|
}
|
|
57
133
|
|
|
134
|
+
if (parsedArgs.kind === "identify") {
|
|
135
|
+
const terminalId = await getFocusedGhosttyTerminalId(pi, ctx.cwd);
|
|
136
|
+
if (!terminalId) {
|
|
137
|
+
ctx.ui.notify("Unable to identify the focused Ghostty terminal.", "error");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
identifiedGhosttyTerminalId = terminalId;
|
|
142
|
+
ctx.ui.notify(`Identified Ghostty terminal ${terminalId}.`, "info");
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!ctx.model) {
|
|
147
|
+
ctx.ui.notify("No model selected", "error");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
58
151
|
const sessionContext = buildSessionContext(
|
|
59
152
|
ctx.sessionManager.getEntries(),
|
|
60
153
|
ctx.sessionManager.getLeafId(),
|
|
@@ -118,14 +211,25 @@ export default function sessionHandoffExtension(pi: ExtensionAPI): void {
|
|
|
118
211
|
const bootstrapValue = encodeHandoffBootstrap(
|
|
119
212
|
createHandoffBootstrap(createdSession.sessionId, handoffMetadata),
|
|
120
213
|
);
|
|
121
|
-
|
|
214
|
+
let launchResult = await launchSplitHandoffSession(pi, {
|
|
122
215
|
cwd: ctx.cwd,
|
|
123
216
|
sessionDir: ctx.sessionManager.getSessionDir(),
|
|
124
217
|
direction: parsedArgs.splitDirection,
|
|
125
218
|
sessionId: createdSession.sessionId,
|
|
126
219
|
bootstrapValue,
|
|
127
220
|
title: handoffMetadata.title,
|
|
221
|
+
terminalId: identifiedGhosttyTerminalId,
|
|
128
222
|
});
|
|
223
|
+
if (!launchResult.success && identifiedGhosttyTerminalId) {
|
|
224
|
+
launchResult = await launchSplitHandoffSession(pi, {
|
|
225
|
+
cwd: ctx.cwd,
|
|
226
|
+
sessionDir: ctx.sessionManager.getSessionDir(),
|
|
227
|
+
direction: parsedArgs.splitDirection,
|
|
228
|
+
sessionId: createdSession.sessionId,
|
|
229
|
+
bootstrapValue,
|
|
230
|
+
title: handoffMetadata.title,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
129
233
|
|
|
130
234
|
if (!launchResult.success) {
|
|
131
235
|
ctx.ui.notify(
|
|
@@ -193,6 +297,11 @@ export default function sessionHandoffExtension(pi: ExtensionAPI): void {
|
|
|
193
297
|
}
|
|
194
298
|
|
|
195
299
|
try {
|
|
300
|
+
if (isChildGeneratedHandoffBootstrap(bootstrap)) {
|
|
301
|
+
await startChildGeneratedHandoff(pi, ctx, bootstrap, pi.getThinkingLevel());
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
196
305
|
const entries = ctx.sessionManager.getEntries();
|
|
197
306
|
if (hasUserMessages(entries)) {
|
|
198
307
|
if (ctx.hasUI) {
|
|
@@ -223,7 +332,12 @@ export default function sessionHandoffExtension(pi: ExtensionAPI): void {
|
|
|
223
332
|
}
|
|
224
333
|
});
|
|
225
334
|
|
|
226
|
-
pi.on("before_agent_start", async (event) => {
|
|
335
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
336
|
+
if (isGhosttyHandoffAvailable() && ctx) {
|
|
337
|
+
identifiedGhosttyTerminalId =
|
|
338
|
+
(await getFocusedGhosttyTerminalId(pi, ctx.cwd)) ?? identifiedGhosttyTerminalId;
|
|
339
|
+
}
|
|
340
|
+
|
|
227
341
|
return {
|
|
228
342
|
systemPrompt:
|
|
229
343
|
event.systemPrompt +
|
|
@@ -232,8 +346,243 @@ export default function sessionHandoffExtension(pi: ExtensionAPI): void {
|
|
|
232
346
|
});
|
|
233
347
|
}
|
|
234
348
|
|
|
349
|
+
async function executeSessionHandoffTool(
|
|
350
|
+
pi: ExtensionAPI,
|
|
351
|
+
params: HandoffToolParams,
|
|
352
|
+
ctx: ExtensionContext,
|
|
353
|
+
terminalId: string | undefined,
|
|
354
|
+
) {
|
|
355
|
+
const goal = params.goal.trim();
|
|
356
|
+
if (!goal) {
|
|
357
|
+
return createHandoffToolError("session_handoff requires a goal.");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (!ctx.model) {
|
|
361
|
+
return createHandoffToolError("No model selected.");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const preflightError = await validateSplitHandoffPrerequisites(pi, ctx);
|
|
365
|
+
if (preflightError) {
|
|
366
|
+
return createHandoffToolError(preflightError);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (!terminalId) {
|
|
370
|
+
return createHandoffToolError(NO_IDENTIFIED_TERMINAL_MESSAGE);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const targetCwd = resolveHandoffCwd(ctx.cwd, params.cwd);
|
|
374
|
+
if (targetCwd.error) {
|
|
375
|
+
return createHandoffToolError(targetCwd.message);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const parentSessionFile = ctx.sessionManager.getSessionFile();
|
|
379
|
+
if (!parentSessionFile) {
|
|
380
|
+
return createHandoffToolError("Handoff requires a persisted current session.");
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const sessionContext = buildSessionContext(
|
|
384
|
+
ctx.sessionManager.getEntries(),
|
|
385
|
+
ctx.sessionManager.getLeafId(),
|
|
386
|
+
);
|
|
387
|
+
if (sessionContext.messages.length === 0) {
|
|
388
|
+
return createHandoffToolError("No conversation to hand off.");
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const createdSession = createHandoffSession({
|
|
392
|
+
cwd: targetCwd.path,
|
|
393
|
+
sessionDir: ctx.sessionManager.getSessionDir(),
|
|
394
|
+
parentSessionFile,
|
|
395
|
+
title: TOOL_HANDOFF_PROVISIONAL_TITLE,
|
|
396
|
+
});
|
|
397
|
+
const bootstrapValue = encodeHandoffBootstrap(
|
|
398
|
+
createChildGeneratedHandoffBootstrap({
|
|
399
|
+
sessionId: createdSession.sessionId,
|
|
400
|
+
goal,
|
|
401
|
+
title: TOOL_HANDOFF_PROVISIONAL_TITLE,
|
|
402
|
+
parentSessionFile,
|
|
403
|
+
}),
|
|
404
|
+
);
|
|
405
|
+
const model = formatModelArgument(ctx.model, pi.getThinkingLevel());
|
|
406
|
+
const launchResult = await launchSplitHandoffSession(pi, {
|
|
407
|
+
cwd: targetCwd.path,
|
|
408
|
+
sessionDir: ctx.sessionManager.getSessionDir(),
|
|
409
|
+
direction: params.splitDirection,
|
|
410
|
+
sessionId: createdSession.sessionId,
|
|
411
|
+
bootstrapValue,
|
|
412
|
+
title: TOOL_HANDOFF_PROVISIONAL_TITLE,
|
|
413
|
+
terminalId,
|
|
414
|
+
restoreFocus: true,
|
|
415
|
+
model,
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
if (!launchResult.success) {
|
|
419
|
+
return createHandoffToolError(
|
|
420
|
+
`${launchResult.error} Created handoff session ${createdSession.sessionId}; start it manually with: ${buildPiResumeCommand(
|
|
421
|
+
ctx.sessionManager.getSessionDir(),
|
|
422
|
+
createdSession.sessionId,
|
|
423
|
+
bootstrapValue,
|
|
424
|
+
TOOL_HANDOFF_PROVISIONAL_TITLE,
|
|
425
|
+
model,
|
|
426
|
+
)}`,
|
|
427
|
+
{
|
|
428
|
+
sessionId: createdSession.sessionId,
|
|
429
|
+
title: TOOL_HANDOFF_PROVISIONAL_TITLE,
|
|
430
|
+
splitDirection: params.splitDirection,
|
|
431
|
+
cwd: targetCwd.path,
|
|
432
|
+
},
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const details: HandoffToolDetails = {
|
|
437
|
+
error: false,
|
|
438
|
+
sessionId: createdSession.sessionId,
|
|
439
|
+
title: TOOL_HANDOFF_PROVISIONAL_TITLE,
|
|
440
|
+
splitDirection: params.splitDirection,
|
|
441
|
+
cwd: targetCwd.path,
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
return {
|
|
445
|
+
content: [
|
|
446
|
+
{
|
|
447
|
+
type: "text" as const,
|
|
448
|
+
text: `Started handoff session ${createdSession.sessionId} (${params.splitDirection}) in ${targetCwd.path}.`,
|
|
449
|
+
},
|
|
450
|
+
],
|
|
451
|
+
details,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function startChildGeneratedHandoff(
|
|
456
|
+
pi: ExtensionAPI,
|
|
457
|
+
ctx: ExtensionContext,
|
|
458
|
+
bootstrap: ChildGeneratedHandoffBootstrap,
|
|
459
|
+
thinkingLevel: ThinkingLevel | undefined,
|
|
460
|
+
): Promise<void> {
|
|
461
|
+
const entries = ctx.sessionManager.getEntries();
|
|
462
|
+
if (hasUserMessages(entries)) {
|
|
463
|
+
if (ctx.hasUI) {
|
|
464
|
+
ctx.ui.notify(HANDOFF_STALE_SESSION_MESSAGE, "error");
|
|
465
|
+
}
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (!ctx.hasUI) {
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
try {
|
|
474
|
+
const sourceSessionManager = SessionManager.open(bootstrap.parentSessionFile);
|
|
475
|
+
const generatedDraft = await runWithLoader(
|
|
476
|
+
ctx,
|
|
477
|
+
"Generating handoff draft...",
|
|
478
|
+
async (signal: AbortSignal) =>
|
|
479
|
+
generateHandoffDraftFromSessionManager(
|
|
480
|
+
ctx,
|
|
481
|
+
sourceSessionManager,
|
|
482
|
+
bootstrap.goal,
|
|
483
|
+
thinkingLevel,
|
|
484
|
+
signal,
|
|
485
|
+
),
|
|
486
|
+
);
|
|
487
|
+
if (!generatedDraft) {
|
|
488
|
+
ctx.ui.notify("Cancelled", "info");
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const review = await reviewHandoffDraftForSend(ctx.ui, generatedDraft.draft);
|
|
493
|
+
if (review.action === "prefill") {
|
|
494
|
+
ctx.ui.setEditorText(review.prompt);
|
|
495
|
+
ctx.ui.notify("Handoff prompt ready in editor.", "info");
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (review.action === "cancel") {
|
|
500
|
+
ctx.ui.notify("Cancelled", "info");
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const metadata = createHandoffSessionMetadata(
|
|
505
|
+
bootstrap.goal,
|
|
506
|
+
generatedDraft.context.nextTask,
|
|
507
|
+
review.prompt,
|
|
508
|
+
generatedDraft.context.title,
|
|
509
|
+
);
|
|
510
|
+
if (!getHandoffMetadataFromEntries(ctx.sessionManager.getEntries())) {
|
|
511
|
+
pi.appendEntry(HANDOFF_METADATA_CUSTOM_TYPE, metadata);
|
|
512
|
+
}
|
|
513
|
+
pi.setSessionName(metadata.title);
|
|
514
|
+
pi.sendUserMessage(review.prompt);
|
|
515
|
+
} catch (error) {
|
|
516
|
+
ctx.ui.notify(formatHandoffError(error), "error");
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function createHandoffToolError(message: string, details: Partial<HandoffToolDetails> = {}) {
|
|
521
|
+
const resultDetails: HandoffToolDetails = {
|
|
522
|
+
error: true,
|
|
523
|
+
...details,
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
content: [{ type: "text" as const, text: message }],
|
|
528
|
+
details: resultDetails,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function resolveHandoffCwd(
|
|
533
|
+
currentCwd: string,
|
|
534
|
+
requestedCwd: string | undefined,
|
|
535
|
+
): { path: string; error?: undefined } | { error: true; message: string } {
|
|
536
|
+
const rawPath = requestedCwd?.trim();
|
|
537
|
+
const resolvedPath = rawPath ? resolveRequestedPath(currentCwd, rawPath) : currentCwd;
|
|
538
|
+
|
|
539
|
+
if (!existsSync(resolvedPath)) {
|
|
540
|
+
return {
|
|
541
|
+
error: true,
|
|
542
|
+
message: `Handoff cwd does not exist: ${resolvedPath}`,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (!statSync(resolvedPath).isDirectory()) {
|
|
547
|
+
return {
|
|
548
|
+
error: true,
|
|
549
|
+
message: `Handoff cwd is not a directory: ${resolvedPath}`,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return { path: resolvedPath };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function resolveRequestedPath(currentCwd: string, requestedPath: string): string {
|
|
557
|
+
if (requestedPath === "~") {
|
|
558
|
+
return homedir();
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (requestedPath.startsWith("~/")) {
|
|
562
|
+
return resolve(homedir(), requestedPath.slice(2));
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (isAbsolute(requestedPath)) {
|
|
566
|
+
return requestedPath;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
return resolve(currentCwd, requestedPath);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function formatModelArgument(
|
|
573
|
+
model: ExtensionContext["model"],
|
|
574
|
+
thinkingLevel: ThinkingLevel | undefined,
|
|
575
|
+
): string | undefined {
|
|
576
|
+
if (!model) {
|
|
577
|
+
return undefined;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const base = `${model.provider}/${model.id}`;
|
|
581
|
+
return thinkingLevel ? `${base}:${thinkingLevel}` : base;
|
|
582
|
+
}
|
|
583
|
+
|
|
235
584
|
async function runWithLoader<T>(
|
|
236
|
-
ctx:
|
|
585
|
+
ctx: { ui: ExtensionUIContext },
|
|
237
586
|
label: string,
|
|
238
587
|
task: (signal: AbortSignal) => Promise<T>,
|
|
239
588
|
): Promise<T | undefined> {
|
|
@@ -313,16 +662,23 @@ function formatHandoffError(error: unknown): string {
|
|
|
313
662
|
return "Handoff generation failed.";
|
|
314
663
|
}
|
|
315
664
|
|
|
316
|
-
function parseHandoffCommandArgs(
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
665
|
+
function parseHandoffCommandArgs(args: string):
|
|
666
|
+
| { kind: "identify" }
|
|
667
|
+
| {
|
|
668
|
+
kind: "ok";
|
|
669
|
+
goal: string;
|
|
670
|
+
splitDirection?: HandoffSplitDirection | undefined;
|
|
671
|
+
}
|
|
320
672
|
| { kind: "error"; message: string } {
|
|
321
673
|
const tokens = args
|
|
322
674
|
.trim()
|
|
323
675
|
.split(/\s+/)
|
|
324
676
|
.filter((token) => token.length > 0);
|
|
325
677
|
|
|
678
|
+
if (tokens.includes("--identify")) {
|
|
679
|
+
return { kind: "identify" };
|
|
680
|
+
}
|
|
681
|
+
|
|
326
682
|
if (tokens.length === 0) {
|
|
327
683
|
return { kind: "error", message: HANDOFF_USAGE };
|
|
328
684
|
}
|