pi-sessions 0.1.0 → 0.2.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
@@ -62,11 +62,20 @@ What session did I implement the db layer?
62
62
 
63
63
  `/handoff <goal>` starts a focused new session. Give pi a goal, and it will generate a prompt for you to review before kicking it off.
64
64
 
65
+ You can either start a new session directly in your current one, or if you have Ghostty on macOS, you can spawn a new one in a split-pane and continue in your current:
66
+
67
+ - `/handoff --left <goal>`
68
+ - `/handoff --right <goal>`
69
+ - `/handoff --up <goal>`
70
+ - `/handoff --down <goal>`
71
+
72
+ The flag indicates the Ghostty split direction.
73
+
65
74
  Flow:
66
75
 
67
- - run `/handoff <goal>`
76
+ - run `/handoff [--<direction>] <goal>`
68
77
  - review the generated prompt preview
69
- - optionally edit the prompt to make any adjustments
78
+ - optionally edit the prompt
70
79
  - start the new session
71
80
 
72
81
  If you do nothing, the preview autostarts after a short countdown.
@@ -138,8 +147,6 @@ To manage existing titles, run `/title`, where you can:
138
147
 
139
148
  Note that generating titles for all sessions can take some time, and will hit your configured model with the full contents of all sessions.
140
149
 
141
- Behavior notes:
142
-
143
150
  - automatic retitles run every few turns
144
151
  - if you manually rename a session with `/name`, automatic retitling pauses for that session
145
152
  - Regenerate the title for the current session to resume automatic retitling
@@ -147,7 +154,7 @@ Behavior notes:
147
154
  - `google/gemini-flash-lite-latest`
148
155
  - `anthropic/claude-haiku-4-5`
149
156
  - `openai/gpt-5.4-mini`
150
- - your currently configured model
157
+ - your currently configured model
151
158
 
152
159
  To change auto-titling settings, edit `~/.pi/agent/settings.json`:
153
160
 
@@ -1,25 +1,29 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { Buffer } from "node:buffer";
2
2
  import type { CustomEntry, SessionEntry } from "@mariozechner/pi-coding-agent";
3
3
  import { type Static, Type } from "@sinclair/typebox";
4
4
  import { safeParseTypeBoxValue } from "../shared/typebox.js";
5
5
 
6
6
  export const HANDOFF_METADATA_CUSTOM_TYPE = "pi-sessions.handoff";
7
- export const PENDING_SEND_CONSUMED_CUSTOM_TYPE = "pi-sessions.pending-send-consumed";
7
+ export const HANDOFF_BOOTSTRAP_ENV = "PI_SESSIONS_HANDOFF_BOOTSTRAP";
8
+ export const HANDOFF_STALE_SESSION_MESSAGE =
9
+ "Session handoff failed: target session already has user input.";
8
10
 
9
11
  export const HANDOFF_SESSION_METADATA_SCHEMA = Type.Object({
10
12
  origin: Type.Literal("handoff"),
11
13
  goal: Type.String(),
12
14
  nextTask: Type.String(),
13
15
  initial_prompt: Type.String(),
14
- initial_prompt_nonce: Type.String(),
15
16
  });
16
17
 
