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 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`, session picker shortcut | Start a focused new session; alternative to compaction |
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
- ctx.sessionManager.getEntries(),
99
- ctx.sessionManager.getLeafId(),
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 = ctx.sessionManager.getSessionId();
119
- const sessionPath = ctx.sessionManager.getSessionFile();
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 HANDOFF_BOOTSTRAP_SCHEMA = Type.Object({
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
- ): HandoffBootstrap {
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 action = await runPreviewGate(ctx.ui, draft);
238
- if (action === "cancel") {
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 ctx.ui.editor("Edit handoff prompt", draft);
263
+ const editedDraft = await ui.editor("Edit handoff prompt", draft);
247
264
  if (!editedDraft?.trim()) {
248
- return undefined;
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 ExtensionCommandContext,
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: ExtensionCommandContext,
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
- " set targetTerminal to focused terminal of selected tab of front window",
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
- " focus targetTerminal",
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
- ): string {
153
- const payload = `${buildPiResumeCommand(sessionDir, sessionId, bootstrapValue, title)}; exec /bin/zsh -il`;
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 type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
- import { buildSessionContext } from "@earendil-works/pi-coding-agent";
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 { generateHandoffDraft, type HandoffDraftResult } from "./session-handoff/extract.js";
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 { renderStrongModal, reviewHandoffDraft } from "./session-handoff/review.js";
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: ExtensionCommandContext["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
- const launchResult = await launchSplitHandoffSession(pi, {
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: ExtensionCommandContext,
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
- args: string,
318
- ):
319
- | { kind: "ok"; goal: string; splitDirection?: HandoffSplitDirection | undefined }
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sessions",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Pi session search, ask, handoff, auto-titling, and indexing tools",
6
6
  "license": "MIT",