pi-sessions 0.8.0 → 0.10.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 (111) hide show
  1. package/README.md +74 -37
  2. package/dist/session-messaging/broker/process.js +234 -0
  3. package/dist/shared/session-broker/framing.js +58 -0
  4. package/dist/shared/session-broker/protocol.js +163 -0
  5. package/dist/shared/session-broker/socket-path.js +19 -0
  6. package/dist/shared/typebox.js +36 -0
  7. package/extensions/pi-sessions.ts +125 -0
  8. package/extensions/session-ask/agent.ts +34 -14
  9. package/extensions/{session-ask.ts → session-ask/install.ts} +73 -90
  10. package/extensions/session-ask/navigate.ts +3 -1
  11. package/extensions/session-ask/presenter.ts +23 -0
  12. package/extensions/session-ask/renderer.ts +63 -0
  13. package/extensions/session-ask/tool-contract.ts +27 -0
  14. package/extensions/session-ask/view-model.ts +18 -0
  15. package/extensions/session-auto-title/generate.ts +18 -18
  16. package/extensions/{session-auto-title.ts → session-auto-title/install.ts} +60 -53
  17. package/extensions/session-auto-title/model.ts +25 -17
  18. package/extensions/session-auto-title/retitle.ts +17 -18
  19. package/extensions/session-auto-title/wizard.ts +12 -11
  20. package/extensions/session-handoff/board-view-model.ts +179 -0
  21. package/extensions/session-handoff/board.ts +663 -0
  22. package/extensions/session-handoff/bootstrap.ts +172 -0
  23. package/extensions/session-handoff/extract.ts +129 -132
  24. package/extensions/session-handoff/install.ts +225 -0
  25. package/extensions/session-handoff/kickoff.ts +92 -0
  26. package/extensions/session-handoff/launch/backend.ts +16 -0
  27. package/extensions/session-handoff/launch/deferred.ts +20 -0
  28. package/extensions/session-handoff/launch/ghostty.ts +99 -0
  29. package/extensions/session-handoff/launch/resolution.ts +57 -0
  30. package/extensions/session-handoff/launch/shell.ts +7 -0
  31. package/extensions/session-handoff/launch/tmux.ts +31 -0
  32. package/extensions/session-handoff/launch-options.ts +61 -0
  33. package/extensions/session-handoff/launch-target.ts +91 -0
  34. package/extensions/session-handoff/metadata.ts +194 -56
  35. package/extensions/session-handoff/model.ts +58 -0
  36. package/extensions/session-handoff/query.ts +0 -1
  37. package/extensions/session-handoff/receipt.ts +96 -0
  38. package/extensions/session-handoff/review.ts +2 -28
  39. package/extensions/session-handoff/spawn.ts +76 -181
  40. package/extensions/session-handoff/strong-modal.ts +13 -0
  41. package/extensions/session-handoff/tool-contract.ts +12 -0
  42. package/extensions/session-handoff/tool-presenter.ts +35 -0
  43. package/extensions/session-handoff/tool-renderer.ts +66 -0
  44. package/extensions/session-handoff/tool-schema.ts +54 -0
  45. package/extensions/session-handoff/tool-view-model.ts +49 -0
  46. package/extensions/session-handoff/tool.ts +206 -0
  47. package/extensions/session-handoff/ui.ts +66 -0
  48. package/extensions/session-hooks/install.ts +75 -0
  49. package/extensions/{session-index.ts → session-index/install.ts} +9 -7
  50. package/extensions/session-messaging/broker/process.ts +21 -28
  51. package/extensions/session-messaging/broker/spawn.ts +12 -7
  52. package/extensions/session-messaging/install.ts +78 -0
  53. package/extensions/session-messaging/pi/cancel-session-presenter.ts +64 -0
  54. package/extensions/session-messaging/pi/incoming-message-presenter.ts +32 -0
  55. package/extensions/session-messaging/pi/incoming-message-view-model.ts +20 -0
  56. package/extensions/session-messaging/pi/incoming-runtime.ts +27 -23
  57. package/extensions/session-messaging/pi/message-contracts.ts +32 -0
  58. package/extensions/session-messaging/pi/renderer.ts +19 -12
  59. package/extensions/session-messaging/pi/send-message-presenter.ts +39 -0
  60. package/extensions/session-messaging/pi/send-message-view-model.ts +54 -0
  61. package/extensions/session-messaging/pi/service.ts +262 -81
  62. package/extensions/session-messaging/pi/tools.ts +184 -40
  63. package/extensions/session-search/extract.ts +60 -14
  64. package/extensions/session-search/hooks.ts +10 -13
  65. package/extensions/session-search/install.ts +373 -0
  66. package/extensions/session-search/renderer.ts +195 -0
  67. package/extensions/session-search/tool-contract.ts +51 -0
  68. package/extensions/shared/composition.ts +20 -0
  69. package/extensions/shared/errors.ts +3 -0
  70. package/extensions/shared/model-resolution.ts +73 -0
  71. package/extensions/shared/model-runtime.ts +33 -0
  72. package/extensions/shared/model.ts +7 -10
  73. package/extensions/shared/rendering/collapsible-text.ts +89 -0
  74. package/extensions/shared/rendering/expandable-content-layout.ts +84 -0
  75. package/extensions/shared/rendering/theme.ts +5 -0
  76. package/extensions/shared/session-broker/client.ts +30 -29
  77. package/extensions/shared/session-broker/protocol.ts +128 -10
  78. package/extensions/shared/session-index/common.ts +5 -5
  79. package/extensions/shared/session-index/lineage.ts +0 -3
  80. package/extensions/shared/session-index/schema.ts +0 -1
  81. package/extensions/shared/session-index/scoring.ts +1 -2
  82. package/extensions/shared/session-index/search.ts +11 -4
  83. package/extensions/shared/session-index/store.ts +4 -9
  84. package/extensions/shared/settings.ts +83 -40
  85. package/extensions/shared/text.ts +5 -0
  86. package/extensions/shared/thinking-levels.ts +22 -0
  87. package/extensions/shared/tmux.ts +205 -0
  88. package/extensions/subagents/cancel.ts +111 -0
  89. package/extensions/subagents/classify.ts +58 -0
  90. package/extensions/subagents/install.ts +229 -0
  91. package/extensions/subagents/launch-target.ts +75 -0
  92. package/extensions/subagents/ledger.ts +217 -0
  93. package/extensions/subagents/reconcile.ts +386 -0
  94. package/extensions/subagents/report-message-presenter.ts +36 -0
  95. package/extensions/subagents/report-message-renderer.ts +21 -0
  96. package/extensions/subagents/report-message-view-model.ts +29 -0
  97. package/extensions/subagents/report.ts +159 -0
  98. package/extensions/subagents/roster.ts +262 -0
  99. package/extensions/subagents/settle.ts +182 -0
  100. package/extensions/subagents/wake.ts +173 -0
  101. package/images/handoff-board-subagents.png +0 -0
  102. package/images/handoff-board-user-sessions.png +0 -0
  103. package/images/handoff-subagent-report.png +0 -0
  104. package/images/session-handoff-tool.png +0 -0
  105. package/package.json +16 -18
  106. package/extensions/session-handoff.ts +0 -741
  107. package/extensions/session-hooks.ts +0 -75
  108. package/extensions/session-messaging/pi/message-view.ts +0 -131
  109. package/extensions/session-messaging.ts +0 -30
  110. package/extensions/session-search.ts +0 -419
  111. package/extensions/shared/session-broker/active.ts +0 -26