17
- export const PENDING_SEND_CONSUMED_ENTRY_SCHEMA = Type.Object({
18
- nonce: Type.String(),
18
+ export const HANDOFF_BOOTSTRAP_SCHEMA = Type.Object({
19
+ sessionId: Type.String(),
20
+ goal: Type.String(),
21
+ nextTask: Type.String(),
22
+ initialPrompt: Type.String(),
19
23
  });
20
24
 
21
25
  export type HandoffSessionMetadata = Static<typeof HANDOFF_SESSION_METADATA_SCHEMA>;
22
- export type PendingSendConsumedEntryData = Static<typeof PENDING_SEND_CONSUMED_ENTRY_SCHEMA>;
26
+ export type HandoffBootstrap = Static<typeof HANDOFF_BOOTSTRAP_SCHEMA>;
23
27
 
24
28
  export function createHandoffSessionMetadata(
25
29
  goal: string,
@@ -28,69 +32,69 @@ export function createHandoffSessionMetadata(
28
32
  ): HandoffSessionMetadata {
29
33
  const normalizedGoal = goal.trim();
30
34
  const normalizedNextTask = nextTask.trim() || normalizedGoal;
31
- const normalizedInitialPrompt = initialPrompt.trim();
32
35
 
33
36
  return {
34
37
  origin: "handoff",
35
38
  goal: normalizedGoal,
36
39
  nextTask: normalizedNextTask,
37
- initial_prompt: normalizedInitialPrompt,
38
- initial_prompt_nonce: randomUUID(),
40
+ initial_prompt: initialPrompt.trim(),
41
+ };
42
+ }
43
+
44
+ export function createHandoffBootstrap(
45
+ sessionId: string,
46
+ metadata: HandoffSessionMetadata,
47
+ ): HandoffBootstrap {
48
+ return {
49
+ sessionId,
50
+ goal: metadata.goal,
51
+ nextTask: metadata.nextTask,
52
+ initialPrompt: metadata.initial_prompt,
39
53
  };
40
54
  }
41
55
 
42
- export function createPendingSendConsumedEntry(nonce: string): PendingSendConsumedEntryData {
43
- return { nonce };
56
+ export function encodeHandoffBootstrap(bootstrap: HandoffBootstrap): string {
57
+ return Buffer.from(JSON.stringify(bootstrap), "utf8").toString("base64");
44
58
  }
45
59
 
46
- export function parsePendingSendConsumedEntry(
47
- value: unknown,
48
- ): PendingSendConsumedEntryData | undefined {
49
- return safeParseTypeBoxValue(PENDING_SEND_CONSUMED_ENTRY_SCHEMA, value);
60
+ export function parseHandoffBootstrap(value: string): HandoffBootstrap | undefined {
61
+ try {
62
+ const decoded = Buffer.from(value, "base64").toString("utf8");
63
+ return safeParseTypeBoxValue(HANDOFF_BOOTSTRAP_SCHEMA, JSON.parse(decoded));
64
+ } catch {
65
+ return undefined;
66
+ }
50
67
  }
51
68
 
52
69
  export function parseHandoffSessionMetadata(value: unknown): HandoffSessionMetadata | undefined {
53
70
  return safeParseTypeBoxValue(HANDOFF_SESSION_METADATA_SCHEMA, value);
54
71
  }
55
72
 
56
- export function getPendingInitialPromptFromEntries(
73
+ export function getHandoffMetadataFromEntries(
57
74
  entries: readonly SessionEntry[],
58
75
  ): HandoffSessionMetadata | undefined {
59
- const consumed = new Set<string>();
60
- let pending: HandoffSessionMetadata | undefined;
61
-
62
76
  for (const entry of entries) {
63
77
  if (entry.type !== "custom") {
64
78
  continue;
65
79
  }
66
80
 
67
- const customEntry = entry as CustomEntry;
68
- if (customEntry.customType === PENDING_SEND_CONSUMED_CUSTOM_TYPE) {
69
- const consumedEntry = parsePendingSendConsumedEntry(customEntry.data);
70
- if (consumedEntry) {
71
- consumed.add(consumedEntry.nonce);
72
- if (pending?.initial_prompt_nonce === consumedEntry.nonce) {
73
- pending = undefined;
74
- }
75
- }
76
- continue;
77
- }
78
-
79
- if (customEntry.customType !== HANDOFF_METADATA_CUSTOM_TYPE) {
80
- continue;
81
+ const metadata = parseCustomHandoffMetadata(entry);
82
+ if (metadata) {
83
+ return metadata;
81
84
  }
85
+ }
82
86
 
83
- const metadata = parseHandoffSessionMetadata(customEntry.data);
84
- if (!metadata) {
85
- continue;
86
- }
87
+ return undefined;
88
+ }
87
89
 
88
- if (consumed.has(metadata.initial_prompt_nonce)) {
89
- continue;
90
- }
90
+ export function hasUserMessages(entries: readonly SessionEntry[]): boolean {
91
+ return entries.some((entry) => entry.type === "message" && entry.message.role === "user");
92
+ }
91
93
 
92
- pending = metadata;
94
+ function parseCustomHandoffMetadata(entry: CustomEntry): HandoffSessionMetadata | undefined {
95
+ if (entry.customType !== HANDOFF_METADATA_CUSTOM_TYPE) {
96
+ return undefined;
93
97
  }
94
98
 
95
- return pending;
99
+ return parseHandoffSessionMetadata(entry.data);
96
100
  }
@@ -0,0 +1,142 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import {
5
+ CURRENT_SESSION_VERSION,
6
+ type ExtensionAPI,
7
+ type ExtensionCommandContext,
8
+ type SessionHeader,
9
+ } from "@mariozechner/pi-coding-agent";
10
+ import { HANDOFF_BOOTSTRAP_ENV } from "./metadata.js";
11
+
12
+ const GHOSTTY_MACOS_ONLY_MESSAGE = "Split handoff currently supports Ghostty on macOS only.";
13
+ const GHOSTTY_REQUIRED_MESSAGE = "Split handoff requires running inside Ghostty.";
14
+ const GHOSTTY_SPLIT_TIMEOUT_MS = 15_000;
15
+ const OSASCRIPT_PATH = "/usr/bin/osascript";
16
+
17
+ export type HandoffSplitDirection = "left" | "right" | "up" | "down";
18
+
19
+ export interface CreatedHandoffSession {
20
+ sessionId: string;
21
+ sessionFile: string;
22
+ }
23
+
24
+ export async function validateSplitHandoffPrerequisites(
25
+ _pi: ExtensionAPI,
26
+ ctx: ExtensionCommandContext,
27
+ ): Promise<string | undefined> {
28
+ if (!ctx.sessionManager.getSessionFile()) {
29
+ return "Split handoff requires a persisted current session.";
30
+ }
31
+
32
+ if (process.platform !== "darwin") {
33
+ return GHOSTTY_MACOS_ONLY_MESSAGE;
34
+ }
35
+
36
+ if (process.env.TERM_PROGRAM !== "ghostty") {
37
+ return GHOSTTY_REQUIRED_MESSAGE;
38
+ }
39
+
40
+ return undefined;
41
+ }
42
+
43
+ export function createHandoffSession(options: {
44
+ cwd: string;
45
+ sessionDir: string;
46
+ parentSessionFile: string;
47
+ }): CreatedHandoffSession {
48
+ const sessionId = randomUUID();
49
+ const timestamp = new Date().toISOString();
50
+ const fileTimestamp = timestamp.replace(/[:.]/g, "-");
51
+ const sessionFile = join(options.sessionDir, `${fileTimestamp}_${sessionId}.jsonl`);
52
+
53
+ const header: SessionHeader = {
54
+ type: "session",
55
+ version: CURRENT_SESSION_VERSION,
56
+ id: sessionId,
57
+ timestamp,
58
+ cwd: options.cwd,
59
+ parentSession: options.parentSessionFile,
60
+ };
61
+
62
+ writeFileSync(sessionFile, `${JSON.stringify(header)}\n`);
63
+
64
+ return { sessionId, sessionFile };
65
+ }
66
+
67
+ export async function launchSplitHandoffSession(
68
+ pi: ExtensionAPI,
69
+ options: {
70
+ cwd: string;
71
+ sessionDir: string;
72
+ direction: HandoffSplitDirection;
73
+ sessionId: string;
74
+ bootstrapValue: string;
75
+ },
76
+ ): Promise<{ success: true } | { success: false; error: string }> {
77
+ const piCommand = buildPiLaunchCommand(
78
+ options.sessionDir,
79
+ options.sessionId,
80
+ options.bootstrapValue,
81
+ );
82
+ const escapedCwd = escapeAppleScriptString(options.cwd);
83
+ const escapedCommand = escapeAppleScriptString(piCommand);
84
+ const appleScript = [
85
+ 'tell application "Ghostty"',
86
+ " set targetTerminal to focused terminal of selected tab of front window",
87
+ " set cfg to new surface configuration",
88
+ ` set initial working directory of cfg to "${escapedCwd}"`,
89
+ ` set command of cfg to "${escapedCommand}"`,
90
+ ` set newTerminal to split targetTerminal direction ${options.direction} with configuration cfg`,
91
+ " focus targetTerminal",
92
+ "end tell",
93
+ ].join("\n");
94
+ const result = await pi.exec(OSASCRIPT_PATH, ["-e", appleScript], {
95
+ cwd: options.cwd,
96
+ timeout: GHOSTTY_SPLIT_TIMEOUT_MS,
97
+ });
98
+
99
+ if (result.code === 0) {
100
+ return { success: true };
101
+ }
102
+
103
+ const details = `${result.stderr || result.stdout}`.trim() || `exit code ${result.code}`;
104
+ return {
105
+ success: false,
106
+ error:
107
+ `Failed to launch Ghostty split: ${details}. ` +
108
+ "Split handoff currently supports Ghostty on macOS only.",
109
+ };
110
+ }
111
+
112
+ export function buildPiResumeCommand(
113
+ sessionDir: string,
114
+ sessionId: string,
115
+ bootstrapValue: string,
116
+ ): string {
117
+ return [
118
+ `${HANDOFF_BOOTSTRAP_ENV}=${shellQuote(bootstrapValue)}`,
119
+ "pi",
120
+ "--session-dir",
121
+ shellQuote(sessionDir),
122
+ "--session",
123
+ shellQuote(sessionId),
124
+ ].join(" ");
125
+ }
126
+
127
+ export function buildPiLaunchCommand(
128
+ sessionDir: string,
129
+ sessionId: string,
130
+ bootstrapValue: string,
131
+ ): string {
132
+ const payload = `${buildPiResumeCommand(sessionDir, sessionId, bootstrapValue)}; exec /bin/zsh -il`;
133
+ return `/bin/zsh -ilc ${shellQuote(payload)}`;
134
+ }
135
+
136
+ function escapeAppleScriptString(value: string): string {
137
+ return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
138
+ }
139
+
140
+ function shellQuote(value: string): string {
141
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
142
+ }
@@ -1,23 +1,32 @@
1
- import type {
2
- ExtensionAPI,
3
- ExtensionCommandContext,
4
- SessionManager,
5
- } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
6
2
  import { buildSessionContext } from "@mariozechner/pi-coding-agent";
7
3
  import { Key, matchesKey } from "@mariozechner/pi-tui";
8
4
  import { generateHandoffDraft, type HandoffDraftResult } from "./session-handoff/extract.js";
9
5
  import {
6
+ createHandoffBootstrap,
10
7
  createHandoffSessionMetadata,
11
- createPendingSendConsumedEntry,
12
- getPendingInitialPromptFromEntries,
8
+ encodeHandoffBootstrap,
9
+ getHandoffMetadataFromEntries,
10
+ HANDOFF_BOOTSTRAP_ENV,
13
11
  HANDOFF_METADATA_CUSTOM_TYPE,
14
- PENDING_SEND_CONSUMED_CUSTOM_TYPE,
12
+ HANDOFF_STALE_SESSION_MESSAGE,
13
+ hasUserMessages,
14
+ parseHandoffBootstrap,
15
15
  } from "./session-handoff/metadata.js";
16
16
  import { openSessionReferencePicker } from "./session-handoff/picker.js";
17
17
  import { SESSION_TOKEN_PREFIX } from "./session-handoff/query.js";
18
18
  import { renderStrongModal, reviewHandoffDraft } from "./session-handoff/review.js";
19
+ import {
20
+ buildPiResumeCommand,
21
+ createHandoffSession,
22
+ type HandoffSplitDirection,
23
+ launchSplitHandoffSession,
24
+ validateSplitHandoffPrerequisites,
25
+ } from "./session-handoff/spawn.js";
19
26
  import { loadSettings } from "./shared/settings.js";
20
27
 
28
+ const HANDOFF_USAGE = "Usage: /handoff [--left|--right|--up|--down] <goal for new thread>";
29
+
21
30
  export default function sessionHandoffExtension(pi: ExtensionAPI): void {
22
31
  const settings = loadSettings();
23
32
 
@@ -34,9 +43,9 @@ export default function sessionHandoffExtension(pi: ExtensionAPI): void {
34
43
  return;
35
44
  }
36
45
 
37
- const goal = args.trim();
38
- if (!goal) {
39
- ctx.ui.notify("Usage: /handoff <goal for new thread>", "error");
46
+ const parsedArgs = parseHandoffCommandArgs(args);
47
+ if (parsedArgs.kind === "error") {
48
+ ctx.ui.notify(parsedArgs.message, "error");
40
49
  return;
41
50
  }
42
51
 
@@ -49,12 +58,20 @@ export default function sessionHandoffExtension(pi: ExtensionAPI): void {
49
58
  return;
50
59
  }
51
60
 
61
+ if (parsedArgs.splitDirection) {
62
+ const preflightError = await validateSplitHandoffPrerequisites(pi, ctx);
63
+ if (preflightError) {
64
+ ctx.ui.notify(preflightError, "error");
65
+ return;
66
+ }
67
+ }
68
+
52
69
  let generatedDraft: HandoffDraftResult | undefined;
53
70
  try {
54
71
  generatedDraft = await runWithLoader(
55
72
  ctx,
56
73
  "Generating handoff draft...",
57
- async (signal: AbortSignal) => generateHandoffDraft(ctx, goal, signal),
74
+ async (signal: AbortSignal) => generateHandoffDraft(ctx, parsedArgs.goal, signal),
58
75
  );
59
76
  } catch (error) {
60
77
  ctx.ui.notify(formatHandoffError(error), "error");
@@ -72,22 +89,59 @@ export default function sessionHandoffExtension(pi: ExtensionAPI): void {
72
89
  return;
73
90
  }
74
91
 
75
- const parentSession = ctx.sessionManager.getSessionFile();
92
+ const parentSessionFile = ctx.sessionManager.getSessionFile();
93
+ if (!parentSessionFile) {
94
+ ctx.ui.notify("Handoff requires a persisted current session.", "error");
95
+ return;
96
+ }
76
97
 
77
98
  const handoffMetadata = createHandoffSessionMetadata(
78
- goal,
99
+ parsedArgs.goal,
79
100
  generatedDraft.context.nextTask,
80
101
  approvedDraft,
81
102
  );
82
- const writeMetadata = async (sm: SessionManager) => {
83
- sm.appendCustomEntry(HANDOFF_METADATA_CUSTOM_TYPE, handoffMetadata);
84
- };
85
- const newSessionResult = parentSession
86
- ? await ctx.newSession({ parentSession, setup: writeMetadata })
87
- : await ctx.newSession({ setup: writeMetadata });
103
+ const createdSession = createHandoffSession({
104
+ cwd: ctx.cwd,
105
+ sessionDir: ctx.sessionManager.getSessionDir(),
106
+ parentSessionFile,
107
+ });
108
+ const bootstrapValue = encodeHandoffBootstrap(
109
+ createHandoffBootstrap(createdSession.sessionId, handoffMetadata),
110
+ );
111
+
112
+ if (parsedArgs.splitDirection) {
113
+ const launchResult = await launchSplitHandoffSession(pi, {
114
+ cwd: ctx.cwd,
115
+ sessionDir: ctx.sessionManager.getSessionDir(),
116
+ direction: parsedArgs.splitDirection,
117
+ sessionId: createdSession.sessionId,
118
+ bootstrapValue,
119
+ });
120
+
121
+ if (!launchResult.success) {
122
+ ctx.ui.notify(
123
+ `${launchResult.error} Created handoff session ${createdSession.sessionId}; start it manually with: ${buildPiResumeCommand(ctx.sessionManager.getSessionDir(), createdSession.sessionId, bootstrapValue)}`,
124
+ "error",
125
+ );
126
+ return;
127
+ }
128
+
129
+ ctx.ui.notify(`Handoff started in a new pane (${parsedArgs.splitDirection}).`, "info");
130
+ return;
131
+ }
88
132
 
89
- if (newSessionResult.cancelled) {
90
- ctx.ui.notify("New session cancelled", "info");
133
+ const previousBootstrapValue = process.env[HANDOFF_BOOTSTRAP_ENV];
134
+ process.env[HANDOFF_BOOTSTRAP_ENV] = bootstrapValue;
135
+
136
+ let switchResult: { cancelled: boolean };
137
+ try {
138
+ switchResult = await ctx.switchSession(createdSession.sessionFile);
139
+ } finally {
140
+ restoreProcessEnv(HANDOFF_BOOTSTRAP_ENV, previousBootstrapValue);
141
+ }
142
+
143
+ if (switchResult.cancelled) {
144
+ ctx.ui.notify("Session switch cancelled", "info");
91
145
  return;
92
146
  }
93
147
 
@@ -116,24 +170,48 @@ export default function sessionHandoffExtension(pi: ExtensionAPI): void {
116
170
  });
117
171
 
118
172
  pi.on("session_start", async (_event, ctx) => {
119
- const sessionFile = ctx.sessionManager.getSessionFile();
120
- const pending = getPendingInitialPromptFromEntries(ctx.sessionManager.getEntries());
173
+ const encodedBootstrap = process.env[HANDOFF_BOOTSTRAP_ENV];
174
+ if (!encodedBootstrap) {
175
+ return;
176
+ }
177
+
178
+ const bootstrap = parseHandoffBootstrap(encodedBootstrap);
179
+ if (!bootstrap) {
180
+ delete process.env[HANDOFF_BOOTSTRAP_ENV];
181
+ return;
182
+ }
121
183
 
122
- if (!sessionFile || !pending) {
184
+ if (bootstrap.sessionId !== ctx.sessionManager.getSessionId()) {
123
185
  return;
124
186
  }
125
187
 
126
- pi.appendEntry(
127
- PENDING_SEND_CONSUMED_CUSTOM_TYPE,
128
- createPendingSendConsumedEntry(pending.initial_prompt_nonce),
129
- );
130
- pi.sendUserMessage(pending.initial_prompt);
188
+ try {
189
+ const entries = ctx.sessionManager.getEntries();
190
+ if (hasUserMessages(entries)) {
191
+ if (ctx.hasUI) {
192
+ ctx.ui.notify(HANDOFF_STALE_SESSION_MESSAGE, "error");
193
+ }
194
+ return;
195
+ }
196
+
197
+ if (!getHandoffMetadataFromEntries(entries)) {
198
+ pi.appendEntry(
199
+ HANDOFF_METADATA_CUSTOM_TYPE,
200
+ createHandoffSessionMetadata(bootstrap.goal, bootstrap.nextTask, bootstrap.initialPrompt),
201
+ );
202
+ }
203
+
204
+ pi.sendUserMessage(bootstrap.initialPrompt);
205
+ } finally {
206
+ delete process.env[HANDOFF_BOOTSTRAP_ENV];
207
+ }
131
208
  });
132
209
 
133
- pi.on("before_agent_start", async () => {
210
+ pi.on("before_agent_start", async (event) => {
134
211
  return {
135
212
  systemPrompt:
136
- "When the user references @session:<uuid>, treat it as a session token. If you call session_ask, pass only the UUID value, not the @session: prefix.",
213
+ event.systemPrompt +
214
+ "\n\nWhen the user references @session:<uuid>, treat it as a session token. If you call session_ask, pass only the UUID value, not the @session: prefix.",
137
215
  };
138
216
  });
139
217
  }
@@ -201,3 +279,65 @@ function formatHandoffError(error: unknown): string {
201
279
 
202
280
  return "Handoff generation failed.";
203
281
  }
282
+
283
+ function parseHandoffCommandArgs(
284
+ args: string,
285
+ ):
286
+ | { kind: "ok"; goal: string; splitDirection?: HandoffSplitDirection | undefined }
287
+ | { kind: "error"; message: string } {
288
+ const tokens = args
289
+ .trim()
290
+ .split(/\s+/)
291
+ .filter((token) => token.length > 0);
292
+
293
+ if (tokens.length === 0) {
294
+ return { kind: "error", message: HANDOFF_USAGE };
295
+ }
296
+
297
+ const directionFlags = new Map<string, HandoffSplitDirection>([
298
+ ["--left", "left"],
299
+ ["--right", "right"],
300
+ ["--up", "up"],
301
+ ["--down", "down"],
302
+ ]);
303
+
304
+ let splitDirection: HandoffSplitDirection | undefined;
305
+ const goalTokens: string[] = [];
306
+
307
+ for (const token of tokens) {
308
+ const direction = directionFlags.get(token);
309
+ if (!direction) {
310
+ goalTokens.push(token);
311
+ continue;
312
+ }
313
+
314
+ if (splitDirection) {
315
+ return {
316
+ kind: "error",
317
+ message: "Use only one split flag: --left, --right, --up, or --down.",
318
+ };
319
+ }
320
+
321
+ splitDirection = direction;
322
+ }
323
+
324
+ const goal = goalTokens.join(" ").trim();
325
+ if (!goal) {
326
+ return { kind: "error", message: HANDOFF_USAGE };
327
+ }
328
+
329
+ return {
330
+ kind: "ok",
331
+ goal,
332
+ splitDirection,
333
+ };
334
+ }
335
+
336
+ function restoreProcessEnv(key: string, previousValue: string | undefined): void {
337
+ if (previousValue === undefined) {
338
+ delete process.env[key];
339
+ return;
340
+ }
341
+
342
+ process.env[key] = previousValue;
343
+ }
@@ -1,4 +1,5 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { HANDOFF_BOOTSTRAP_ENV, parseHandoffBootstrap } from "./session-handoff/metadata.js";
2
3
  import { createSessionHookController } from "./session-search/hooks.js";
3
4
  import { loadSettings } from "./shared/settings.js";
4
5
 
@@ -18,7 +19,12 @@ export default function sessionHooksExtension(pi: ExtensionAPI): void {
18
19
  switch (reason) {
19
20
  case "new":
20
21
  case "resume":
21
- await controller.handleSessionSwitch(previousSessionFile, sessionFile, ctx.cwd);
22
+ await controller.handleSessionSwitch(
23
+ previousSessionFile,
24
+ sessionFile,
25
+ ctx.cwd,
26
+ getSessionStartOrigin(ctx),
27
+ );
22
28
  break;
23
29
  case "fork":
24
30
  await controller.handleSessionFork(previousSessionFile, sessionFile, ctx.cwd);
@@ -53,3 +59,17 @@ export default function sessionHooksExtension(pi: ExtensionAPI): void {
53
59
  await controller.handleSessionShutdown(ctx.sessionManager.getSessionFile(), ctx.cwd);
54
60
  });
55
61
  }
62
+
63
+ function getSessionStartOrigin(ctx: ExtensionContext): "handoff" | undefined {
64
+ const encodedBootstrap = process.env[HANDOFF_BOOTSTRAP_ENV];
65
+ if (!encodedBootstrap) {
66
+ return undefined;
67
+ }
68
+
69
+ const bootstrap = parseHandoffBootstrap(encodedBootstrap);
70
+ if (!bootstrap) {
71
+ return undefined;
72
+ }
73
+
74
+ return bootstrap.sessionId === ctx.sessionManager.getSessionId() ? "handoff" : undefined;
75
+ }
@@ -157,7 +157,7 @@ export function extractSessionRecord(sessionPath: string): ExtractedSessionRecor
157
157
  }
158
158
  case "custom": {
159
159
  const nextHandoffMetadata = extractHandoffMetadata(entry, entryTs);
160
- if (nextHandoffMetadata) {
160
+ if (nextHandoffMetadata && !durableHandoffMetadata) {
161
161
  durableHandoffMetadata = nextHandoffMetadata;
162
162
  }
163
163
  continue;
@@ -282,7 +282,7 @@ function findDurableHandoffMetadata(
282
282
  }
283
283
 
284
284
  const parsed = extractHandoffMetadata(entry, getEntryTimestamp(entry, fallbackTs));
285
- if (parsed) {
285
+ if (parsed && !durableHandoffMetadata) {
286
286
  durableHandoffMetadata = parsed;
287
287
  }
288
288
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sessions",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Pi session search, ask, handoff, auto-titling, and indexing tools",
6
6
  "license": "MIT",
@@ -38,27 +38,28 @@
38
38
  "prepare": "husky"
39
39
  },
40
40
  "devDependencies": {
41
- "@biomejs/biome": "^2.4.11",
42
- "@mariozechner/pi-ai": "^0.66.1",
43
- "@mariozechner/pi-coding-agent": "^0.66.1",
44
- "@mariozechner/pi-tui": "^0.66.1",
41
+ "@biomejs/biome": "^2.4.12",
42
+ "@mariozechner/pi-ai": "^0.67.68",
43
+ "@mariozechner/pi-coding-agent": "^0.67.68",
44
+ "@mariozechner/pi-tui": "^0.67.68",
45
+ "@sinclair/typebox": "^0.34.49",
45
46
  "@types/better-sqlite3": "^7.6.13",
46
47
  "@types/node": "^25.6.0",
47
48
  "husky": "^9.1.7",
48
49
  "lint-staged": "^16.4.0",
49
- "typescript": "^6.0.2",
50
+ "typescript": "^6.0.3",
50
51
  "vitest": "^4.1.4"
51
52
  },
52
- "dependencies": {
53
- "better-sqlite3": "^12.8.0"
54
- },
55
53
  "peerDependencies": {
56
- "@mariozechner/pi-ai": ">=0.66.1",
57
- "@mariozechner/pi-coding-agent": ">=0.66.1",
58
- "@mariozechner/pi-tui": ">=0.66.1",
59
- "@sinclair/typebox": ">=0.34.49"
54
+ "@mariozechner/pi-ai": ">=0.67.68",
55
+ "@mariozechner/pi-coding-agent": ">=0.67.68",
56
+ "@mariozechner/pi-tui": ">=0.67.68",
57
+ "@sinclair/typebox": ">=0.34.41"
60
58
  },
61
59
  "lint-staged": {
62
60
  "*.{js,cjs,mjs,jsx,ts,tsx,json,jsonc}": "biome check --write --no-errors-on-unmatched"
61
+ },
62
+ "dependencies": {
63
+ "better-sqlite3": "^12.9.0"
63
64
  }
64
65
  }