pi-cc-extensions 0.1.2 → 0.1.3

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.
@@ -1,411 +1,411 @@
1
- import { resolve } from "node:path";
2
- import {
3
- SessionManager,
4
- type ExtensionAPI,
5
- type SessionInfo,
6
- } from "@earendil-works/pi-coding-agent";
7
- import {
8
- Text,
9
- type AutocompleteItem,
10
- type AutocompleteProvider,
11
- type AutocompleteSuggestions,
12
- fuzzyFilter,
13
- } from "@earendil-works/pi-tui";
14
- import {
15
- SESSION_REFERENCE_CUSTOM_TYPE,
16
- SESSION_REFERENCE_PREFIX,
17
- buildReferenceContentFromSections,
18
- extractSessionReferenceIds,
19
- formatReferenceSession,
20
- sessionTitle,
21
- type ReferenceSessionInfo,
22
- type ReferenceSource,
23
- } from "./core.ts";
24
-
25
- const MAX_SESSION_SUGGESTIONS = 5;
26
- const MAX_FILE_SUGGESTIONS = 10;
27
- const MAX_REFERENCED_SESSIONS = 5;
28
- const MENTION_PATTERN = /(?:^|[\t ])@([^\s@]*)$/;
29
- const SUBAGENT_MANAGER_KEY = Symbol.for("pi-subagents:manager");
30
-
31
- // ── In-process subagent record tracker ──────────────────────────────
32
- // pi-subagents does not expose a global manager; we track records
33
- // ourselves by listening to the events it emits.
34
-
35
- type SubagentLiveRecord = {
36
- runId: string;
37
- sessionId: string;
38
- agent: string;
39
- cwd: string;
40
- startedAt: number;
41
- completedAt?: number;
42
- };
43
-
44
- type ReferenceDetails = {
45
- sessions: Array<{ id: string; title: string; cwd: string }>;
46
- };
47
-
48
- type SessionReference = {
49
- kind: "session" | "subagent";
50
- referenceIds: string[];
51
- info: ReferenceSessionInfo;
52
- path?: string;
53
- messages?: unknown[];
54
- };
55
-
56
- type SubagentRecord = {
57
- id: string;
58
- description?: string;
59
- startedAt?: number;
60
- completedAt?: number;
61
- };
62
-
63
- type SubagentManager = {
64
- getRecord(id: string): SubagentRecord | undefined;
65
- };
66
-
67
- // Local subagent record tracking (pi-subagents does not expose a global manager).
68
- const liveSubagentRecords = new Map<string, SubagentLiveRecord>();
69
-
70
- function trackSubagentFromEvent(data: unknown): void {
71
- if (!data || typeof data !== "object") return;
72
- const event = data as Record<string, unknown>;
73
- // subagent:async-started payload has "id" as the run ID
74
- // subagent:async-complete payload has "runId" as the run ID
75
- const runId = (typeof event.id === "string" ? event.id : undefined)
76
- ?? (typeof event.runId === "string" ? event.runId : undefined);
77
- if (!runId) return;
78
- const sessionId = typeof event.sessionId === "string" ? event.sessionId : undefined;
79
- if (!sessionId) return;
80
-
81
- const existing = liveSubagentRecords.get(runId);
82
- if (existing) {
83
- // Completion event — mark completedAt
84
- if (typeof event.endedAt === "number" || typeof event.lastUpdate === "number") {
85
- existing.completedAt = (typeof event.endedAt === "number" ? event.endedAt : event.lastUpdate) as number;
86
- }
87
- return;
88
- }
89
- // Started event — create new record
90
- const agent = typeof event.agent === "string" ? event.agent : "";
91
- const cwd = typeof event.cwd === "string" ? event.cwd : "";
92
- const startedAt = typeof event.startedAt === "number" ? event.startedAt as number : Date.now();
93
- liveSubagentRecords.set(runId, {
94
- runId,
95
- sessionId,
96
- agent,
97
- cwd,
98
- startedAt,
99
- });
100
- }
101
-
102
- function getSubagentManager(): SubagentManager | undefined {
103
- // Try the global manager first (future-proof), fall back to local records.
104
- const manager = (globalThis as any)[SUBAGENT_MANAGER_KEY] as SubagentManager | undefined;
105
- if (manager && typeof manager.getRecord === "function") return manager;
106
- // If no global manager, use our local tracking.
107
- if (liveSubagentRecords.size === 0) return undefined;
108
- return {
109
- getRecord(id: string): SubagentRecord | undefined {
110
- const record = liveSubagentRecords.get(id);
111
- if (!record) return undefined;
112
- return {
113
- id: record.runId,
114
- description: record.agent || undefined,
115
- startedAt: record.startedAt,
116
- completedAt: record.completedAt,
117
- };
118
- },
119
- };
120
- }
121
-
122
- function extractMentionQuery(textBeforeCursor: string): string | undefined {
123
- return textBeforeCursor.match(MENTION_PATTERN)?.[1];
124
- }
125
-
126
- function formatDate(date: Date): string {
127
- return new Intl.DateTimeFormat(undefined, {
128
- year: "numeric",
129
- month: "2-digit",
130
- day: "2-digit",
131
- }).format(date);
132
- }
133
-
134
- function sessionSearchText(reference: SessionReference): string {
135
- return [
136
- reference.info.name,
137
- reference.info.firstMessage,
138
- reference.info.cwd,
139
- reference.info.id,
140
- ...reference.referenceIds,
141
- ].filter(Boolean).join(" ");
142
- }
143
-
144
- function sessionItem(reference: SessionReference, currentCwd: string): AutocompleteItem {
145
- const session = reference.info;
146
- const workspace = samePath(session.cwd, currentCwd) ? "current workspace" : session.cwd || "unknown workspace";
147
- const label = reference.kind === "subagent" ? "[SubAgent]" : "[Session]";
148
- return {
149
- value: `${SESSION_REFERENCE_PREFIX}${reference.referenceIds[0]}`,
150
- label: `${label} ${sessionTitle(session)}`,
151
- description: `${workspace} · ${session.messageCount} messages · ${formatDate(session.modified)}`,
152
- };
153
- }
154
-
155
- function filterSessions(references: SessionReference[], query: string, currentCwd: string): AutocompleteItem[] {
156
- if (query.startsWith("session:")) return [];
157
-
158
- const ordered = [...references].sort((left, right) => {
159
- const leftLocal = samePath(left.info.cwd, currentCwd) ? 1 : 0;
160
- const rightLocal = samePath(right.info.cwd, currentCwd) ? 1 : 0;
161
- return rightLocal - leftLocal || right.info.modified.getTime() - left.info.modified.getTime();
162
- });
163
- const matches = query.trim()
164
- ? fuzzyFilter(ordered, query, sessionSearchText)
165
- : ordered;
166
- return matches.slice(0, MAX_SESSION_SUGGESTIONS).map((reference) => sessionItem(reference, currentCwd));
167
- }
168
-
169
- function filterSessions(agentIds: Set<string>, currentSessionId: string): SessionReference[] {
170
- const manager = getSubagentManager();
171
- if (!manager) return [];
172
-
173
- const references: SessionReference[] = [];
174
- for (const agentId of agentIds) {
175
- const record = manager.getRecord(agentId);
176
- if (!record) continue;
177
-
178
- // Try the global manager's live session first (future-proof).
179
- const liveRecord = liveSubagentRecords.get(agentId);
180
- const sessionId = liveRecord?.sessionId ?? record.id;
181
- if (!sessionId || sessionId === currentSessionId) continue;
182
-
183
- const name = record.description?.trim() || liveRecord?.agent || undefined;
184
- const modifiedAt = record.completedAt ?? record.startedAt ?? Date.now();
185
- references.push({
186
- kind: "subagent",
187
- referenceIds: [sessionId, agentId],
188
- info: {
189
- id: sessionId,
190
- name,
191
- cwd: liveRecord?.cwd ?? "",
192
- firstMessage: "",
193
- messageCount: 0,
194
- modified: new Date(modifiedAt),
195
- },
196
- });
197
- }
198
- return references;
199
- }
200
-
201
- function mergeReferences(sessions: SessionInfo[], subagents: SessionReference[]): SessionReference[] {
202
- const bySessionId = new Map<string, SessionReference>();
203
- for (const session of sessions) {
204
- bySessionId.set(session.id, {
205
- kind: "session",
206
- referenceIds: [session.id],
207
- info: session,
208
- path: session.path,
209
- });
210
- }
211
- for (const subagent of subagents) {
212
- // Prefer the live subagent context when a persisted subagent has the same ID.
213
- bySessionId.set(subagent.info.id, subagent);
214
- }
215
- return [...bySessionId.values()];
216
- }
217
-
218
- function createAutocompleteProvider(
219
- current: AutocompleteProvider,
220
- getReferences: () => Promise<SessionReference[]>,
221
- currentCwd: string,
222
- ): AutocompleteProvider {
223
- return {
224
- triggerCharacters: ["@"],
225
- async getSuggestions(lines, cursorLine, cursorCol, options): Promise<AutocompleteSuggestions | null> {
226
- const currentLine = lines[cursorLine] ?? "";
227
- const query = extractMentionQuery(currentLine.slice(0, cursorCol));
228
- if (query === undefined) {
229
- return current.getSuggestions(lines, cursorLine, cursorCol, options);
230
- }
231
-
232
- const [baseSuggestions, references] = await Promise.all([
233
- current.getSuggestions(lines, cursorLine, cursorCol, options),
234
- getReferences(),
235
- ]);
236
- if (options.signal.aborted) return null;
237
-
238
- const sessionItems = filterSessions(references, query, currentCwd);
239
- const fileItems = (baseSuggestions?.items ?? []).slice(0, MAX_FILE_SUGGESTIONS);
240
- const items = [...sessionItems, ...fileItems];
241
- if (items.length === 0) return null;
242
- return { prefix: `@${query}`, items };
243
- },
244
-
245
- applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
246
- return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
247
- },
248
-
249
- shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
250
- return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
251
- },
252
- };
253
- }
254
-
255
- function samePath(left: string | undefined, right: string): boolean {
256
- if (!left) return false;
257
- const normalizedLeft = resolve(left);
258
- const normalizedRight = resolve(right);
259
- return process.platform === "win32"
260
- ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
261
- : normalizedLeft === normalizedRight;
262
- }
263
-
264
- export default function sessionReferenceExtension(pi: ExtensionAPI): void {
265
- let getAvailableReferences: (() => Promise<SessionReference[]>) | undefined;
266
- const subagentIds = new Set<string>();
267
- // pi-subagents emits "subagent:async-started" and "subagent:async-complete" events.
268
- // We track records locally so @[SubAgent] suggestions work even without a global manager.
269
- const subagentEventNames = ["subagent:async-started", "subagent:async-complete"];
270
- const unsubscribeSubagentEvents = subagentEventNames.map(
271
- (eventName) =>
272
- pi.events.on(eventName, (data) => {
273
- trackSubagentFromEvent(data);
274
- if (!data || typeof data !== "object") return;
275
- const payload = data as Record<string, unknown>;
276
- // subagent:async-started uses "id", subagent:async-complete uses "runId"
277
- const id = (typeof payload.id === "string" ? payload.id : undefined)
278
- ?? (typeof payload.runId === "string" ? payload.runId : undefined);
279
- if (typeof id === "string" && id) subagentIds.add(id);
280
- }),
281
- );
282
-
283
- pi.registerMessageRenderer(SESSION_REFERENCE_CUSTOM_TYPE, (message, _options, theme) => {
284
- const details = message.details as ReferenceDetails | undefined;
285
- const sessions = details?.sessions ?? [];
286
- const labels = sessions.map((session) => session.title).join(", ");
287
- const summary = sessions.length === 1 ? "Referenced 1 session" : `Referenced ${sessions.length} sessions`;
288
- const text = labels ? `${theme.fg("accent", summary)}\n${theme.fg("dim", labels)}` : summary;
289
- return new Text(text, 1, 0);
290
- });
291
-
292
- pi.on("session_start", (_event, ctx) => {
293
- subagentIds.clear();
294
- let loadErrorShown = false;
295
- const currentSessionId = ctx.sessionManager.getSessionId();
296
- const currentSessionFile = ctx.sessionManager.getSessionFile();
297
- let sessionsPromise: Promise<SessionInfo[]> | undefined;
298
-
299
- const getSessions = (): Promise<SessionInfo[]> => {
300
- sessionsPromise ||= SessionManager.listAll()
301
- .then((sessions) =>
302
- sessions.filter(
303
- (session) => session.id !== currentSessionId && !samePath(currentSessionFile, session.path),
304
- ),
305
- )
306
- .catch((error: unknown) => {
307
- if (!loadErrorShown) {
308
- loadErrorShown = true;
309
- const reason = error instanceof Error ? error.message : String(error);
310
- ctx.ui.notify(`session-reference: failed to load sessions: ${reason}`, "error");
311
- }
312
- return [];
313
- });
314
- return sessionsPromise;
315
- };
316
-
317
- const getReferences = async (): Promise<SessionReference[]> =>
318
- mergeReferences(await getSessions(), liveSubagentReferences(subagentIds, currentSessionId));
319
-
320
- getAvailableReferences = getReferences;
321
- if (ctx.mode === "tui") {
322
- void getReferences();
323
- ctx.ui.addAutocompleteProvider((current) => createAutocompleteProvider(current, getReferences, ctx.cwd));
324
- }
325
- });
326
-
327
- pi.on("before_agent_start", async (event, ctx) => {
328
- const referenceIds = extractSessionReferenceIds(event.prompt);
329
- if (referenceIds.length === 0) return;
330
-
331
- const currentSessionId = ctx.sessionManager.getSessionId();
332
- const references = await (
333
- getAvailableReferences?.() ??
334
- SessionManager.listAll().then((sessions) =>
335
- mergeReferences(
336
- sessions.filter((session) => session.id !== currentSessionId),
337
- liveSubagentReferences(subagentIds, currentSessionId),
338
- ),
339
- )
340
- );
341
- const referencesById = new Map<string, SessionReference>();
342
- for (const reference of references) {
343
- for (const id of reference.referenceIds) referencesById.set(id, reference);
344
- }
345
- const seenReferences = new Set<SessionReference>();
346
- const matchingReferences = referenceIds
347
- .map((id) => referencesById.get(id))
348
- .filter((reference): reference is SessionReference => {
349
- if (!reference || reference.info.id === currentSessionId || seenReferences.has(reference)) return false;
350
- seenReferences.add(reference);
351
- return true;
352
- });
353
- const selected = matchingReferences.slice(0, MAX_REFERENCED_SESSIONS);
354
-
355
- if (selected.length === 0) {
356
- ctx.ui.notify("session-reference: referenced sessions were not found", "warning");
357
- return;
358
- }
359
-
360
- if (matchingReferences.length > MAX_REFERENCED_SESSIONS) {
361
- ctx.ui.notify(`session-reference: only the first ${MAX_REFERENCED_SESSIONS} sessions were included`, "warning");
362
- }
363
-
364
- const sections: string[] = [];
365
- const referencedSessions: ReferenceDetails["sessions"] = [];
366
- for (const reference of selected) {
367
- try {
368
- const info = reference.info;
369
- const messages = reference.messages ?? (
370
- reference.path
371
- ? SessionManager.open(reference.path).buildSessionContext().messages
372
- : (() => {
373
- throw new Error("reference session is no longer available");
374
- })()
375
- );
376
- const source: ReferenceSource = { info, messages };
377
- sections.push(formatReferenceSession(source));
378
- referencedSessions.push({
379
- id: info.id,
380
- title: sessionTitle(info),
381
- cwd: info.cwd,
382
- });
383
- } catch (error) {
384
- const reason = error instanceof Error ? error.message : String(error);
385
- ctx.ui.notify(`session-reference: failed to read ${reference.info.id}: ${reason}`, "warning");
386
- }
387
- }
388
- if (sections.length === 0) return;
389
-
390
- const details: ReferenceDetails = { sessions: referencedSessions };
391
- return {
392
- message: {
393
- customType: SESSION_REFERENCE_CUSTOM_TYPE,
394
- content: buildReferenceContentFromSections(sections),
395
- display: true,
396
- details,
397
- },
398
- };
399
- });
400
-
401
- pi.on("session_before_switch", () => {
402
- subagentIds.clear();
403
- getAvailableReferences = undefined;
404
- });
405
-
406
- pi.on("session_shutdown", () => {
407
- for (const unsubscribe of unsubscribeSubagentEvents) unsubscribe();
408
- subagentIds.clear();
409
- getAvailableReferences = undefined;
410
- });
411
- }
1
+ import { resolve } from "node:path";
2
+ import {
3
+ SessionManager,
4
+ type ExtensionAPI,
5
+ type SessionInfo,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import {
8
+ Text,
9
+ type AutocompleteItem,
10
+ type AutocompleteProvider,
11
+ type AutocompleteSuggestions,
12
+ fuzzyFilter,
13
+ } from "@earendil-works/pi-tui";
14
+ import {
15
+ SESSION_REFERENCE_CUSTOM_TYPE,
16
+ SESSION_REFERENCE_PREFIX,
17
+ buildReferenceContentFromSections,
18
+ extractSessionReferenceIds,
19
+ formatReferenceSession,
20
+ sessionTitle,
21
+ type ReferenceSessionInfo,
22
+ type ReferenceSource,
23
+ } from "./core.ts";
24
+
25
+ const MAX_SESSION_SUGGESTIONS = 5;
26
+ const MAX_FILE_SUGGESTIONS = 10;
27
+ const MAX_REFERENCED_SESSIONS = 5;
28
+ const MENTION_PATTERN = /(?:^|[\t ])@([^\s@]*)$/;
29
+ const SUBAGENT_MANAGER_KEY = Symbol.for("pi-subagents:manager");
30
+
31
+ // ── In-process subagent record tracker ──────────────────────────────
32
+ // pi-subagents does not expose a global manager; we track records
33
+ // ourselves by listening to the events it emits.
34
+
35
+ type SubagentLiveRecord = {
36
+ runId: string;
37
+ sessionId: string;
38
+ agent: string;
39
+ cwd: string;
40
+ startedAt: number;
41
+ completedAt?: number;
42
+ };
43
+
44
+ type ReferenceDetails = {
45
+ sessions: Array<{ id: string; title: string; cwd: string }>;
46
+ };
47
+
48
+ type SessionReference = {
49
+ kind: "session" | "subagent";
50
+ referenceIds: string[];
51
+ info: ReferenceSessionInfo;
52
+ path?: string;
53
+ messages?: unknown[];
54
+ };
55
+
56
+ type SubagentRecord = {
57
+ id: string;
58
+ description?: string;
59
+ startedAt?: number;
60
+ completedAt?: number;
61
+ };
62
+
63
+ type SubagentManager = {
64
+ getRecord(id: string): SubagentRecord | undefined;
65
+ };
66
+
67
+ // Local subagent record tracking (pi-subagents does not expose a global manager).
68
+ const liveSubagentRecords = new Map<string, SubagentLiveRecord>();
69
+
70
+ function trackSubagentFromEvent(data: unknown): void {
71
+ if (!data || typeof data !== "object") return;
72
+ const event = data as Record<string, unknown>;
73
+ // subagent:async-started payload has "id" as the run ID
74
+ // subagent:async-complete payload has "runId" as the run ID
75
+ const runId = (typeof event.id === "string" ? event.id : undefined)
76
+ ?? (typeof event.runId === "string" ? event.runId : undefined);
77
+ if (!runId) return;
78
+ const sessionId = typeof event.sessionId === "string" ? event.sessionId : undefined;
79
+ if (!sessionId) return;
80
+
81
+ const existing = liveSubagentRecords.get(runId);
82
+ if (existing) {
83
+ // Completion event — mark completedAt
84
+ if (typeof event.endedAt === "number" || typeof event.lastUpdate === "number") {
85
+ existing.completedAt = (typeof event.endedAt === "number" ? event.endedAt : event.lastUpdate) as number;
86
+ }
87
+ return;
88
+ }
89
+ // Started event — create new record
90
+ const agent = typeof event.agent === "string" ? event.agent : "";
91
+ const cwd = typeof event.cwd === "string" ? event.cwd : "";
92
+ const startedAt = typeof event.startedAt === "number" ? event.startedAt as number : Date.now();
93
+ liveSubagentRecords.set(runId, {
94
+ runId,
95
+ sessionId,
96
+ agent,
97
+ cwd,
98
+ startedAt,
99
+ });
100
+ }
101
+
102
+ function getSubagentManager(): SubagentManager | undefined {
103
+ // Try the global manager first (future-proof), fall back to local records.
104
+ const manager = (globalThis as any)[SUBAGENT_MANAGER_KEY] as SubagentManager | undefined;
105
+ if (manager && typeof manager.getRecord === "function") return manager;
106
+ // If no global manager, use our local tracking.
107
+ if (liveSubagentRecords.size === 0) return undefined;
108
+ return {
109
+ getRecord(id: string): SubagentRecord | undefined {
110
+ const record = liveSubagentRecords.get(id);
111
+ if (!record) return undefined;
112
+ return {
113
+ id: record.runId,
114
+ description: record.agent || undefined,
115
+ startedAt: record.startedAt,
116
+ completedAt: record.completedAt,
117
+ };
118
+ },
119
+ };
120
+ }
121
+
122
+ function extractMentionQuery(textBeforeCursor: string): string | undefined {
123
+ return textBeforeCursor.match(MENTION_PATTERN)?.[1];
124
+ }
125
+
126
+ function formatDate(date: Date): string {
127
+ return new Intl.DateTimeFormat(undefined, {
128
+ year: "numeric",
129
+ month: "2-digit",
130
+ day: "2-digit",
131
+ }).format(date);
132
+ }
133
+
134
+ function sessionSearchText(reference: SessionReference): string {
135
+ return [
136
+ reference.info.name,
137
+ reference.info.firstMessage,
138
+ reference.info.cwd,
139
+ reference.info.id,
140
+ ...reference.referenceIds,
141
+ ].filter(Boolean).join(" ");
142
+ }
143
+
144
+ function sessionItem(reference: SessionReference, currentCwd: string): AutocompleteItem {
145
+ const session = reference.info;
146
+ const workspace = samePath(session.cwd, currentCwd) ? "current workspace" : session.cwd || "unknown workspace";
147
+ const label = reference.kind === "subagent" ? "[SubAgent]" : "[Session]";
148
+ return {
149
+ value: `${SESSION_REFERENCE_PREFIX}${reference.referenceIds[0]}`,
150
+ label: `${label} ${sessionTitle(session)}`,
151
+ description: `${workspace} · ${session.messageCount} messages · ${formatDate(session.modified)}`,
152
+ };
153
+ }
154
+
155
+ function filterSessions(references: SessionReference[], query: string, currentCwd: string): AutocompleteItem[] {
156
+ if (query.startsWith("session:")) return [];
157
+
158
+ const ordered = [...references].sort((left, right) => {
159
+ const leftLocal = samePath(left.info.cwd, currentCwd) ? 1 : 0;
160
+ const rightLocal = samePath(right.info.cwd, currentCwd) ? 1 : 0;
161
+ return rightLocal - leftLocal || right.info.modified.getTime() - left.info.modified.getTime();
162
+ });
163
+ const matches = query.trim()
164
+ ? fuzzyFilter(ordered, query, sessionSearchText)
165
+ : ordered;
166
+ return matches.slice(0, MAX_SESSION_SUGGESTIONS).map((reference) => sessionItem(reference, currentCwd));
167
+ }
168
+
169
+ function liveSubagentReferences(agentIds: Set<string>, currentSessionId: string): SessionReference[] {
170
+ const manager = getSubagentManager();
171
+ if (!manager) return [];
172
+
173
+ const references: SessionReference[] = [];
174
+ for (const agentId of agentIds) {
175
+ const record = manager.getRecord(agentId);
176
+ if (!record) continue;
177
+
178
+ // Try the global manager's live session first (future-proof).
179
+ const liveRecord = liveSubagentRecords.get(agentId);
180
+ const sessionId = liveRecord?.sessionId ?? record.id;
181
+ if (!sessionId || sessionId === currentSessionId) continue;
182
+
183
+ const name = record.description?.trim() || liveRecord?.agent || undefined;
184
+ const modifiedAt = record.completedAt ?? record.startedAt ?? Date.now();
185
+ references.push({
186
+ kind: "subagent",
187
+ referenceIds: [sessionId, agentId],
188
+ info: {
189
+ id: sessionId,
190
+ name,
191
+ cwd: liveRecord?.cwd ?? "",
192
+ firstMessage: "",
193
+ messageCount: 0,
194
+ modified: new Date(modifiedAt),
195
+ },
196
+ });
197
+ }
198
+ return references;
199
+ }
200
+
201
+ function mergeReferences(sessions: SessionInfo[], subagents: SessionReference[]): SessionReference[] {
202
+ const bySessionId = new Map<string, SessionReference>();
203
+ for (const session of sessions) {
204
+ bySessionId.set(session.id, {
205
+ kind: "session",
206
+ referenceIds: [session.id],
207
+ info: session,
208
+ path: session.path,
209
+ });
210
+ }
211
+ for (const subagent of subagents) {
212
+ // Prefer the live subagent context when a persisted subagent has the same ID.
213
+ bySessionId.set(subagent.info.id, subagent);
214
+ }
215
+ return [...bySessionId.values()];
216
+ }
217
+
218
+ function createAutocompleteProvider(
219
+ current: AutocompleteProvider,
220
+ getReferences: () => Promise<SessionReference[]>,
221
+ currentCwd: string,
222
+ ): AutocompleteProvider {
223
+ return {
224
+ triggerCharacters: ["@"],
225
+ async getSuggestions(lines, cursorLine, cursorCol, options): Promise<AutocompleteSuggestions | null> {
226
+ const currentLine = lines[cursorLine] ?? "";
227
+ const query = extractMentionQuery(currentLine.slice(0, cursorCol));
228
+ if (query === undefined) {
229
+ return current.getSuggestions(lines, cursorLine, cursorCol, options);
230
+ }
231
+
232
+ const [baseSuggestions, references] = await Promise.all([
233
+ current.getSuggestions(lines, cursorLine, cursorCol, options),
234
+ getReferences(),
235
+ ]);
236
+ if (options.signal.aborted) return null;
237
+
238
+ const sessionItems = filterSessions(references, query, currentCwd);
239
+ const fileItems = (baseSuggestions?.items ?? []).slice(0, MAX_FILE_SUGGESTIONS);
240
+ const items = [...sessionItems, ...fileItems];
241
+ if (items.length === 0) return null;
242
+ return { prefix: `@${query}`, items };
243
+ },
244
+
245
+ applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
246
+ return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
247
+ },
248
+
249
+ shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
250
+ return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
251
+ },
252
+ };
253
+ }
254
+
255
+ function samePath(left: string | undefined, right: string): boolean {
256
+ if (!left) return false;
257
+ const normalizedLeft = resolve(left);
258
+ const normalizedRight = resolve(right);
259
+ return process.platform === "win32"
260
+ ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
261
+ : normalizedLeft === normalizedRight;
262
+ }
263
+
264
+ export default function sessionReferenceExtension(pi: ExtensionAPI): void {
265
+ let getAvailableReferences: (() => Promise<SessionReference[]>) | undefined;
266
+ const subagentIds = new Set<string>();
267
+ // pi-subagents emits "subagent:async-started" and "subagent:async-complete" events.
268
+ // We track records locally so @[SubAgent] suggestions work even without a global manager.
269
+ const subagentEventNames = ["subagent:async-started", "subagent:async-complete"];
270
+ const unsubscribeSubagentEvents = subagentEventNames.map(
271
+ (eventName) =>
272
+ pi.events.on(eventName, (data) => {
273
+ trackSubagentFromEvent(data);
274
+ if (!data || typeof data !== "object") return;
275
+ const payload = data as Record<string, unknown>;
276
+ // subagent:async-started uses "id", subagent:async-complete uses "runId"
277
+ const id = (typeof payload.id === "string" ? payload.id : undefined)
278
+ ?? (typeof payload.runId === "string" ? payload.runId : undefined);
279
+ if (typeof id === "string" && id) subagentIds.add(id);
280
+ }),
281
+ );
282
+
283
+ pi.registerMessageRenderer(SESSION_REFERENCE_CUSTOM_TYPE, (message, _options, theme) => {
284
+ const details = message.details as ReferenceDetails | undefined;
285
+ const sessions = details?.sessions ?? [];
286
+ const labels = sessions.map((session) => session.title).join(", ");
287
+ const summary = sessions.length === 1 ? "Referenced 1 session" : `Referenced ${sessions.length} sessions`;
288
+ const text = labels ? `${theme.fg("accent", summary)}\n${theme.fg("dim", labels)}` : summary;
289
+ return new Text(text, 1, 0);
290
+ });
291
+
292
+ pi.on("session_start", (_event, ctx) => {
293
+ subagentIds.clear();
294
+ let loadErrorShown = false;
295
+ const currentSessionId = ctx.sessionManager.getSessionId();
296
+ const currentSessionFile = ctx.sessionManager.getSessionFile();
297
+ let sessionsPromise: Promise<SessionInfo[]> | undefined;
298
+
299
+ const getSessions = (): Promise<SessionInfo[]> => {
300
+ sessionsPromise ||= SessionManager.listAll()
301
+ .then((sessions) =>
302
+ sessions.filter(
303
+ (session) => session.id !== currentSessionId && !samePath(currentSessionFile, session.path),
304
+ ),
305
+ )
306
+ .catch((error: unknown) => {
307
+ if (!loadErrorShown) {
308
+ loadErrorShown = true;
309
+ const reason = error instanceof Error ? error.message : String(error);
310
+ ctx.ui.notify(`session-reference: failed to load sessions: ${reason}`, "error");
311
+ }
312
+ return [];
313
+ });
314
+ return sessionsPromise;
315
+ };
316
+
317
+ const getReferences = async (): Promise<SessionReference[]> =>
318
+ mergeReferences(await getSessions(), liveSubagentReferences(subagentIds, currentSessionId));
319
+
320
+ getAvailableReferences = getReferences;
321
+ if (ctx.mode === "tui") {
322
+ void getReferences();
323
+ ctx.ui.addAutocompleteProvider((current) => createAutocompleteProvider(current, getReferences, ctx.cwd));
324
+ }
325
+ });
326
+
327
+ pi.on("before_agent_start", async (event, ctx) => {
328
+ const referenceIds = extractSessionReferenceIds(event.prompt);
329
+ if (referenceIds.length === 0) return;
330
+
331
+ const currentSessionId = ctx.sessionManager.getSessionId();
332
+ const references = await (
333
+ getAvailableReferences?.() ??
334
+ SessionManager.listAll().then((sessions) =>
335
+ mergeReferences(
336
+ sessions.filter((session) => session.id !== currentSessionId),
337
+ liveSubagentReferences(subagentIds, currentSessionId),
338
+ ),
339
+ )
340
+ );
341
+ const referencesById = new Map<string, SessionReference>();
342
+ for (const reference of references) {
343
+ for (const id of reference.referenceIds) referencesById.set(id, reference);
344
+ }
345
+ const seenReferences = new Set<SessionReference>();
346
+ const matchingReferences = referenceIds
347
+ .map((id) => referencesById.get(id))
348
+ .filter((reference): reference is SessionReference => {
349
+ if (!reference || reference.info.id === currentSessionId || seenReferences.has(reference)) return false;
350
+ seenReferences.add(reference);
351
+ return true;
352
+ });
353
+ const selected = matchingReferences.slice(0, MAX_REFERENCED_SESSIONS);
354
+
355
+ if (selected.length === 0) {
356
+ ctx.ui.notify("session-reference: referenced sessions were not found", "warning");
357
+ return;
358
+ }
359
+
360
+ if (matchingReferences.length > MAX_REFERENCED_SESSIONS) {
361
+ ctx.ui.notify(`session-reference: only the first ${MAX_REFERENCED_SESSIONS} sessions were included`, "warning");
362
+ }
363
+
364
+ const sections: string[] = [];
365
+ const referencedSessions: ReferenceDetails["sessions"] = [];
366
+ for (const reference of selected) {
367
+ try {
368
+ const info = reference.info;
369
+ const messages = reference.messages ?? (
370
+ reference.path
371
+ ? SessionManager.open(reference.path).buildSessionContext().messages
372
+ : (() => {
373
+ throw new Error("reference session is no longer available");
374
+ })()
375
+ );
376
+ const source: ReferenceSource = { info, messages };
377
+ sections.push(formatReferenceSession(source));
378
+ referencedSessions.push({
379
+ id: info.id,
380
+ title: sessionTitle(info),
381
+ cwd: info.cwd,
382
+ });
383
+ } catch (error) {
384
+ const reason = error instanceof Error ? error.message : String(error);
385
+ ctx.ui.notify(`session-reference: failed to read ${reference.info.id}: ${reason}`, "warning");
386
+ }
387
+ }
388
+ if (sections.length === 0) return;
389
+
390
+ const details: ReferenceDetails = { sessions: referencedSessions };
391
+ return {
392
+ message: {
393
+ customType: SESSION_REFERENCE_CUSTOM_TYPE,
394
+ content: buildReferenceContentFromSections(sections),
395
+ display: true,
396
+ details,
397
+ },
398
+ };
399
+ });
400
+
401
+ pi.on("session_before_switch", () => {
402
+ subagentIds.clear();
403
+ getAvailableReferences = undefined;
404
+ });
405
+
406
+ pi.on("session_shutdown", () => {
407
+ for (const unsubscribe of unsubscribeSubagentEvents) unsubscribe();
408
+ subagentIds.clear();
409
+ getAvailableReferences = undefined;
410
+ });
411
+ }