pi-sessions 0.1.0 → 0.2.1

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,26 +89,57 @@ 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
+ }
88
128
 
89
- if (newSessionResult.cancelled) {
90
- ctx.ui.notify("New session cancelled", "info");
129
+ ctx.ui.notify(`Handoff started in a new pane (${parsedArgs.splitDirection}).`, "info");
91
130
  return;
92
131
  }
93
132
 
94
- ctx.ui.notify("Handoff started in a new session.", "info");
133
+ const switchResult = await ctx.switchSession(createdSession.sessionFile, {
134
+ withSession: async (nextCtx) => {
135
+ await nextCtx.sendUserMessage(approvedDraft);
136
+ nextCtx.ui.notify("Handoff started in a new session.", "info");
137
+ },
138
+ });
139
+
140
+ if (switchResult.cancelled) {
141
+ ctx.ui.notify("Session switch cancelled", "info");
142
+ }
95
143
  },
96
144
  });
97
145
 
@@ -116,24 +164,48 @@ export default function sessionHandoffExtension(pi: ExtensionAPI): void {
116
164
  });
117
165
 
118
166
  pi.on("session_start", async (_event, ctx) => {
119
- const sessionFile = ctx.sessionManager.getSessionFile();
120
- const pending = getPendingInitialPromptFromEntries(ctx.sessionManager.getEntries());
167
+ const encodedBootstrap = process.env[HANDOFF_BOOTSTRAP_ENV];
168
+ if (!encodedBootstrap) {
169
+ return;
170
+ }
121
171
 
122
- if (!sessionFile || !pending) {
172
+ const bootstrap = parseHandoffBootstrap(encodedBootstrap);
173
+ if (!bootstrap) {
174
+ delete process.env[HANDOFF_BOOTSTRAP_ENV];
123
175
  return;
124
176
  }
125
177
 
126
- pi.appendEntry(
127
- PENDING_SEND_CONSUMED_CUSTOM_TYPE,
128
- createPendingSendConsumedEntry(pending.initial_prompt_nonce),
129
- );
130
- pi.sendUserMessage(pending.initial_prompt);
178
+ if (bootstrap.sessionId !== ctx.sessionManager.getSessionId()) {
179
+ return;
180
+ }
181
+
182
+ try {
183
+ const entries = ctx.sessionManager.getEntries();
184
+ if (hasUserMessages(entries)) {
185
+ if (ctx.hasUI) {
186
+ ctx.ui.notify(HANDOFF_STALE_SESSION_MESSAGE, "error");
187
+ }
188
+ return;
189
+ }
190
+
191
+ if (!getHandoffMetadataFromEntries(entries)) {
192
+ pi.appendEntry(
193
+ HANDOFF_METADATA_CUSTOM_TYPE,
194
+ createHandoffSessionMetadata(bootstrap.goal, bootstrap.nextTask, bootstrap.initialPrompt),
195
+ );
196
+ }
197
+
198
+ pi.sendUserMessage(bootstrap.initialPrompt);
199
+ } finally {
200
+ delete process.env[HANDOFF_BOOTSTRAP_ENV];
201
+ }
131
202
  });
132
203
 
133
- pi.on("before_agent_start", async () => {
204
+ pi.on("before_agent_start", async (event) => {
134
205
  return {
135
206
  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.",
207
+ event.systemPrompt +
208
+ "\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
209
  };
138
210
  });
139
211
  }
@@ -201,3 +273,56 @@ function formatHandoffError(error: unknown): string {
201
273
 
202
274
  return "Handoff generation failed.";
203
275
  }
276
+
277
+ function parseHandoffCommandArgs(
278
+ args: string,
279
+ ):
280
+ | { kind: "ok"; goal: string; splitDirection?: HandoffSplitDirection | undefined }
281
+ | { kind: "error"; message: string } {
282
+ const tokens = args
283
+ .trim()
284
+ .split(/\s+/)
285
+ .filter((token) => token.length > 0);
286
+
287
+ if (tokens.length === 0) {
288
+ return { kind: "error", message: HANDOFF_USAGE };
289
+ }
290
+
291
+ const directionFlags = new Map<string, HandoffSplitDirection>([
292
+ ["--left", "left"],
293
+ ["--right", "right"],
294
+ ["--up", "up"],
295
+ ["--down", "down"],
296
+ ]);
297
+
298
+ let splitDirection: HandoffSplitDirection | undefined;
299
+ const goalTokens: string[] = [];
300
+
301
+ for (const token of tokens) {
302
+ const direction = directionFlags.get(token);
303
+ if (!direction) {
304
+ goalTokens.push(token);
305
+ continue;
306
+ }
307
+
308
+ if (splitDirection) {
309
+ return {
310
+ kind: "error",
311
+ message: "Use only one split flag: --left, --right, --up, or --down.",
312
+ };
313
+ }
314
+
315
+ splitDirection = direction;
316
+ }
317
+
318
+ const goal = goalTokens.join(" ").trim();
319
+ if (!goal) {
320
+ return { kind: "error", message: HANDOFF_USAGE };
321
+ }
322
+
323
+ return {
324
+ kind: "ok",
325
+ goal,
326
+ splitDirection,
327
+ };
328
+ }
@@ -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
  }
@@ -22,6 +22,9 @@ import {
22
22
  import { type ExtractedSessionRecord, extractSessionRecord } from "./extract.js";
23
23
 
24
24
  const TOOL_RESULT_TEXT_LIMIT = 500;
25
+ const HOOK_INDEX_WRITE_TIMEOUT_MS = 500;
26
+ const HOOK_INDEX_WRITE_MAX_ATTEMPTS = 4;
27
+ const HOOK_INDEX_WRITE_RETRY_DELAYS_MS = [150, 400, 1000];
25
28
 
26
29
  type TrackedToolName = "read" | "edit" | "write";
27
30
 
@@ -96,15 +99,24 @@ export function createSessionHookController(options: { indexPath: string }): Ses
96
99
  return syncAttachedSession(indexPath, state, "session_start");
97
100
  },
98
101
  async handleSessionSwitch(previousSessionFile, sessionFile, cwd, sessionOrigin) {
99
- const previousSynced = syncSessionFile(indexPath, previousSessionFile, "session_switch");
102
+ const previousSynced = await syncSessionFile(
103
+ indexPath,
104
+ previousSessionFile,
105
+ "session_switch",
106
+ );
100
107
  attachSession(state, sessionFile, cwd);
101
- const currentSynced = syncAttachedSession(indexPath, state, "session_switch", sessionOrigin);
108
+ const currentSynced = await syncAttachedSession(
109
+ indexPath,
110
+ state,
111
+ "session_switch",
112
+ sessionOrigin,
113
+ );
102
114
  return previousSynced || currentSynced;
103
115
  },
104
116
  async handleSessionFork(previousSessionFile, sessionFile, cwd) {
105
- const previousSynced = syncSessionFile(indexPath, previousSessionFile, "session_fork");
117
+ const previousSynced = await syncSessionFile(indexPath, previousSessionFile, "session_fork");
106
118
  attachSession(state, sessionFile, cwd);
107
- const currentSynced = syncAttachedSession(indexPath, state, "session_fork", "fork");
119
+ const currentSynced = await syncAttachedSession(indexPath, state, "session_fork", "fork");
108
120
  return previousSynced || currentSynced;
109
121
  },
110
122
  handleToolCall(event, sessionFile, cwd) {
@@ -131,9 +143,11 @@ export function createSessionHookController(options: { indexPath: string }): Ses
131
143
  },
132
144
  async handleTurnEnd(sessionFile, cwd) {
133
145
  attachSession(state, sessionFile, cwd);
134
- const synced = syncAttachedSession(indexPath, state, "turn_end");
135
- clearTurnState(state);
136
- return synced;
146
+ try {
147
+ return await syncAttachedSession(indexPath, state, "turn_end");
148
+ } finally {
149
+ clearTurnState(state);
150
+ }
137
151
  },
138
152
  async handleSessionTree(sessionFile, cwd) {
139
153
  attachSession(state, sessionFile, cwd);
@@ -145,11 +159,13 @@ export function createSessionHookController(options: { indexPath: string }): Ses
145
159
  },
146
160
  async handleSessionShutdown(sessionFile, cwd) {
147
161
  attachSession(state, sessionFile, cwd);
148
- const synced = syncAttachedSession(indexPath, state, "session_shutdown");
149
- clearTurnState(state);
150
- state.currentSessionFile = undefined;
151
- state.currentCwd = undefined;
152
- return synced;
162
+ try {
163
+ return await syncAttachedSession(indexPath, state, "session_shutdown");
164
+ } finally {
165
+ clearTurnState(state);
166
+ state.currentSessionFile = undefined;
167
+ state.currentCwd = undefined;
168
+ }
153
169
  },
154
170
  };