@@ -0,0 +1,373 @@
1
+ import {
2
+ type ExtensionAPI,
3
+ type ExtensionContext,
4
+ SessionManager,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { Type } from "typebox";
7
+ import { isSessionStarting } from "../session-handoff/metadata.ts";
8
+ import type { MessagingHandle } from "../session-messaging/install.ts";
9
+ import type { IndexHandle } from "../shared/composition.ts";
10
+ import { formatError } from "../shared/errors.ts";
11
+ import { stripSearchSnippetMarkers } from "../shared/search-snippet.ts";
12
+ import {
13
+ getSessionById,
14
+ type SearchSessionsParams,
15
+ type SessionSearchEvidence,
16
+ searchSessions,
17
+ withSessionIndex,
18
+ } from "../shared/session-index/index.ts";
19
+ import type { SessionSettings } from "../shared/settings.ts";
20
+ import type { SubagentState } from "../subagents/classify.ts";
21
+ import type { SubagentRoster } from "../subagents/roster.ts";
22
+ import { renderSessionSearchResult } from "./renderer.ts";
23
+ import type {
24
+ SessionSearchResult,
25
+ SessionSearchToolDetails,
26
+ SessionSearchToolParams,
27
+ } from "./tool-contract.ts";
28
+
29
+ const DEFAULT_SESSION_SEARCH_LIMIT = 6;
30
+
31
+ export interface SearchInstallDeps {
32
+ settings: SessionSettings;
33
+ index: IndexHandle;
34
+ messaging?: MessagingHandle | undefined;
35
+ roster?: SubagentRoster | undefined;
36
+ }
37
+
38
+ export function installSearch(pi: ExtensionAPI, deps: SearchInstallDeps): void {
39
+ const indexPath = deps.index.path;
40
+
41
+ pi.registerTool({
42
+ name: "session_search",
43
+ label: "Session Search",
44
+ description: "Search Pi sessions",
45
+ promptSnippet: "Locate Pi sessions for follow-up",
46
+ promptGuidelines: [
47
+ "Omit queries in session_search to list matching sessions chronologically.",
48
+ "After session_search finds a session id, switch to session_ask for questions about it.",
49
+ 'To identify your own subagents, use session_search with relationScope: "branch".',
50
+ ],
51
+ parameters: Type.Object({
52
+ query: Type.Optional(
53
+ Type.String({
54
+ description:
55
+ "Use plain adjacent terms for normal search. Supports quoted phrases, AND/OR/NOT, parentheses, and -term negation when matching needs to be stricter.",
56
+ }),
57
+ ),
58
+ files: Type.Optional(
59
+ Type.Object({
60
+ touched: Type.Optional(
61
+ Type.Array(
62
+ Type.String({
63
+ description: "File path read or changed in the session",
64
+ }),
65
+ ),
66
+ ),
67
+ changed: Type.Optional(
68
+ Type.Array(
69
+ Type.String({
70
+ description: "File path changed in the session",
71
+ }),
72
+ ),
73
+ ),
74
+ }),
75
+ ),
76
+ repo: Type.Optional(
77
+ Type.String({
78
+ description: "Git repository touched in the session",
79
+ }),
80
+ ),
81
+ cwd: Type.Optional(
82
+ Type.String({
83
+ description: "Directory of the session",
84
+ }),
85
+ ),
86
+ time: Type.Optional(
87
+ Type.Object({
88
+ after: Type.Optional(
89
+ Type.String({
90
+ description: "Inclusive lower bound for the session activity interval, in ISO format",
91
+ }),
92
+ ),
93
+ before: Type.Optional(
94
+ Type.String({
95
+ description: "Inclusive upper bound for the session activity interval, in ISO format",
96
+ }),
97
+ ),
98
+ }),
99
+ ),
100
+ sort: Type.Optional(
101
+ Type.Union(
102
+ [Type.Literal("relevance"), Type.Literal("modified_desc"), Type.Literal("modified_asc")],
103
+ {
104
+ description: "Display order for returned matches",
105
+ },
106
+ ),
107
+ ),
108
+ live: Type.Optional(
109
+ Type.Boolean({
110
+ description: "When true, only return currently active sessions",
111
+ }),
112
+ ),
113
+ kind: Type.Optional(
114
+ Type.Union([Type.Literal("user"), Type.Literal("subagent")], {
115
+ description: "Filter by session kind",
116
+ }),
117
+ ),
118
+ relationScope: Type.Optional(
119
+ Type.Union([Type.Literal("branch"), Type.Literal("tree")], {
120
+ description:
121
+ "Restrict results to subagents launched from the active branch or anywhere in this session's conversation tree",
122
+ }),
123
+ ),
124
+ limit: Type.Optional(
125
+ Type.Number({
126
+ description: "Number of matches to return",
127
+ }),
128
+ ),
129
+ }),
130
+ async execute(_toolCallId, params: SessionSearchToolParams, _signal, onUpdate, ctx) {
131
+ const validationError = validateSearchParams(params);
132
+ if (validationError) {
133
+ throw new Error(validationError);
134
+ }
135
+
136
+ const progressDetails: SessionSearchToolDetails = {
137
+ params,
138
+ results: [],
139
+ };
140
+ onUpdate?.({
141
+ content: [{ type: "text", text: "Searching sessions..." }],
142
+ details: progressDetails,
143
+ });
144
+
145
+ const scope = await resolveSearchScope(params, deps);
146
+
147
+ return withSessionIndex(indexPath, { mode: "read", required: true }, ({ db, status }) => {
148
+ const results = searchSessions(
149
+ db,
150
+ buildSearchParams(params, ctx, scope.includeSessionIds),
151
+ ).map((result): SessionSearchResult => {
152
+ const annotation = scope.annotations.get(result.sessionId);
153
+ if (!annotation) {
154
+ return result;
155
+ }
156
+ if ("depth" in annotation) {
157
+ return {
158
+ ...result,
159
+ state: annotation.state,
160
+ depth: annotation.depth,
161
+ onActiveBranch: annotation.onActiveBranch,
162
+ };
163
+ }
164
+ return { ...result, state: annotation.state };
165
+ });
166
+ const details: SessionSearchToolDetails = {
167
+ params,
168
+ status,
169
+ results,
170
+ ...(scope.total === undefined
171
+ ? {}
172
+ : { scope: { matched: results.length, total: scope.total } }),
173
+ };
174
+ return {
175
+ content: [
176
+ {
177
+ type: "text" as const,
178
+ text: formatSearchResultsForModel(details),
179
+ },
180
+ ],
181
+ details,
182
+ };
183
+ });
184
+ },
185
+ renderResult: renderSessionSearchResult,
186
+ });
187
+ }
188
+
189
+ function buildSearchParams(
190
+ params: SessionSearchToolParams,
191
+ ctx: ExtensionContext,
192
+ liveSessionIds?: string[] | undefined,
193
+ ): SearchSessionsParams {
194
+ const currentSessionId = ctx.sessionManager.getSessionId();
195
+
196
+ return {
197
+ query: params.query,
198
+ touched: params.files?.touched,
199
+ changed: params.files?.changed,
200
+ repo: params.repo,
201
+ cwd: params.cwd,
202
+ after: params.time?.after,
203
+ before: params.time?.before,
204
+ kind: params.kind,
205
+ sort: params.sort,
206
+ limit: params.limit ?? DEFAULT_SESSION_SEARCH_LIMIT,
207
+ includeSessionIds: liveSessionIds,
208
+ relativeToSessionId: currentSessionId,
209
+ };
210
+ }
211
+
212
+ interface StartingSearchResultAnnotation {
213
+ state: "starting";
214
+ }
215
+
216
+ interface RelatedSubagentSearchResultAnnotation {
217
+ state: SubagentState;
218
+ depth: number;
219
+ onActiveBranch: boolean;
220
+ }
221
+
222
+ type SearchResultAnnotation =
223
+ | StartingSearchResultAnnotation
224
+ | RelatedSubagentSearchResultAnnotation;
225
+
226
+ interface ResolvedSearchScope {
227
+ includeSessionIds: string[] | undefined;
228
+ annotations: ReadonlyMap<string, SearchResultAnnotation>;
229
+ total: number | undefined;
230
+ }
231
+
232
+ async function resolveSearchScope(
233
+ params: SessionSearchToolParams,
234
+ deps: SearchInstallDeps,
235
+ ): Promise<ResolvedSearchScope> {
236
+ if (params.relationScope) {
237
+ if (!deps.roster) {
238
+ throw new Error("Subagent relations are unavailable because subagents are not active.");
239
+ }
240
+ try {
241
+ const roster = await deps.roster.resolve(params.relationScope);
242
+ const entries = params.live
243
+ ? roster.entries.filter((entry) => entry.managedLive)
244
+ : roster.entries;
245
+ const annotations = new Map<string, SearchResultAnnotation>();
246
+ for (const entry of roster.entries) {
247
+ annotations.set(entry.sessionId, {
248
+ state: entry.state,
249
+ depth: entry.depth,
250
+ onActiveBranch: entry.onActiveBranch,
251
+ });
252
+ }
253
+ return {
254
+ includeSessionIds: entries.map((entry) => entry.sessionId),
255
+ annotations,
256
+ total: roster.total,
257
+ };
258
+ } catch (error) {
259
+ throw new Error(`Subagent relation scope is unavailable: ${formatError(error)}`);
260
+ }
261
+ }
262
+
263
+ if (!params.live) {
264
+ return {
265
+ includeSessionIds: undefined,
266
+ annotations: new Map(),
267
+ total: undefined,
268
+ };
269
+ }
270
+
271
+ const liveSessionIds = await getLiveSessionIds(deps.messaging);
272
+ return {
273
+ includeSessionIds: liveSessionIds,
274
+ annotations: getStartingSessionAnnotations(liveSessionIds, deps.index.path),
275
+ total: undefined,
276
+ };
277
+ }
278
+
279
+ function getStartingSessionAnnotations(
280
+ sessionIds: readonly string[],
281
+ indexPath: string,
282
+ ): ReadonlyMap<string, SearchResultAnnotation> {
283
+ return withSessionIndex(indexPath, { mode: "read", required: true }, ({ db }) => {
284
+ const annotations = new Map<string, SearchResultAnnotation>();
285
+ for (const sessionId of sessionIds) {
286
+ const session = getSessionById(db, sessionId);
287
+ if (!session) {
288
+ continue;
289
+ }
290
+ try {
291
+ if (isSessionStarting(SessionManager.open(session.sessionPath).getBranch())) {
292
+ annotations.set(sessionId, { state: "starting" });
293
+ }
294
+ } catch {}
295
+ }
296
+ return annotations;
297
+ });
298
+ }
299
+
300
+ async function getLiveSessionIds(messaging: MessagingHandle | undefined): Promise<string[]> {
301
+ if (!messaging) {
302
+ throw new Error(
303
+ "Session messaging is not active; live session search requires session messaging.",
304
+ );
305
+ }
306
+
307
+ try {
308
+ return await messaging.listSessions();
309
+ } catch (error) {
310
+ throw new Error(`Session messaging is unavailable: ${formatError(error)}`);
311
+ }
312
+ }
313
+
314
+ function formatSearchResultsForModel(details: SessionSearchToolDetails): string {
315
+ return JSON.stringify(
316
+ {
317
+ params: details.params,
318
+ status: details.status,
319
+ ...(details.scope ? { scope: details.scope } : {}),
320
+ results: details.results.map(formatSearchResultForModel),
321
+ },
322
+ null,
323
+ 2,
324
+ );
325
+ }
326
+
327
+ function formatSearchResultForModel(result: SessionSearchResult): SessionSearchResult {
328
+ return {
329
+ ...result,
330
+ snippet: stripSearchSnippetMarkers(result.snippet) ?? result.snippet,
331
+ evidence: result.evidence.map(formatEvidenceForModel),
332
+ };
333
+ }
334
+
335
+ function formatEvidenceForModel(evidence: SessionSearchEvidence): SessionSearchEvidence {
336
+ if (evidence.kind !== "text") {
337
+ return evidence;
338
+ }
339
+
340
+ return {
341
+ ...evidence,
342
+ snippet: stripSearchSnippetMarkers(evidence.snippet) ?? evidence.snippet,
343
+ };
344
+ }
345
+
346
+ function validateSearchParams(params: SessionSearchToolParams): string | undefined {
347
+ if (params.time?.after && !isValidIsoDateLike(params.time.after)) {
348
+ return `Invalid time.after value: ${params.time.after}`;
349
+ }
350
+
351
+ if (params.time?.before && !isValidIsoDateLike(params.time.before)) {
352
+ return `Invalid time.before value: ${params.time.before}`;
353
+ }
354
+
355
+ if (params.time?.after && params.time?.before) {
356
+ const after = new Date(params.time.after);
357
+ const before = new Date(params.time.before);
358
+ if (after.getTime() > before.getTime()) {
359
+ return `time.after must be less than or equal to time.before`;
360
+ }
361
+ }
362
+
363
+ if (params.limit !== undefined && params.limit <= 0) {
364
+ return `limit must be greater than 0`;
365
+ }
366
+
367
+ return undefined;
368
+ }
369
+
370
+ function isValidIsoDateLike(value: string): boolean {
371
+ const date = new Date(value);
372
+ return !Number.isNaN(date.getTime());
373
+ }
@@ -0,0 +1,195 @@
1
+ import path from "node:path";
2
+ import type {
3
+ AgentToolResult,
4
+ Theme,
5
+ ToolRenderResultOptions,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import { type Component, getKeybindings, type Keybinding, Text } from "@earendil-works/pi-tui";
8
+ import {
9
+ stripSearchSnippetMarkers,
10
+ transformSearchSnippetMatches,
11
+ } from "../shared/search-snippet.ts";
12
+ import { formatSessionTitleOrShortId } from "../shared/session-ui.ts";
13
+ import type {
14
+ AnnotatedSearchResult,
15
+ RelatedSubagentSearchResult,
16
+ SessionSearchResult,
17
+ SessionSearchToolDetails,
18
+ SessionSearchToolParams,
19
+ } from "./tool-contract.ts";
20
+
21
+ const COLLAPSED_RESULT_PREVIEW_ROWS = 6;
22
+
23
+ interface SessionSearchRenderContext {
24
+ isError: boolean;
25
+ }
26
+
27
+ export function renderSessionSearchResult(
28
+ result: AgentToolResult<unknown>,
29
+ { expanded, isPartial }: ToolRenderResultOptions,
30
+ theme: Theme,
31
+ context: SessionSearchRenderContext,
32
+ ): Component {
33
+ const details = result.details as SessionSearchToolDetails | undefined;
34
+ const content = result.content[0];
35
+ if (content?.type !== "text") {
36
+ return new Text(theme.fg("error", "No search output"), 0, 0);
37
+ }
38
+
39
+ if (context.isError) {
40
+ return new Text(theme.fg("error", content.text), 0, 0);
41
+ }
42
+
43
+ if (isPartial) {
44
+ const lines = [theme.bold(theme.fg("warning", "Searching sessions..."))];
45
+ lines.push(...formatSessionSearchContextLines(details?.params, theme));
46
+ return new Text(lines.join("\n"), 0, 0);
47
+ }
48
+
49
+ if (!details) {
50
+ return new Text(theme.fg("error", content.text), 0, 0);
51
+ }
52
+
53
+ const lines = formatSessionSearchContextLines(details.params, theme);
54
+ if (details.scope) {
55
+ lines.push(
56
+ theme.fg("dim", `scope: ${details.scope.matched} matched • ${details.scope.total} total`),
57
+ );
58
+ }
59
+ if (details.results.length === 0) {
60
+ if (lines.length > 0) lines.push("");
61
+ lines.push(theme.fg("warning", "No matching sessions found."));
62
+ return new Text(lines.join("\n"), 0, 0);
63
+ }
64
+
65
+ if (lines.length > 0) lines.push("");
66
+ lines.push(...formatSessionSearchPanelResults(details.results, details.params, expanded, theme));
67
+ return new Text(lines.join("\n"), 0, 0);
68
+ }
69
+
70
+ function formatSessionSearchContextLines(
71
+ params: SessionSearchToolParams | undefined,
72
+ theme: Theme,
73
+ ): string[] {
74
+ if (!params) return [];
75
+
76
+ const lines: string[] = [];
77
+ if (params.query?.trim()) {
78
+ lines.push(theme.fg("muted", `query: ${params.query.trim()}`));
79
+ }
80
+
81
+ const filters: string[] = [];
82
+ if (params.repo?.trim()) filters.push(`repo: ${params.repo.trim()}`);
83
+ if (params.cwd?.trim()) filters.push(`cwd: ${params.cwd.trim()}`);
84
+ if (params.files?.touched?.length)
85
+ filters.push(`files.touched: ${params.files.touched.join(", ")}`);
86
+ if (params.files?.changed?.length)
87
+ filters.push(`files.changed: ${params.files.changed.join(", ")}`);
88
+ if (params.live) filters.push("live: true");
89
+ if (params.kind) filters.push(`kind: ${params.kind}`);
90
+ if (params.relationScope) filters.push(`relationScope: ${params.relationScope}`);
91
+ if (params.sort) filters.push(`sort: ${params.sort}`);
92
+ if (params.time?.after?.trim()) filters.push(`after: ${params.time.after.trim()}`);
93
+ if (params.time?.before?.trim()) filters.push(`before: ${params.time.before.trim()}`);
94
+ if (params.limit !== undefined) filters.push(`limit: ${params.limit}`);
95
+
96
+ if (filters.length > 0) {
97
+ lines.push(theme.fg("dim", filters.join(" • ")));
98
+ }
99
+
100
+ if (lines.length === 0) {
101
+ lines.push(theme.fg("dim", "all sessions"));
102
+ }
103
+
104
+ return lines;
105
+ }
106
+
107
+ function formatSessionSearchPanelResults(
108
+ results: SessionSearchResult[],
109
+ params: SessionSearchToolParams | undefined,
110
+ expanded: boolean,
111
+ theme: Theme,
112
+ ): string[] {
113
+ const visibleResults = expanded ? results : results.slice(0, COLLAPSED_RESULT_PREVIEW_ROWS);
114
+ const lines = visibleResults.flatMap((result, index) => {
115
+ const location = formatSearchResultLocation(result.cwd);
116
+ const relation = result.relation ? ` ${theme.fg("dim", `[${result.relation}]`)}` : "";
117
+ const annotation = formatSearchResultAnnotation(result);
118
+ const state = annotation.length > 0 ? ` ${theme.fg("dim", `[${annotation.join(" • ")}]`)}` : "";
119
+ const heading = `${index + 1}. ${theme.bold(formatSearchResultLabel(result))}${relation}${state}${location ? ` ${theme.fg("dim", `(${location})`)}` : ""}`;
120
+ const snippets = params?.query ? formatSearchSnippets(result).slice(0, 3) : [];
121
+ return snippets.length > 0
122
+ ? [heading, ...snippets.map((snippet) => theme.fg("dim", ` - ${snippet}`))]
123
+ : [heading];
124
+ });
125
+
126
+ if (!expanded && results.length > visibleResults.length) {
127
+ lines.push(formatOverflowHint(results.length - visibleResults.length, results.length, theme));
128
+ }
129
+
130
+ return lines;
131
+ }
132
+
133
+ function formatSearchResultAnnotation(result: SessionSearchResult): string[] {
134
+ if (!isAnnotatedSearchResult(result)) {
135
+ return [];
136
+ }
137
+ if (!isRelatedSubagentSearchResult(result)) {
138
+ return [result.state];
139
+ }
140
+ return [result.state, `depth ${result.depth}`, ...(result.onActiveBranch ? [] : ["history"])];
141
+ }
142
+
143
+ function isAnnotatedSearchResult(result: SessionSearchResult): result is AnnotatedSearchResult {
144
+ return "state" in result;
145
+ }
146
+
147
+ function isRelatedSubagentSearchResult(
148
+ result: AnnotatedSearchResult,
149
+ ): result is RelatedSubagentSearchResult {
150
+ return "depth" in result;
151
+ }
152
+
153
+ function formatSearchResultLabel(result: SessionSearchResult): string {
154
+ return formatSessionTitleOrShortId(result.sessionName, result.sessionId);
155
+ }
156
+
157
+ function formatSearchResultLocation(cwd: string | undefined): string | undefined {
158
+ if (!cwd) return undefined;
159
+ const base = path.basename(cwd);
160
+ return base || cwd;
161
+ }
162
+
163
+ function formatSearchSnippets(result: SessionSearchResult): string[] {
164
+ return result.evidence
165
+ .filter((evidence): evidence is SearchTextEvidence => evidence.kind === "text")
166
+ .map((evidence) => formatSearchSnippetText(evidence.snippet))
167
+ .filter((snippet): snippet is string => Boolean(snippet));
168
+ }
169
+
170
+ interface SearchTextEvidence {
171
+ kind: "text";
172
+ sourceKind: string;
173
+ snippet: string;
174
+ score: number;
175
+ entryId: string;
176
+ }
177
+
178
+ function formatSearchSnippetText(snippet: string): string | undefined {
179
+ const plainSnippet = stripSearchSnippetMarkers(snippet)?.replace(/\s+/g, " ").trim();
180
+ if (!plainSnippet) return undefined;
181
+ return transformSearchSnippetMatches(snippet, (match) => `[${match}]`)
182
+ ?.replace(/\s+/g, " ")
183
+ .trim();
184
+ }
185
+
186
+ function formatOverflowHint(remaining: number, total: number, theme: Theme): string {
187
+ return `${theme.fg("muted", `... (${remaining} more lines, ${total} total,`)} ${theme.fg(
188
+ "dim",
189
+ formatKeyHint("app.tools.expand"),
190
+ )}${theme.fg("muted", " to expand)")}`;
191
+ }
192
+
193
+ function formatKeyHint(keybinding: Keybinding): string {
194
+ return getKeybindings().getKeys(keybinding).join("/");
195
+ }
@@ -0,0 +1,51 @@
1
+ import type {
2
+ SearchSessionResult,
3
+ SearchSort,
4
+ SessionIndexStatus,
5
+ SessionKind,
6
+ } from "../shared/session-index/index.ts";
7
+ import type { SubagentState } from "../subagents/classify.ts";
8
+
9
+ export interface SessionSearchToolParams {
10
+ query?: string;
11
+ files?: {
12
+ touched?: string[];
13
+ changed?: string[];
14
+ };
15
+ repo?: string;
16
+ cwd?: string;
17
+ time?: {
18
+ after?: string;
19
+ before?: string;
20
+ };
21
+ sort?: SearchSort;
22
+ limit?: number;
23
+ live?: boolean;
24
+ kind?: SessionKind;
25
+ relationScope?: "branch" | "tree";
26
+ }
27
+
28
+ export interface SessionSearchScopeDetails {
29
+ matched: number;
30
+ total: number;
31
+ }
32
+
33
+ export interface StartingSearchResult extends SearchSessionResult {
34
+ state: "starting";
35
+ }
36
+
37
+ export interface RelatedSubagentSearchResult extends SearchSessionResult {
38
+ state: SubagentState;
39
+ depth: number;
40
+ onActiveBranch: boolean;
41
+ }
42
+
43
+ export type AnnotatedSearchResult = StartingSearchResult | RelatedSubagentSearchResult;
44
+ export type SessionSearchResult = SearchSessionResult | AnnotatedSearchResult;
45
+
46
+ export interface SessionSearchToolDetails {
47
+ params?: SessionSearchToolParams | undefined;
48
+ results: SessionSearchResult[];
49
+ status?: SessionIndexStatus | undefined;
50
+ scope?: SessionSearchScopeDetails | undefined;
51
+ }
@@ -0,0 +1,20 @@
1
+ import type {
2
+ ExtensionContext,
3
+ SessionShutdownEvent,
4
+ SessionStartEvent,
5
+ } from "@earendil-works/pi-coding-agent";
6
+
7
+ /**
8
+ * Session lifecycle hooks a feature exposes to the composition root. The root owns the
9
+ * single `session_start`/`session_shutdown` subscription and drives these deterministically,
10
+ * so features never subscribe to those two events themselves.
11
+ */
12
+ export interface SessionLifecycle {
13
+ onSessionStart?(event: SessionStartEvent, ctx: ExtensionContext): Promise<void> | void;
14
+ onSessionShutdown?(event: SessionShutdownEvent, ctx: ExtensionContext): Promise<void> | void;
15
+ }
16
+
17
+ /** The index substrate handle passed to features that read the session index. */
18
+ export interface IndexHandle {
19
+ path: string;
20
+ }
@@ -0,0 +1,3 @@
1
+ export function formatError(error: unknown): string {
2
+ return error instanceof Error && error.message.trim() ? error.message : String(error);
3
+ }