pi-sessions 0.5.1 → 0.7.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.
Files changed (57) hide show
  1. package/README.md +22 -1
  2. package/extensions/session-ask/agent.ts +400 -0
  3. package/extensions/session-ask/navigate.ts +880 -0
  4. package/extensions/session-ask.ts +82 -134
  5. package/extensions/session-auto-title/command.ts +1 -1
  6. package/extensions/session-auto-title/controller.ts +3 -3
  7. package/extensions/session-auto-title/generate.ts +2 -2
  8. package/extensions/session-auto-title/model.ts +4 -12
  9. package/extensions/session-auto-title/retitle.ts +5 -5
  10. package/extensions/session-auto-title/state.ts +1 -1
  11. package/extensions/session-auto-title/wizard.ts +4 -4
  12. package/extensions/session-auto-title.ts +8 -8
  13. package/extensions/session-handoff/extract.ts +18 -5
  14. package/extensions/session-handoff/metadata.ts +4 -1
  15. package/extensions/session-handoff/picker.ts +52 -6
  16. package/extensions/session-handoff/query.ts +78 -62
  17. package/extensions/session-handoff/refs.ts +14 -20
  18. package/extensions/session-handoff/spawn.ts +1 -1
  19. package/extensions/session-handoff.ts +54 -35
  20. package/extensions/session-hooks.ts +3 -3
  21. package/extensions/session-index.ts +4 -4
  22. package/extensions/session-messaging/broker/process.ts +302 -0
  23. package/extensions/session-messaging/broker/spawn.ts +164 -0
  24. package/extensions/session-messaging/pi/incoming-runtime.ts +114 -0
  25. package/extensions/session-messaging/pi/message-contracts.ts +36 -0
  26. package/extensions/session-messaging/pi/message-view.ts +131 -0
  27. package/extensions/session-messaging/pi/renderer.ts +16 -0
  28. package/extensions/session-messaging/pi/service.ts +370 -0
  29. package/extensions/session-messaging/pi/tools.ts +92 -0
  30. package/extensions/session-messaging.ts +30 -0
  31. package/extensions/session-search/extract.ts +90 -440
  32. package/extensions/session-search/hooks.ts +20 -28
  33. package/extensions/session-search/normalize.ts +1 -1
  34. package/extensions/session-search/reindex.ts +3 -2
  35. package/extensions/session-search.ts +161 -132
  36. package/extensions/shared/model.ts +18 -0
  37. package/extensions/shared/session-broker/active.ts +26 -0
  38. package/extensions/shared/session-broker/client.ts +253 -0
  39. package/extensions/shared/session-broker/framing.ts +72 -0
  40. package/extensions/shared/session-broker/protocol.ts +95 -0
  41. package/extensions/shared/session-broker/socket-path.ts +30 -0
  42. package/extensions/shared/session-index/access.ts +128 -0
  43. package/extensions/shared/session-index/common.ts +46 -51
  44. package/extensions/shared/session-index/index.ts +6 -5
  45. package/extensions/shared/session-index/lineage.ts +19 -2
  46. package/extensions/shared/session-index/query/ast.ts +40 -0
  47. package/extensions/shared/session-index/query/compiler.ts +146 -0
  48. package/extensions/shared/session-index/query/lexer.ts +140 -0
  49. package/extensions/shared/session-index/query/parser.ts +178 -0
  50. package/extensions/shared/session-index/schema.ts +25 -9
  51. package/extensions/shared/session-index/scoring.ts +80 -0
  52. package/extensions/shared/session-index/search.ts +555 -281
  53. package/extensions/shared/session-index/sqlite.ts +16 -9
  54. package/extensions/shared/session-index/store.ts +14 -23
  55. package/extensions/shared/settings.ts +62 -5
  56. package/extensions/shared/text.ts +50 -0
  57. package/package.json +4 -3
@@ -11,11 +11,12 @@ import {
11
11
  import {
12
12
  stripSearchSnippetMarkers,
13
13
  transformSearchSnippetMatches,
14
- } from "../shared/search-snippet.js";
15
- import { listSessionPickerItems, normalizeDisplayText, type SessionPickerItem } from "./query.js";
14
+ } from "../shared/search-snippet.ts";
15
+ import { listSessionPickerItems, normalizeDisplayText, type SessionPickerItem } from "./query.ts";
16
16
 