155
171
  }
@@ -166,22 +182,66 @@ function attachSession(
166
182
  state.currentCwd = cwd;
167
183
  }
168
184
 
185
+ async function retryIndexWrite<T>(action: () => T): Promise<T> {
186
+ for (let attempt = 1; attempt <= HOOK_INDEX_WRITE_MAX_ATTEMPTS; attempt += 1) {
187
+ try {
188
+ return action();
189
+ } catch (error) {
190
+ if (!isRetryableSqliteLock(error) || attempt >= HOOK_INDEX_WRITE_MAX_ATTEMPTS) {
191
+ throw error;
192
+ }
193
+
194
+ await sleep(getRetryDelayMs(attempt));
195
+ }
196
+ }
197
+
198
+ return action();
199
+ }
200
+
201
+ function getRetryDelayMs(attempt: number): number {
202
+ return (
203
+ HOOK_INDEX_WRITE_RETRY_DELAYS_MS[attempt - 1] ??
204
+ HOOK_INDEX_WRITE_RETRY_DELAYS_MS[HOOK_INDEX_WRITE_RETRY_DELAYS_MS.length - 1] ??
205
+ 0
206
+ );
207
+ }
208
+
209
+ function isRetryableSqliteLock(error: unknown): boolean {
210
+ if (!(error instanceof Error)) {
211
+ return false;
212
+ }
213
+
214
+ const code = "code" in error && typeof error.code === "string" ? error.code : undefined;
215
+ const message = error.message.toLowerCase();
216
+ return (
217
+ code === "SQLITE_BUSY" ||
218
+ code === "SQLITE_LOCKED" ||
219
+ code?.startsWith("SQLITE_BUSY_") === true ||
220
+ code?.startsWith("SQLITE_LOCKED_") === true ||
221
+ message.includes("database is locked")
222
+ );
223
+ }
224
+
225
+ function sleep(ms: number): Promise<void> {
226
+ return new Promise((resolve) => setTimeout(resolve, ms));
227
+ }
228
+
169
229
  function syncAttachedSession(
170
230
  indexPath: string,
171
231
  state: SessionHookState,
172
232
  eventType: string,
173
233
  sessionOrigin?: SessionOrigin,
174
- ): boolean {
234
+ ): Promise<boolean> {
175
235
  return syncSessionFile(indexPath, state.currentSessionFile, eventType, state, sessionOrigin);
176
236
  }
