pi-sessions 0.2.2 → 0.3.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.
@@ -18,6 +18,7 @@ import { parseTypeBoxValue } from "../shared/typebox.js";
18
18
 
19
19
  const MAX_RELEVANT_FILES = 12;
20
20
  const MAX_OPEN_QUESTIONS = 8;
21
+ const MAX_HANDOFF_TITLE_LENGTH = 64;
21
22
 
22
23
  const HANDOFF_SYSTEM_PROMPT = `You extract context for a deliberate session handoff.
23
24
 
@@ -27,12 +28,16 @@ Rules:
27
28
  - Extract only context that is relevant to the next task.
28
29
  - Keep the summary compact and concrete.
29
30
  - Prefer workspace-relative file paths when possible.
31
+ - title must be a short session title for the new handoff thread, 64 characters or less, without prefixes like "Handoff:" or otherwise referencing the current thread.
30
32
  - nextTask must be the concrete next action for the new session.
31
33
  - openQuestions should contain only unresolved items that materially affect the next task.
32
34
  - If there are no meaningful open questions, omit openQuestions entirely.
33
35
  - Do not write the final handoff prompt yourself.`;
34
36
 
35
37
  const HANDOFF_EXTRACTION_PARAMETERS = Type.Object({
38
+ title: Type.String({
39
+ description: "Short display title for the new handoff session.",
40
+ }),
36
41
  summary: Type.String({
37
42
  description: "Only the context relevant to the next task.",
38
43
  }),
@@ -58,11 +63,13 @@ const HANDOFF_EXTRACTION_TOOL: Tool<typeof HANDOFF_EXTRACTION_PARAMETERS> = {
58
63
  type RequiredHandoffExtractionArgs = Static<typeof REQUIRED_HANDOFF_EXTRACTION_PARAMETERS>;
59
64
 
60
65
  const REQUIRED_HANDOFF_EXTRACTION_PARAMETERS = Type.Object({
66
+ title: HANDOFF_EXTRACTION_PARAMETERS.properties.title,
61
67
  summary: HANDOFF_EXTRACTION_PARAMETERS.properties.summary,
62
68
  nextTask: HANDOFF_EXTRACTION_PARAMETERS.properties.nextTask,
63
69
  });
64
70
 
65
71
  export interface HandoffContext {
72
+ title: string;
66
73
  summary: string;
67
74
  relevantFiles: string[];
68
75
  nextTask: string;
@@ -139,11 +146,13 @@ export async function generateHandoffDraft(
139
146
  throw new Error(response.errorMessage ?? "Handoff generation failed.");
140
147
  }
141
148
 
142
- const handoffContext = extractHandoffContext(response, goal);
143
- if (!handoffContext) {
144
- throw new Error("Handoff extraction did not return structured context.");
149
+ const extraction = extractHandoffContext(response, goal);
150
+ if (!extraction.context) {
151
+ throw new Error(extraction.error);
145
152
  }
146
153
 
154
+ const handoffContext = extraction.context;
155
+
147
156
  const sessionId = ctx.sessionManager.getSessionId();
148
157
  const sessionPath = ctx.sessionManager.getSessionFile();
149
158
 
@@ -208,10 +217,10 @@ export function assembleHandoffDraft(
208
217
  export function extractHandoffContext(
209
218
  response: AssistantMessage,
210
219
  goal: string,
211
- ): HandoffContext | undefined {
220
+ ): { context: HandoffContext; error?: undefined } | { context?: undefined; error: string } {
212
221
  const toolCall = response.content.find(isCreateHandoffContextToolCall);
213
222
  if (!toolCall) {
214
- return undefined;
223
+ return { error: "Handoff extraction did not return structured context." };
215
224
  }
216
225
 
217
226
  let requiredArguments: RequiredHandoffExtractionArgs;
@@ -222,7 +231,12 @@ export function extractHandoffContext(
222
231
  "Invalid create_handoff_context arguments",
223
232
  );
224
233
  } catch {
225
- return undefined;
234
+ return { error: "Handoff extraction did not return structured context." };
235
+ }
236
+
237
+ const title = normalizeText(requiredArguments.title);
238
+ if (title.length > MAX_HANDOFF_TITLE_LENGTH) {
239
+ return { error: "Handoff title must be 64 characters or less." };
226
240
  }
227
241
 
228
242
  const summary = normalizeText(requiredArguments.summary);
@@ -230,15 +244,18 @@ export function extractHandoffContext(
230
244
  const nextTask = normalizeText(requiredArguments.nextTask) || goal.trim();
231
245
  const openQuestions = normalizeStringArray(toolCall.arguments.openQuestions, MAX_OPEN_QUESTIONS);
232
246
 
233
- if (!summary || !nextTask) {
234
- return undefined;
247
+ if (!summary || !nextTask || !title) {
248
+ return { error: "Handoff extraction did not return structured context." };
235
249
  }
236
250
 
237
251
  return {
238
- summary,
239
- relevantFiles,
240
- nextTask,
241
- openQuestions,
252
+ context: {
253
+ title,
254
+ summary,
255
+ relevantFiles,
256
+ nextTask,
257
+ openQuestions,
258
+ },
242
259
  };
243
260
  }
244
261
 
@@ -12,6 +12,7 @@ export const HANDOFF_SESSION_METADATA_SCHEMA = Type.Object({
12
12
  origin: Type.Literal("handoff"),
13
13
  goal: Type.String(),
14
14
  nextTask: Type.String(),
15
+ title: Type.String(),
15
16
  initial_prompt: Type.String(),
16
17
  });
17
18
 
@@ -19,6 +20,7 @@ export const HANDOFF_BOOTSTRAP_SCHEMA = Type.Object({
19
20
  sessionId: Type.String(),
20
21
  goal: Type.String(),
21
22
  nextTask: Type.String(),
23
+ title: Type.String(),
22
24
  initialPrompt: Type.String(),
23
25
  });
24
26
 
@@ -29,6 +31,7 @@ export function createHandoffSessionMetadata(
29
31
  goal: string,
30
32
  nextTask: string,
31
33
  initialPrompt: string,
34
+ title: string,
32
35
  ): HandoffSessionMetadata {
33
36
  const normalizedGoal = goal.trim();
34
37
  const normalizedNextTask = nextTask.trim() || normalizedGoal;
@@ -37,6 +40,7 @@ export function createHandoffSessionMetadata(
37
40
  origin: "handoff",
38
41
  goal: normalizedGoal,
39
42
  nextTask: normalizedNextTask,
43
+ title,
40
44
  initial_prompt: initialPrompt.trim(),
41
45
  };
42
46
  }
@@ -49,6 +53,7 @@ export function createHandoffBootstrap(
49
53
  sessionId,
50
54
  goal: metadata.goal,
51
55
  nextTask: metadata.nextTask,
56
+ title: metadata.title,
52
57
  initialPrompt: metadata.initial_prompt,
53
58
  };
54
59
  }
@@ -6,6 +6,7 @@ import {
6
6
  type ExtensionAPI,
7
7
  type ExtensionCommandContext,
8
8
  type SessionHeader,
9
+ type SessionInfoEntry,
9
10
  } from "@earendil-works/pi-coding-agent";
10
11
  import { HANDOFF_BOOTSTRAP_ENV } from "./metadata.js";
11
12
 
@@ -44,6 +45,7 @@ export function createHandoffSession(options: {
44
45
  cwd: string;
45
46
  sessionDir: string;
46
47
  parentSessionFile: string;
48
+ title: string;
47
49
  }): CreatedHandoffSession {
48
50
  const sessionId = randomUUID();
49
51
  const timestamp = new Date().toISOString();
@@ -59,7 +61,18 @@ export function createHandoffSession(options: {
59
61
  parentSession: options.parentSessionFile,
60
62
  };
61
63
 
62
- writeFileSync(sessionFile, `${JSON.stringify(header)}\n`);
64
+ const titleEntry: SessionInfoEntry = {
65
+ type: "session_info",
66
+ id: randomUUID(),
67
+ parentId: null,
68
+ timestamp,
69
+ name: options.title,
70
+ };
71
+
72
+ writeFileSync(
73
+ sessionFile,
74
+ `${[JSON.stringify(header), JSON.stringify(titleEntry)].join("\n")}\n`,
75
+ );
63
76
 
64
77
  return { sessionId, sessionFile };
65
78
  }
@@ -72,12 +85,14 @@ export async function launchSplitHandoffSession(
72
85
  direction: HandoffSplitDirection;
73
86
  sessionId: string;
74
87
  bootstrapValue: string;
88
+ title: string;
75
89
  },
76
90
  ): Promise<{ success: true } | { success: false; error: string }> {
77
91
  const piCommand = buildPiLaunchCommand(
78
92
  options.sessionDir,
79
93
  options.sessionId,
80
94
  options.bootstrapValue,
95
+ options.title,
81
96
  );
82
97
  const escapedCwd = escapeAppleScriptString(options.cwd);
83
98
  const escapedCommand = escapeAppleScriptString(piCommand);
@@ -113,23 +128,29 @@ export function buildPiResumeCommand(
113
128
  sessionDir: string,
114
129
  sessionId: string,
115
130
  bootstrapValue: string,
131
+ title: string,
116
132
  ): string {
117
- return [
133
+ const args = [
118
134
  `${HANDOFF_BOOTSTRAP_ENV}=${shellQuote(bootstrapValue)}`,
119
135
  "pi",
120
136
  "--session-dir",
121
137
  shellQuote(sessionDir),
122
- "--session",
138
+ "--session-id",
123
139
  shellQuote(sessionId),
124
- ].join(" ");
140
+ "--name",
141
+ shellQuote(title),
142
+ ];
143
+
144
+ return args.join(" ");
125
145
  }
126
146
 
127
147
  export function buildPiLaunchCommand(
128
148
  sessionDir: string,
129
149
  sessionId: string,
130
150
  bootstrapValue: string,
151
+ title: string,
131
152
  ): string {
132
- const payload = `${buildPiResumeCommand(sessionDir, sessionId, bootstrapValue)}; exec /bin/zsh -il`;
153
+ const payload = `${buildPiResumeCommand(sessionDir, sessionId, bootstrapValue, title)}; exec /bin/zsh -il`;
133
154
  return `/bin/zsh -ilc ${shellQuote(payload)}`;
134
155
  }
135
156
 
@@ -27,6 +27,11 @@ import { loadSettings } from "./shared/settings.js";
27
27
 
28
28
  const HANDOFF_USAGE = "Usage: /handoff [--left|--right|--up|--down] <goal for new thread>";
29
29
 
30
+ interface HandoffPromptContext {
31
+ ui: ExtensionCommandContext["ui"];
32
+ sendUserMessage(content: string): Promise<void>;
33
+ }
34
+
30
35
  export default function sessionHandoffExtension(pi: ExtensionAPI): void {
31
36
  const settings = loadSettings();
32
37
 
@@ -99,28 +104,30 @@ export default function sessionHandoffExtension(pi: ExtensionAPI): void {
99
104
  parsedArgs.goal,
100
105
  generatedDraft.context.nextTask,
101
106
  approvedDraft,
107
+ generatedDraft.context.title,
102
108
  );
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
109
  if (parsedArgs.splitDirection) {
110
+ const createdSession = createHandoffSession({
111
+ cwd: ctx.cwd,
112
+ sessionDir: ctx.sessionManager.getSessionDir(),
113
+ parentSessionFile,
114
+ title: handoffMetadata.title,
115
+ });
116
+ const bootstrapValue = encodeHandoffBootstrap(
117
+ createHandoffBootstrap(createdSession.sessionId, handoffMetadata),
118
+ );
113
119
  const launchResult = await launchSplitHandoffSession(pi, {
114
120
  cwd: ctx.cwd,
115
121
  sessionDir: ctx.sessionManager.getSessionDir(),
116
122
  direction: parsedArgs.splitDirection,
117
123
  sessionId: createdSession.sessionId,
118
124
  bootstrapValue,
125
+ title: handoffMetadata.title,
119
126
  });
120
127
 
121
128
  if (!launchResult.success) {
122
129
  ctx.ui.notify(
123
- `${launchResult.error} Created handoff session ${createdSession.sessionId}; start it manually with: ${buildPiResumeCommand(ctx.sessionManager.getSessionDir(), createdSession.sessionId, bootstrapValue)}`,
130
+ `${launchResult.error} Created handoff session ${createdSession.sessionId}; start it manually with: ${buildPiResumeCommand(ctx.sessionManager.getSessionDir(), createdSession.sessionId, bootstrapValue, handoffMetadata.title)}`,
124
131
  "error",
125
132
  );
126
133
  return;
@@ -130,10 +137,14 @@ export default function sessionHandoffExtension(pi: ExtensionAPI): void {
130
137
  return;
131
138
  }
132
139
 
133
- const switchResult = await ctx.switchSession(createdSession.sessionFile, {
140
+ const switchResult = await ctx.newSession({
141
+ parentSession: parentSessionFile,
142
+ setup: async (sessionManager) => {
143
+ sessionManager.appendSessionInfo(handoffMetadata.title);
144
+ sessionManager.appendCustomEntry(HANDOFF_METADATA_CUSTOM_TYPE, handoffMetadata);
145
+ },
134
146
  withSession: async (nextCtx) => {
135
- await nextCtx.sendUserMessage(approvedDraft);
136
- nextCtx.ui.notify("Handoff started in a new session.", "info");
147
+ startHandoffPromptAfterSessionRender(nextCtx, approvedDraft);
137
148
  },
138
149
  });
139
150
 
@@ -191,10 +202,19 @@ export default function sessionHandoffExtension(pi: ExtensionAPI): void {
191
202
  if (!getHandoffMetadataFromEntries(entries)) {
192
203
  pi.appendEntry(
193
204
  HANDOFF_METADATA_CUSTOM_TYPE,
194
- createHandoffSessionMetadata(bootstrap.goal, bootstrap.nextTask, bootstrap.initialPrompt),
205
+ createHandoffSessionMetadata(
206
+ bootstrap.goal,
207
+ bootstrap.nextTask,
208
+ bootstrap.initialPrompt,
209
+ bootstrap.title,
210
+ ),
195
211
  );
196
212
  }
197
213
 
214
+ if (!ctx.sessionManager.getSessionName()) {
215
+ pi.setSessionName(bootstrap.title);
216
+ }
217
+
198
218
  pi.sendUserMessage(bootstrap.initialPrompt);
199
219
  } finally {
200
220
  delete process.env[HANDOFF_BOOTSTRAP_ENV];
@@ -266,6 +286,23 @@ async function runWithLoader<T>(
266
286
  return result;
267
287
  }
268
288
 
289
+ function startHandoffPromptAfterSessionRender(
290
+ ctx: HandoffPromptContext,
291
+ approvedDraft: string,
292
+ ): void {
293
+ // ctx.newSession() renders the replacement session only after withSession returns.
294
+ setImmediate(() => {
295
+ void (async () => {
296
+ try {
297
+ await ctx.sendUserMessage(approvedDraft);
298
+ ctx.ui.notify("Handoff started in a new session.", "info");
299
+ } catch (error) {
300
+ ctx.ui.notify(formatHandoffError(error), "error");
301
+ }
302
+ })();
303
+ });
304
+ }
305
+
269
306
  function formatHandoffError(error: unknown): string {
270
307
  if (error instanceof Error && error.message.trim()) {
271
308
  return error.message;
@@ -6,6 +6,7 @@ import { type Static, Type } from "typebox";
6
6
  import { parseTypeBoxValue } from "./typebox.js";
7
7
 
8
8
  export const DEFAULT_AUTO_TITLE_REFRESH_TURNS = 4;
9
+ export const DEFAULT_AUTO_TITLE_PROMPT = `Name this coding session (under 80 chars). Be specific to what is being discussed. Your exact output will be displayed to the user, so make sure that it contains ONLY the title itself and nothing else.`;
9
10
  const SESSION_FILE_SETTINGS_SCHEMA = Type.Object({
10
11
  handoff: Type.Optional(
11
12
  Type.Object({
@@ -21,6 +22,7 @@ const SESSION_FILE_SETTINGS_SCHEMA = Type.Object({
21
22
  Type.Object({
22
23
  refreshTurns: Type.Optional(Type.Integer({ minimum: 1 })),
23
24
  model: Type.Optional(Type.String()),
25
+ prompt: Type.Optional(Type.String()),
24
26
  }),
25
27
  ),
26
28
  });
@@ -42,6 +44,7 @@ export class ModelReference {
42
44
  export interface AutoTitleSettings {
43
45
  refreshTurns: number;
44
46
  model: ModelReference | undefined;
47
+ prompt: string;
45
48
  }
46
49
 
47
50
  export interface SessionSettings {
@@ -113,6 +116,11 @@ function parseModelReference(value: string | undefined): ModelReference | undefi
113
116
  return new ModelReference(trimmed.slice(0, slashIndex), trimmed.slice(slashIndex + 1));
114
117
  }
115
118
 
119
+ function normalizeAutoTitlePrompt(value: string | undefined): string {
120
+ const trimmed = value?.trim();
121
+ return trimmed ? trimmed : DEFAULT_AUTO_TITLE_PROMPT;
122
+ }
123
+
116
124
  function loadSessionFileSettings(): SessionFileSettings {
117
125
  const globalSettings = SettingsManager.create(process.cwd()).getGlobalSettings();
118
126
  const parsed = parseTypeBoxValue(ROOT_SETTINGS_SCHEMA, globalSettings, "Invalid settings");
@@ -132,6 +140,7 @@ function resolveSessionSettings(fileSettings: SessionFileSettings): SessionSetti
132
140
  autoTitle: {
133
141
  refreshTurns: fileSettings.autoTitle?.refreshTurns ?? DEFAULT_AUTO_TITLE_REFRESH_TURNS,
134
142
  model: parseModelReference(fileSettings.autoTitle?.model),
143
+ prompt: normalizeAutoTitlePrompt(fileSettings.autoTitle?.prompt),
135
144
  },
136
145
  };
137
146
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sessions",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Pi session search, ask, handoff, auto-titling, and indexing tools",
6
6
  "license": "MIT",
@@ -42,10 +42,10 @@
42
42
  },
43
43
  "devDependencies": {
44
44
  "@biomejs/biome": "^2.4.14",
45
- "@earendil-works/pi-agent-core": "^0.74.0",
46
- "@earendil-works/pi-ai": "^0.74.0",
47
- "@earendil-works/pi-coding-agent": "^0.74.0",
48
- "@earendil-works/pi-tui": "^0.74.0",
45
+ "@earendil-works/pi-agent-core": "^0.78.0",
46
+ "@earendil-works/pi-ai": "^0.78.0",
47
+ "@earendil-works/pi-coding-agent": "^0.78.0",
48
+ "@earendil-works/pi-tui": "^0.78.0",
49
49
  "@types/better-sqlite3": "^7.6.13",
50
50
  "@types/node": "^25.6.2",
51
51
  "husky": "^9.1.7",
@@ -55,10 +55,10 @@
55
55
  "vitest": "^4.1.5"
56
56
  },
57
57
  "peerDependencies": {
58
- "@earendil-works/pi-agent-core": ">=0.74.0",
59
- "@earendil-works/pi-ai": ">=0.74.0",
60
- "@earendil-works/pi-coding-agent": ">=0.74.0",
61
- "@earendil-works/pi-tui": ">=0.74.0",
58
+ "@earendil-works/pi-agent-core": ">=0.78.0",
59
+ "@earendil-works/pi-ai": ">=0.78.0",
60
+ "@earendil-works/pi-coding-agent": ">=0.78.0",
61
+ "@earendil-works/pi-tui": ">=0.78.0",
62
62
  "typebox": ">=1.1.24"
63
63
  },
64
64
  "lint-staged": {
@@ -1,54 +0,0 @@
1
- import type { AutoTitleContext } from "./context.js";
2
- import type { AutoTitleTrigger } from "./state.js";
3
-
4
- const AUTO_TITLE_CHAR_MAX = 120;
5
-
6
- export const AUTO_TITLE_SYSTEM_PROMPT = `You generate concrete coding session titles from the conversation provided.
7
-
8
- Return title text only.
9
-
10
- Rules:
11
- - Prefer 3-15 words.
12
- - Maximum 120 characters.
13
- - Use the full conversation, while letting the most recent work refine the title when needed.
14
- - Describe the current task, bug, feature, or investigation on the active branch.
15
- - Mention specific subsystem or file only when it improves clarity.
16
- - No quotes, markdown, emojis, prefixes, or explanations.
17
- - No trailing punctuation.
18
- - Avoid generic titles like Coding help or Working on project.`;
19
-
20
- export function buildAutoTitlePrompt(context: AutoTitleContext, trigger: AutoTitleTrigger): string {
21
- const sections = [
22
- ["## Trigger", trigger],
23
- ["## Current Title", context.currentTitle ?? "(none)"],
24
- ["## Counts", formatCounts(context)],
25
- ["## Conversation", context.conversationText || "(none)"],
26
- ];
27
-
28
- if (context.cwd) {
29
- sections.unshift(["## Cwd", context.cwd]);
30
- }
31
-
32
- return sections.map(([heading, body]) => `${heading}\n${body}`).join("\n\n");
33
- }
34
-
35
- export function normalizeGeneratedAutoTitle(value: string): string | undefined {
36
- const withoutQuotes = value.trim().replace(/^["'`]+|["'`]+$/g, "");
37
- const collapsed = withoutQuotes
38
- .replace(/\s+/g, " ")
39
- .replace(/[.!?]+$/g, "")
40
- .trim();
41
- if (!collapsed) {
42
- return undefined;
43
- }
44
-
45
- const truncated = collapsed.slice(0, AUTO_TITLE_CHAR_MAX).trim();
46
- return truncated || undefined;
47
- }
48
-
49
- function formatCounts(context: AutoTitleContext): string {
50
- return [
51
- `user_turns: ${context.userTurnCount}`,
52
- `assistant_turns: ${context.assistantTurnCount}`,
53
- ].join("\n");
54
- }