17
17
  const MAX_VISIBLE_BROWSE_ROWS = 10;
18
18
  const MAX_VISIBLE_SEARCH_ROWS = 4;
19
+ const SEARCH_RELOAD_DEBOUNCE_MS = 200;
19
20
 
20
21
  interface PickerRightColumnWidths {
21
22
  marker: number;
@@ -65,6 +66,7 @@ export class SessionReferencePickerComponent implements Focusable {
65
66
  private includeAll = false;
66
67
  private items: SessionPickerItem[] = [];
67
68
  private selectedIndex = 0;
69
+ private searchReloadTimer: ReturnType<typeof setTimeout> | undefined;
68
70
 
69
71
  get focused(): boolean {
70
72
  return this._focused;
@@ -89,17 +91,18 @@ export class SessionReferencePickerComponent implements Focusable {
89
91
 
90
92
  handleInput(data: string): void {
91
93
  if (matchesKey(data, this.options.shortcut)) {
92
- this.done({ kind: "cancel" });
94
+ this.finish({ kind: "cancel" });
93
95
  return;
94
96
  }
95
97
 
96
98
  if (this.keybindings.matches(data, "tui.select.cancel")) {
97
- this.done({ kind: "cancel" });
99
+ this.finish({ kind: "cancel" });
98
100
  return;
99
101
  }
100
102
 
101
103
  if (this.keybindings.matches(data, "tui.input.tab")) {
102
104
  this.includeAll = !this.includeAll;
105
+ this.cancelSearchReload();
103
106
  this.reload();
104
107
  this.tui.requestRender();
105
108
  return;
@@ -130,9 +133,10 @@ export class SessionReferencePickerComponent implements Focusable {
130
133
  }
131
134
 
132
135
  if (this.keybindings.matches(data, "tui.select.confirm")) {
136
+ this.flushSearchReload();
133
137
  const selected = this.items[this.selectedIndex];
134
138
  if (selected?.kind === "session") {
135
- this.done({ kind: "insert-session-token", sessionId: selected.sessionId });
139
+ this.finish({ kind: "insert-session-token", sessionId: selected.sessionId });
136
140
  }
137
141
  return;
138
142
  }
@@ -140,7 +144,7 @@ export class SessionReferencePickerComponent implements Focusable {
140
144
  const before = this.input.getValue();
141
145
  this.input.handleInput(data);
142
146
  if (this.input.getValue() !== before) {
143
- this.reload();
147
+ this.reloadAfterInputChange();
144
148
  this.tui.requestRender();
145
149
  }
146
150
  }
@@ -284,6 +288,48 @@ export class SessionReferencePickerComponent implements Focusable {
284
288
  .trim();
285
289
  }
286
290
 
291
+ private reloadAfterInputChange(): void {
292
+ if (!this.input.getValue().trim()) {
293
+ this.cancelSearchReload();
294
+ this.reload();
295
+ return;
296
+ }
297
+
298
+ this.scheduleSearchReload();
299
+ }
300
+
301
+ private scheduleSearchReload(): void {
302
+ this.cancelSearchReload();
303
+ this.searchReloadTimer = setTimeout(() => {
304
+ this.searchReloadTimer = undefined;
305
+ this.reload();
306
+ this.tui.requestRender();
307
+ }, SEARCH_RELOAD_DEBOUNCE_MS);
308
+ }
309
+
310
+ private flushSearchReload(): void {
311
+ if (!this.searchReloadTimer) {
312
+ return;
313
+ }
314
+
315
+ this.cancelSearchReload();
316
+ this.reload();
317
+ }
318
+
319
+ private cancelSearchReload(): void {
320
+ if (!this.searchReloadTimer) {
321
+ return;
322
+ }
323
+
324
+ clearTimeout(this.searchReloadTimer);
325
+ this.searchReloadTimer = undefined;
326
+ }
327
+
328
+ private finish(result: SessionPickerResult): void {
329
+ this.cancelSearchReload();
330
+ this.done(result);
331
+ }
332
+
287
333
  private reload(): void {
288
334
  this.items = listSessionPickerItems({
289
335
  currentSessionPath: this.options.getCurrentSessionPath(),
@@ -1,17 +1,15 @@
1
- import { stripSearchSnippetMarkers } from "../shared/search-snippet.js";
1
+ import { stripSearchSnippetMarkers } from "../shared/search-snippet.ts";
2
2
  import {
3
- getIndexStatus,
4
- getLineageSessions,
5
3
  getSessionByPath,
6
- INDEX_SCHEMA_VERSION,
7
- openIndexDatabase,
8
4
  type SearchSessionResult,
9
5
  type SessionIndexDatabase,
10
6
  type SessionLineageRelation,
11
7
  searchSessions,
12
- } from "../shared/session-index/index.js";
13
- import { shortenSessionId } from "../shared/session-ui.js";
14
- import { formatCompactRelativeTime } from "../shared/time.js";
8
+ withSessionIndex,
9
+ } from "../shared/session-index/index.ts";
10
+ import { SearchQuerySyntaxError } from "../shared/session-index/query/ast.ts";
11
+ import { shortenSessionId } from "../shared/session-ui.ts";
12
+ import { formatCompactRelativeTime } from "../shared/time.ts";
15
13
 
16
14
  export const SESSION_TOKEN_PREFIX = "@session:";
17
15
 
@@ -19,7 +17,6 @@ const MAX_TREE_DEPTH = 3;
19
17
 
20
18
  interface SessionPickerPresentationContext {
21
19
  currentSessionId?: string | undefined;
22
- relationBySessionId: Map<string, SessionLineageRelation>;
23
20
  }
24
21
 
25
22
  export interface SessionPickerSessionItem {
@@ -32,7 +29,7 @@ export interface SessionPickerSessionItem {
32
29
  modifiedAtText?: string | undefined;
33
30
  prefix: string;
34
31
  snippet?: string | undefined;
35
- relation?: SessionLineageRelation | "self" | undefined;
32
+ relation?: SessionLineageRelation | undefined;
36
33
  }
37
34
 
38
35
  export interface SessionPickerNoticeItem {
@@ -69,69 +66,75 @@ export function listSessionPickerItems(
69
66
  ): SessionPickerQueryResult {
70
67
  const scopeMode = options.includeAll ? "all" : "default";
71
68
  const defaultScopeLabel = options.currentCwd ? "current folder" : undefined;
72
- const status = getIndexStatus(options.indexPath);
73
- if (!status.exists || status.schemaVersion !== INDEX_SCHEMA_VERSION) {
74
- return {
75
- items: [buildIndexErrorItem()],
76
- scopeMode,
77
- defaultScopeLabel,
78
- };
79
- }
80
-
81
- const db = openIndexDatabase(status.dbPath, { create: false });
82
69
  try {
83
- const currentSession = options.currentSessionPath
84
- ? getSessionByPath(db, options.currentSessionPath)
85
- : undefined;
86
- const context = buildPresentationContext(db, currentSession?.sessionId);
87
- const rankedResults = prioritizeSessionResults(
88
- searchSessions(
89
- db,
90
- {
91
- cwd: options.includeAll ? undefined : options.currentCwd,
92
- query: options.mode === "search" ? options.query : undefined,
93
- limit: options.limit,
94
- },
95
- { defaultLimit: undefined },
96
- ),
97
- context,
70
+ return (
71
+ withSessionIndex(options.indexPath, { mode: "read", required: false }, ({ db }) => {
72
+ const currentSession = options.currentSessionPath
73
+ ? getSessionByPath(db, options.currentSessionPath)
74
+ : undefined;
75
+ const context = buildPresentationContext(currentSession?.sessionId);
76
+ const searchResults = searchSessionsSafely(db, options, currentSession?.sessionId);
77
+ const rankedResults =
78
+ options.mode === "browse"
79
+ ? prioritizeSessionResults(searchResults, context)
80
+ : searchResults;
81
+
82
+ if (rankedResults.length === 0) {
83
+ return {
84
+ items: [buildEmptyResultItem(options.mode)],
85
+ scopeMode,
86
+ defaultScopeLabel,
87
+ };
88
+ }
89
+
90
+ const sessionItems =
91
+ options.mode === "browse"
92
+ ? buildBrowseSessionItems(rankedResults, context)
93
+ : rankedResults.map((result) =>
94
+ buildSessionItem(result, context, "", getBestTextSnippet(result)),
95
+ );
96
+
97
+ return {
98
+ items: sessionItems,
99
+ scopeMode,
100
+ defaultScopeLabel,
101
+ };
102
+ }) ?? {
103
+ items: [buildIndexErrorItem()],
104
+ scopeMode,
105
+ defaultScopeLabel,
106
+ }
98
107
  );
99
-
100
- if (rankedResults.length === 0) {
108
+ } catch (error) {
109
+ if (error instanceof SearchQuerySyntaxError) {
101
110
  return {
102
- items: [buildEmptyResultItem(options.mode)],
111
+ items: [buildSearchSyntaxErrorItem(error)],
103
112
  scopeMode,
104
113
  defaultScopeLabel,
105
114
  };
106
115
  }
107
116
 
108
- const sessionItems =
109
- options.mode === "browse"
110
- ? buildBrowseSessionItems(rankedResults, context)
111
- : rankedResults.map((result) => buildSessionItem(result, context, "", result.snippet));
112
-
113
- return {
114
- items: sessionItems,
115
- scopeMode,
116
- defaultScopeLabel,
117
- };
118
- } finally {
119
- db.close();
117
+ throw error;
120
118
  }
121
119
  }
122
120
 
123
- function buildPresentationContext(
121
+ function searchSessionsSafely(
124
122
  db: SessionIndexDatabase,
123
+ options: ListSessionPickerItemsOptions,
124
+ currentSessionId?: string | undefined,
125
+ ): SearchSessionResult[] {
126
+ return searchSessions(db, {
127
+ cwd: options.includeAll ? undefined : options.currentCwd,
128
+ query: options.mode === "search" ? options.query : undefined,
129
+ limit: options.limit,
130
+ relativeToSessionId: currentSessionId,
131
+ });
132
+ }
133
+
134
+ function buildPresentationContext(
125
135
  currentSessionId?: string | undefined,
126
136
  ): SessionPickerPresentationContext {
127
- return {
128
- currentSessionId,
129
- relationBySessionId: currentSessionId
130
- ? new Map(
131
- getLineageSessions(db, currentSessionId).map((row) => [row.sessionId, row.relation]),
132
- )
133
- : new Map<string, SessionLineageRelation>(),
134
- };
137
+ return { currentSessionId };
135
138
  }
136
139
 
137
140
  function prioritizeSessionResults(
@@ -253,13 +256,18 @@ function buildSessionItem(
253
256
  };
254
257
  }
255
258
 
259
+ function getBestTextSnippet(result: SearchSessionResult): string | undefined {
260
+ const textEvidence = result.evidence.find((evidence) => evidence.kind === "text");
261
+ return textEvidence?.kind === "text" ? textEvidence.snippet : undefined;
262
+ }
263
+
256
264
  function getSessionTitle(result: SearchSessionResult): string {
257
265
  return (
258
266
  normalizeDisplayText(result.sessionName) ??
259
267
  normalizeDisplayText(result.handoffNextTask) ??
260
268
  normalizeDisplayText(result.handoffGoal) ??
261
269
  normalizeDisplayText(result.firstUserPrompt) ??
262
- normalizeDisplayText(stripSearchSnippetMarkers(result.snippet)) ??
270
+ normalizeDisplayText(stripSearchSnippetMarkers(getBestTextSnippet(result))) ??
263
271
  shortenSessionId(result.sessionId)
264
272
  );
265
273
  }
@@ -276,12 +284,12 @@ export function normalizeDisplayText(value?: string): string | undefined {
276
284
  function getSessionRelation(
277
285
  result: SearchSessionResult,
278
286
  context: SessionPickerPresentationContext,
279
- ): SessionLineageRelation | "self" | undefined {
287
+ ): SessionLineageRelation | undefined {
280
288
  if (context.currentSessionId && result.sessionId === context.currentSessionId) {
281
289
  return "self";
282
290
  }
283
291
 
284
- return context.relationBySessionId.get(result.sessionId);
292
+ return result.relation;
285
293
  }
286
294
 
287
295
  function getSessionMarker(
@@ -310,6 +318,14 @@ function buildIndexErrorItem(): SessionPickerNoticeItem {
310
318
  };
311
319
  }
312
320
 
321
+ function buildSearchSyntaxErrorItem(error: SearchQuerySyntaxError): SessionPickerNoticeItem {
322
+ return {
323
+ kind: "error",
324
+ title: "Invalid search query",
325
+ description: error.message,
326
+ };
327
+ }
328
+
313
329
  function buildEmptyResultItem(mode: "browse" | "search"): SessionPickerNoticeItem {
314
330
  return {
315
331
  kind: "empty",
@@ -1,13 +1,11 @@
1
1
  import path from "node:path";
2
- import { parseSessionFile } from "../session-search/extract.js";
2
+ import { parseSessionFile } from "../session-search/extract.ts";
3
3
  import {
4
- getIndexStatus,
5
4
  getSessionById,
6
- INDEX_SCHEMA_VERSION,
7
- openIndexDatabase,
8
5
  type SessionLineageRow,
9
6
  type SessionOrigin,
10
- } from "../shared/session-index/index.js";
7
+ withSessionIndex,
8
+ } from "../shared/session-index/index.ts";
11
9
 
12
10
  const HANDOFF_REF_PREFIX = "@handoff/";
13
11
  const SESSION_REFERENCE_HELP =
@@ -103,24 +101,20 @@ function resolveIndexedReference(
103
101
  return { error: `Invalid session reference: ${input}. ${SESSION_REFERENCE_HELP}` };
104
102
  }
105
103
 
106
- const status = getIndexStatus(options.indexPath);
107
- if (!status.exists || status.schemaVersion !== INDEX_SCHEMA_VERSION) {
104
+ try {
105
+ return withSessionIndex(options.indexPath, { mode: "read", required: true }, ({ db }) => {
106
+ const exactMatch = getSessionById(db, value);
107
+ if (exactMatch) {
108
+ return { resolved: buildResolvedReference(input, kind, exactMatch) };
109
+ }
110
+
111
+ return { error: `No session found for reference: ${input}. ${SESSION_REFERENCE_HELP}` };
112
+ });
113
+ } catch {
108
114
  return {
109
- error: `Session reference resolution requires a current index at ${status.dbPath}. Run /session-index and press r to rebuild it.`,
115
+ error: `Session reference resolution requires a current index at ${options.indexPath}. Run /session-index and press r to rebuild it.`,
110
116
  };
111
117
  }
112
-
113
- const db = openIndexDatabase(status.dbPath, { create: false });
114
- try {
115
- const exactMatch = getSessionById(db, value);
116
- if (exactMatch) {
117
- return { resolved: buildResolvedReference(input, kind, exactMatch) };
118
- }
119
-
120
- return { error: `No session found for reference: ${input}. ${SESSION_REFERENCE_HELP}` };
121
- } finally {
122
- db.close();
123
- }
124
118
  }
125
119
 
126
120
  function buildResolvedReference(
@@ -8,7 +8,7 @@ import {
8
8
  type SessionHeader,
9
9
  type SessionInfoEntry,
10
10
  } from "@earendil-works/pi-coding-agent";
11
- import { HANDOFF_BOOTSTRAP_ENV } from "./metadata.js";
11
+ import { HANDOFF_BOOTSTRAP_ENV } from "./metadata.ts";
12
12
 
13
13
  const GHOSTTY_MACOS_ONLY_MESSAGE = "Split handoff currently supports Ghostty on macOS only.";
14
14
  const GHOSTTY_REQUIRED_MESSAGE = "Split handoff requires running inside Ghostty.";
@@ -9,13 +9,13 @@ import type {
9
9
  ExtensionUIContext,
10
10
  } from "@earendil-works/pi-coding-agent";
11
11
  import { buildSessionContext, SessionManager } from "@earendil-works/pi-coding-agent";
12
- import { Key, matchesKey } from "@earendil-works/pi-tui";
12
+ import { Key, matchesKey, Text } from "@earendil-works/pi-tui";
13
13
  import { Type } from "typebox";
14
14
  import {
15
15
  generateHandoffDraft,
16
16
  generateHandoffDraftFromSessionManager,
17
17
  type HandoffDraftResult,
18
- } from "./session-handoff/extract.js";
18
+ } from "./session-handoff/extract.ts";
19
19
  import {
20
20
  type ChildGeneratedHandoffBootstrap,
21
21
  createChildGeneratedHandoffBootstrap,
@@ -29,14 +29,14 @@ import {
29
29
  hasUserMessages,
30
30
  isChildGeneratedHandoffBootstrap,
31
31
  parseHandoffBootstrap,
32
- } from "./session-handoff/metadata.js";
33
- import { openSessionReferencePicker } from "./session-handoff/picker.js";
34
- import { SESSION_TOKEN_PREFIX } from "./session-handoff/query.js";
32
+ } from "./session-handoff/metadata.ts";
33
+ import { openSessionReferencePicker } from "./session-handoff/picker.ts";
34
+ import { SESSION_TOKEN_PREFIX } from "./session-handoff/query.ts";
35
35
  import {
36
36
  renderStrongModal,
37
37
  reviewHandoffDraft,
38
38
  reviewHandoffDraftForSend,
39
- } from "./session-handoff/review.js";
39
+ } from "./session-handoff/review.ts";
40
40
  import {
41
41
  buildPiResumeCommand,
42
42
  createHandoffSession,
@@ -45,9 +45,9 @@ import {
45
45
  isGhosttyHandoffAvailable,
46
46
  launchSplitHandoffSession,
47
47
  validateSplitHandoffPrerequisites,
48
- } from "./session-handoff/spawn.js";
49
- import { isTuiMode } from "./shared/pi-mode.js";
50
- import { loadSettings } from "./shared/settings.js";
48
+ } from "./session-handoff/spawn.ts";
49
+ import { isTuiMode } from "./shared/pi-mode.ts";
50
+ import { loadSettings } from "./shared/settings.ts";
51
51
 
52
52
  const HANDOFF_USAGE = "Usage: /handoff [--left|--right|--up|--down] <goal for new thread>";
53
53
  const TOOL_HANDOFF_PROVISIONAL_TITLE = "Session handoff";
@@ -58,10 +58,10 @@ interface HandoffToolParams {
58
58
  goal: string;
59
59
  splitDirection: HandoffSplitDirection;
60
60
  cwd?: string | undefined;
61
+ requestResponse?: boolean | undefined;
61
62
  }
62
63
 
63
64
  interface HandoffToolDetails {
64
- error: boolean;
65
65
  sessionId?: string | undefined;
66
66
  title?: string | undefined;
67
67
  splitDirection?: HandoffSplitDirection | undefined;
@@ -89,6 +89,7 @@ export default function sessionHandoffExtension(pi: ExtensionAPI): void {
89
89
  "Use session_handoff only when it is clear the work should be forked to a new context.",
90
90
  "Prefer using session_handoff by direction of the user, not as an unsolicited default.",
91
91
  "The goal should capture enough detail to encompass the ask and include any directions the next sessions should consider.",
92
+ "Only request a response when there is a specific ask-and-response expectation: the user asked for a report back, or the current session needs the child result before it can continue. Do not set requestResponse for independent or unrelated background work; most split-off tasks should run without reporting back.",
92
93
  "Only capable of a background handoff; to replace the current session, tell the user to run /handoff instead.",
93
94
  ],
94
95
  executionMode: "sequential",
@@ -110,7 +111,30 @@ export default function sessionHandoffExtension(pi: ExtensionAPI): void {
110
111
  "Optional target working directory. Relative paths resolve from the current session cwd.",
111
112
  }),
112
113
  ),
114
+ requestResponse: Type.Optional(
115
+ Type.Boolean({
116
+ description:
117
+ "Whether the child session should report completion/results of its task back to this session.",
118
+ }),
119
+ ),
113
120
  }),
121
+ renderResult(result, _options, theme, context) {
122
+ const text = getFirstText(result);
123
+ if (context.isError) {
124
+ return new Text(theme.fg("error", text), 0, 0);
125
+ }
126
+
127
+ const details = result.details as HandoffToolDetails | undefined;
128
+ if (!details?.sessionId || !details.splitDirection || !details.cwd) {
129
+ return new Text(text, 0, 0);
130
+ }
131
+
132
+ return new Text(
133
+ `Started handoff session ${theme.bold(details.sessionId)} (${details.splitDirection}) in ${theme.fg("dim", details.cwd)}.`,
134
+ 0,
135
+ 0,
136
+ );
137
+ },
114
138
  async execute(_toolCallId, params: HandoffToolParams, _signal, _onUpdate, ctx) {
115
139
  return executeSessionHandoffTool(pi, params, ctx, identifiedGhosttyTerminalId);
116
140
  },
@@ -354,30 +378,30 @@ async function executeSessionHandoffTool(
354
378
  ) {
355
379
  const goal = params.goal.trim();
356
380
  if (!goal) {
357
- return createHandoffToolError("session_handoff requires a goal.");
381
+ throw new Error("session_handoff requires a goal.");
358
382
  }
359
383
 
360
384
  if (!ctx.model) {
361
- return createHandoffToolError("No model selected.");
385
+ throw new Error("No model selected.");
362
386
  }
363
387
 
364
388
  const preflightError = await validateSplitHandoffPrerequisites(pi, ctx);
365
389
  if (preflightError) {
366
- return createHandoffToolError(preflightError);
390
+ throw new Error(preflightError);
367
391
  }
368
392
 
369
393
  if (!terminalId) {
370
- return createHandoffToolError(NO_IDENTIFIED_TERMINAL_MESSAGE);
394
+ throw new Error(NO_IDENTIFIED_TERMINAL_MESSAGE);
371
395
  }
372
396
 
373
397
  const targetCwd = resolveHandoffCwd(ctx.cwd, params.cwd);
374
398
  if (targetCwd.error) {
375
- return createHandoffToolError(targetCwd.message);
399
+ throw new Error(targetCwd.message);
376
400
  }
377
401
 
378
402
  const parentSessionFile = ctx.sessionManager.getSessionFile();
379
403
  if (!parentSessionFile) {
380
- return createHandoffToolError("Handoff requires a persisted current session.");
404
+ throw new Error("Handoff requires a persisted current session.");
381
405
  }
382
406
 
383
407
  const sessionContext = buildSessionContext(
@@ -385,9 +409,10 @@ async function executeSessionHandoffTool(
385
409
  ctx.sessionManager.getLeafId(),
386
410
  );
387
411
  if (sessionContext.messages.length === 0) {
388
- return createHandoffToolError("No conversation to hand off.");
412
+ throw new Error("No conversation to hand off.");
389
413
  }
390
414
 
415
+ const requestResponse = params.requestResponse ?? false;
391
416
  const createdSession = createHandoffSession({
392
417
  cwd: targetCwd.path,
393
418
  sessionDir: ctx.sessionManager.getSessionDir(),
@@ -400,6 +425,7 @@ async function executeSessionHandoffTool(
400
425
  goal,
401
426
  title: TOOL_HANDOFF_PROVISIONAL_TITLE,
402
427
  parentSessionFile,
428
+ requestResponse,
403
429
  }),
404
430
  );
405
431
  const model = formatModelArgument(ctx.model, pi.getThinkingLevel());
@@ -416,7 +442,7 @@ async function executeSessionHandoffTool(
416
442
  });
417
443
 
418
444
  if (!launchResult.success) {
419
- return createHandoffToolError(
445
+ throw new Error(
420
446
  `${launchResult.error} Created handoff session ${createdSession.sessionId}; start it manually with: ${buildPiResumeCommand(
421
447
  ctx.sessionManager.getSessionDir(),
422
448
  createdSession.sessionId,
@@ -424,17 +450,10 @@ async function executeSessionHandoffTool(
424
450
  TOOL_HANDOFF_PROVISIONAL_TITLE,
425
451
  model,
426
452
  )}`,
427
- {
428
- sessionId: createdSession.sessionId,
429
- title: TOOL_HANDOFF_PROVISIONAL_TITLE,
430
- splitDirection: params.splitDirection,
431
- cwd: targetCwd.path,
432
- },
433
453
  );
434
454
  }
435
455
 
436
456
  const details: HandoffToolDetails = {
437
- error: false,
438
457
  sessionId: createdSession.sessionId,
439
458
  title: TOOL_HANDOFF_PROVISIONAL_TITLE,
440
459
  splitDirection: params.splitDirection,
@@ -445,7 +464,7 @@ async function executeSessionHandoffTool(
445
464
  content: [
446
465
  {
447
466
  type: "text" as const,
448
- text: `Started handoff session ${createdSession.sessionId} (${params.splitDirection}) in ${targetCwd.path}.`,
467
+ text: formatHandoffToolResultForModel(details, requestResponse),
449
468
  },
450
469
  ],
451
470
  details,
@@ -482,6 +501,7 @@ async function startChildGeneratedHandoff(
482
501
  bootstrap.goal,
483
502
  thinkingLevel,
484
503
  signal,
504
+ bootstrap.requestResponse ?? false,
485
505
  ),
486
506
  );
487
507
  if (!generatedDraft) {
@@ -517,16 +537,15 @@ async function startChildGeneratedHandoff(
517
537
  }
518
538
  }
519
539
 
520
- function createHandoffToolError(message: string, details: Partial<HandoffToolDetails> = {}) {
521
- const resultDetails: HandoffToolDetails = {
522
- error: true,
523
- ...details,
524
- };
540
+ function formatHandoffToolResultForModel(
541
+ details: HandoffToolDetails,
542
+ requestResponse: boolean,
543
+ ): string {
544
+ return JSON.stringify({ ...details, requestResponse }, null, 2);
545
+ }
525
546
 
526
- return {
527
- content: [{ type: "text" as const, text: message }],
528
- details: resultDetails,
529
- };
547
+ function getFirstText(result: { content: Array<{ type: string; text?: string }> }): string {
548
+ return result.content.find((item) => item.type === "text")?.text ?? "";
530
549
  }
531
550
 
532
551
  function resolveHandoffCwd(
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { HANDOFF_BOOTSTRAP_ENV, parseHandoffBootstrap } from "./session-handoff/metadata.js";
3
- import { createSessionHookController } from "./session-search/hooks.js";
4
- import { loadSettings } from "./shared/settings.js";
2
+ import { HANDOFF_BOOTSTRAP_ENV, parseHandoffBootstrap } from "./session-handoff/metadata.ts";
3
+ import { createSessionHookController } from "./session-search/hooks.ts";
4
+ import { loadSettings } from "./shared/settings.ts";
5
5
 
6
6
  interface SessionStartLifecycleEvent {
7
7
  reason?: "startup" | "reload" | "new" | "resume" | "fork";
@@ -5,10 +5,10 @@ import {
5
5
  type Theme,
6
6
  } from "@earendil-works/pi-coding-agent";
7
7
  import { type Focusable, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
8
- import { type ReindexResult, rebuildSessionIndex } from "./session-search/reindex.js";
9
- import { isTuiMode } from "./shared/pi-mode.js";
10
- import { getIndexStatus, type SessionIndexStatus } from "./shared/session-index/index.js";
11
- import { loadSettings } from "./shared/settings.js";
8
+ import { type ReindexResult, rebuildSessionIndex } from "./session-search/reindex.ts";
9
+ import { isTuiMode } from "./shared/pi-mode.ts";
10
+ import { getIndexStatus, type SessionIndexStatus } from "./shared/session-index/index.ts";
11
+ import { loadSettings } from "./shared/settings.ts";
12
12
 
13
13
  type SessionIndexAction = "reindex" | undefined;
14
14