177
237
 
178
- function syncSessionFile(
238
+ async function syncSessionFile(
179
239
  indexPath: string,
180
240
  sessionFile: string | undefined,
181
241
  eventType: string,
182
242
  state?: SessionHookState,
183
243
  sessionOrigin?: SessionOrigin,
184
- ): boolean {
244
+ ): Promise<boolean> {
185
245
  if (!sessionFile || !existsSync(sessionFile)) {
186
246
  return false;
187
247
  }
@@ -196,31 +256,36 @@ function syncSessionFile(
196
256
  return false;
197
257
  }
198
258
 
199
- const db = openIndexDatabase(indexPath, { create: false });
200
- try {
201
- db.transaction(() => {
202
- const existingSession = getSessionById(db, extracted.sessionId);
203
- const sessionRow = mergeSessionLineage(extracted, existingSession, sessionOrigin);
204
- upsertSession(db, sessionRow, "hook");
205
- if (shouldRefreshLineageRelations(existingSession, sessionRow)) {
206
- rebuildSessionLineageRelations(db);
207
- }
208
- clearSessionIndexedData(db, extracted.sessionId);
209
-
210
- for (const chunk of extracted.chunks) {
211
- insertTextChunk(db, { sessionId: extracted.sessionId, ...chunk });
212
- }
213
-
214
- for (const fileTouch of extracted.fileTouches) {
215
- insertSessionFileTouch(db, { sessionId: extracted.sessionId, ...fileTouch });
216
- }
217
-
218
- setMetadata(db, "hook_updated_at", new Date().toISOString());
219
- setMetadata(db, "hook_last_event", eventType);
220
- })();
221
- } finally {
222
- db.close();
223
- }
259
+ await retryIndexWrite(() => {
260
+ const db = openIndexDatabase(indexPath, {
261
+ create: false,
262
+ timeoutMs: HOOK_INDEX_WRITE_TIMEOUT_MS,
263
+ });
264
+ try {
265
+ db.transaction(() => {
266
+ const existingSession = getSessionById(db, extracted.sessionId);
267
+ const sessionRow = mergeSessionLineage(extracted, existingSession, sessionOrigin);
268
+ upsertSession(db, sessionRow, "hook");
269
+ if (shouldRefreshLineageRelations(existingSession, sessionRow)) {
270
+ rebuildSessionLineageRelations(db);
271
+ }
272
+ clearSessionIndexedData(db, extracted.sessionId);
273
+
274
+ for (const chunk of extracted.chunks) {
275
+ insertTextChunk(db, { sessionId: extracted.sessionId, ...chunk });
276
+ }
277
+
278
+ for (const fileTouch of extracted.fileTouches) {
279
+ insertSessionFileTouch(db, { sessionId: extracted.sessionId, ...fileTouch });
280
+ }
281
+
282
+ setMetadata(db, "hook_updated_at", new Date().toISOString());
283
+ setMetadata(db, "hook_last_event", eventType);
284
+ })();
285
+ } finally {
286
+ db.close();
287
+ }
288
+ });
224
289
 
225
290
  if (state) {
226
291
  state.lastFlushedSessionFile = sessionFile;
@@ -26,10 +26,15 @@ export function createTempIndexPath(finalPath: string): string {
26
26
 
27
27
  export function openIndexDatabase(
28
28
  dbPath: string,
29
- options?: { create?: boolean },
29
+ options?: { create?: boolean; timeoutMs?: number },
30
30
  ): SessionIndexDatabase {
31
31
  const create = options?.create ?? true;
32
- const db = new Database(dbPath, { fileMustExist: !create });
32
+ const db = new Database(
33
+ dbPath,
34
+ options?.timeoutMs === undefined
35
+ ? { fileMustExist: !create }
36
+ : { fileMustExist: !create, timeout: options.timeoutMs },
37
+ );
33
38
  db.pragma("journal_mode = WAL");
34
39
  db.pragma("synchronous = NORMAL");
35
40
  db.pragma("foreign_keys = ON");
@@ -1,6 +1,6 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
- import { getAgentDir, SettingsManager } from "@mariozechner/pi-coding-agent";
3
+ import { SettingsManager } from "@mariozechner/pi-coding-agent";
4
4
  import type { KeyId } from "@mariozechner/pi-tui";
5
5
  import { type Static, Type } from "@sinclair/typebox";
6
6
  import { parseTypeBoxValue } from "./typebox.js";
@@ -114,7 +114,7 @@ function parseModelReference(value: string | undefined): ModelReference | undefi
114
114
  }
115
115
 
116
116
  function loadSessionFileSettings(): SessionFileSettings {
117
- const globalSettings = SettingsManager.create(undefined, getAgentDir()).getGlobalSettings();
117
+ const globalSettings = SettingsManager.create(process.cwd()).getGlobalSettings();
118
118
  const parsed = parseTypeBoxValue(ROOT_SETTINGS_SCHEMA, globalSettings, "Invalid settings");
119
119
  return parsed.sessions ?? {};
120
120
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sessions",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
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.70.2",
43
+ "@mariozechner/pi-coding-agent": "^0.70.2",
44
+ "@mariozechner/pi-tui": "^0.70.2",
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.70.2",
55
+ "@mariozechner/pi-coding-agent": ">=0.70.2",
56
+ "@mariozechner/pi-tui": ">=0.70.2",
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
  }