pi-cc-extensions 0.8.9 → 0.8.11

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 (42) hide show
  1. package/README.md +3 -2
  2. package/extensions/agent-autocomplete.ts +11 -16
  3. package/extensions/ask-user-question/ask-user-question.ts +29 -7
  4. package/extensions/ask-user-question/config.ts +6 -2
  5. package/extensions/ask-user-question/index.ts +8 -8
  6. package/extensions/ask-user-question/rpc-fallback.ts +16 -4
  7. package/extensions/ask-user-question/state/build-questionnaire.ts +16 -5
  8. package/extensions/ask-user-question/state/key-router.ts +29 -7
  9. package/extensions/ask-user-question/state/questionnaire-session.ts +1 -4
  10. package/extensions/ask-user-question/state/selectors/projections.ts +4 -1
  11. package/extensions/ask-user-question/state/state-reducer.ts +24 -7
  12. package/extensions/ask-user-question/tool/response-envelope.ts +4 -1
  13. package/extensions/ask-user-question/tool/types.ts +5 -1
  14. package/extensions/ask-user-question/tool/validate-questionnaire.ts +10 -2
  15. package/extensions/ask-user-question/view/components/inline-input.ts +15 -2
  16. package/extensions/ask-user-question/view/components/multi-select-view.ts +11 -4
  17. package/extensions/ask-user-question/view/components/option-list-view.ts +14 -5
  18. package/extensions/ask-user-question/view/components/preview/preview-block-renderer.ts +13 -7
  19. package/extensions/ask-user-question/view/components/preview/preview-layout-decider.ts +16 -4
  20. package/extensions/ask-user-question/view/components/preview/preview-pane.ts +5 -1
  21. package/extensions/ask-user-question/view/components/submit-picker.ts +3 -1
  22. package/extensions/ask-user-question/view/components/wrapping-select.ts +20 -4
  23. package/extensions/ask-user-question/view/dialog-builder.ts +27 -7
  24. package/extensions/ask-user-question/view/tab-content-strategy.ts +23 -8
  25. package/extensions/claude-code-style.ts +1722 -1485
  26. package/extensions/compact-style.ts +120 -43
  27. package/extensions/context.ts +502 -394
  28. package/extensions/session-reference-core.ts +8 -3
  29. package/extensions/session-reference.ts +475 -448
  30. package/extensions/tool-diff/ATTRIBUTION.md +28 -0
  31. package/extensions/tool-diff/ansi-utils.ts +113 -0
  32. package/extensions/tool-diff/diff-presentation.ts +70 -0
  33. package/extensions/tool-diff/diff-renderer.ts +2827 -0
  34. package/extensions/tool-diff/index.ts +106 -0
  35. package/extensions/tool-diff/line-width-safety.ts +91 -0
  36. package/extensions/tool-diff/render-utils.ts +5 -0
  37. package/extensions/tool-diff/types.ts +20 -0
  38. package/extensions/tool-diff/write-display-utils.ts +29 -0
  39. package/extensions/tool-diff/write-execution.ts +113 -0
  40. package/extensions/tool-result-sanitize.ts +4 -4
  41. package/package.json +64 -61
  42. package/themes/github-dark-default.json +91 -91
@@ -1,448 +1,475 @@
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 "./session-reference-core.ts";
24
-
25
- const MAX_SESSION_SUGGESTIONS = 3;
26
- const MAX_FILE_SUGGESTIONS = 7;
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 isPathLikeQuery(query: string): boolean {
170
- return /[\\/*?]/.test(query) || /\.[A-Za-z0-9_-]{1,12}$/.test(query);
171
- }
172
-
173
- function mergeSessionAndFileItems(
174
- sessionItems: AutocompleteItem[],
175
- fileItems: AutocompleteItem[],
176
- query: string,
177
- ): AutocompleteItem[] {
178
- const sessions = sessionItems.slice(0, MAX_SESSION_SUGGESTIONS);
179
- const files = fileItems.slice(0, MAX_FILE_SUGGESTIONS);
180
- if (isPathLikeQuery(query)) return [...files, ...sessions];
181
-
182
- const merged: AutocompleteItem[] = [];
183
- let sessionIndex = 0;
184
- let fileIndex = 0;
185
- while (sessionIndex < sessions.length || fileIndex < files.length) {
186
- for (let count = 0; count < 2 && fileIndex < files.length; count++) {
187
- merged.push(files[fileIndex++]!);
188
- }
189
- if (sessionIndex < sessions.length) merged.push(sessions[sessionIndex++]!);
190
- }
191
- return merged;
192
- }
193
-
194
- function liveSubagentReferences(agentIds: Set<string>, currentSessionId: string): SessionReference[] {
195
- const manager = getSubagentManager();
196
- if (!manager) return [];
197
-
198
- const references: SessionReference[] = [];
199
- for (const agentId of agentIds) {
200
- const record = manager.getRecord(agentId);
201
- if (!record) continue;
202
-
203
- // Try the global manager's live session first (future-proof).
204
- const liveRecord = liveSubagentRecords.get(agentId);
205
- const sessionId = liveRecord?.sessionId ?? record.id;
206
- if (!sessionId || sessionId === currentSessionId) continue;
207
-
208
- const name = record.description?.trim() || liveRecord?.agent || undefined;
209
- const modifiedAt = record.completedAt ?? record.startedAt ?? Date.now();
210
- references.push({
211
- kind: "subagent",
212
- referenceIds: [sessionId, agentId],
213
- info: {
214
- id: sessionId,
215
- name,
216
- cwd: liveRecord?.cwd ?? "",
217
- firstMessage: "",
218
- messageCount: 0,
219
- modified: new Date(modifiedAt),
220
- },
221
- });
222
- }
223
- return references;
224
- }
225
-
226
- function mergeReferences(sessions: SessionInfo[], subagents: SessionReference[]): SessionReference[] {
227
- const bySessionId = new Map<string, SessionReference>();
228
- for (const session of sessions) {
229
- bySessionId.set(session.id, {
230
- kind: "session",
231
- referenceIds: [session.id],
232
- info: session,
233
- path: session.path,
234
- });
235
- }
236
- for (const subagent of subagents) {
237
- // Prefer the live subagent context when a persisted subagent has the same ID.
238
- bySessionId.set(subagent.info.id, subagent);
239
- }
240
- return [...bySessionId.values()];
241
- }
242
-
243
- export function createAutocompleteProvider(
244
- current: AutocompleteProvider,
245
- getReferences: () => Promise<SessionReference[]>,
246
- currentCwd: string,
247
- ): AutocompleteProvider {
248
- return {
249
- triggerCharacters: ["@"],
250
- async getSuggestions(lines, cursorLine, cursorCol, options): Promise<AutocompleteSuggestions | null> {
251
- const currentLine = lines[cursorLine] ?? "";
252
- const query = extractMentionQuery(currentLine.slice(0, cursorCol));
253
- if (query === undefined) {
254
- return current.getSuggestions(lines, cursorLine, cursorCol, options);
255
- }
256
-
257
- const [baseSuggestions, references] = await Promise.all([
258
- current.getSuggestions(lines, cursorLine, cursorCol, options),
259
- getReferences(),
260
- ]);
261
- if (options.signal.aborted) return null;
262
-
263
- const sessionItems = filterSessions(references, query, currentCwd);
264
- const fileItems = baseSuggestions?.prefix === `@${query}`
265
- ? baseSuggestions.items
266
- : [];
267
- const items = mergeSessionAndFileItems(sessionItems, fileItems, query);
268
- if (items.length === 0) return baseSuggestions;
269
- return { prefix: `@${query}`, items };
270
- },
271
-
272
- applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
273
- return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
274
- },
275
-
276
- shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
277
- return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
278
- },
279
- };
280
- }
281
-
282
- function samePath(left: string | undefined, right: string): boolean {
283
- if (!left) return false;
284
- const normalizedLeft = resolve(left);
285
- const normalizedRight = resolve(right);
286
- return process.platform === "win32"
287
- ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
288
- : normalizedLeft === normalizedRight;
289
- }
290
-
291
- export default function sessionReferenceExtension(pi: ExtensionAPI): void {
292
- let getAvailableReferences: (() => Promise<SessionReference[]>) | undefined;
293
- let sessionGeneration = 0;
294
- const subagentIds = new Set<string>();
295
- // pi-subagents emits "subagent:async-started" and "subagent:async-complete" events.
296
- // We track records locally so @[SubAgent] suggestions work even without a global manager.
297
- const subagentEventNames = ["subagent:async-started", "subagent:async-complete"];
298
- const unsubscribeSubagentEvents = subagentEventNames.map(
299
- (eventName) =>
300
- pi.events.on(eventName, (data) => {
301
- trackSubagentFromEvent(data);
302
- if (!data || typeof data !== "object") return;
303
- const payload = data as Record<string, unknown>;
304
- // subagent:async-started uses "id", subagent:async-complete uses "runId"
305
- const id = (typeof payload.id === "string" ? payload.id : undefined)
306
- ?? (typeof payload.runId === "string" ? payload.runId : undefined);
307
- if (typeof id === "string" && id) subagentIds.add(id);
308
- }),
309
- );
310
-
311
- pi.registerMessageRenderer(SESSION_REFERENCE_CUSTOM_TYPE, (message, _options, theme) => {
312
- const details = message.details as ReferenceDetails | undefined;
313
- const sessions = details?.sessions ?? [];
314
- const labels = sessions.map((session) => session.title).join(", ");
315
- const summary = sessions.length === 1 ? "Referenced 1 session" : `Referenced ${sessions.length} sessions`;
316
- const text = labels ? `${theme.fg("accent", summary)}\n${theme.fg("dim", labels)}` : summary;
317
- return new Text(text, 1, 0);
318
- });
319
-
320
- pi.on("session_start", (_event, ctx) => {
321
- const generation = ++sessionGeneration;
322
- subagentIds.clear();
323
- let loadErrorShown = false;
324
- const currentSessionId = ctx.sessionManager.getSessionId();
325
- const currentSessionFile = ctx.sessionManager.getSessionFile();
326
- let sessionsPromise: Promise<SessionInfo[]> | undefined;
327
-
328
- const getSessions = (): Promise<SessionInfo[]> => {
329
- sessionsPromise ||= SessionManager.listAll()
330
- .then((sessions) =>
331
- sessions.filter(
332
- (session) => session.id !== currentSessionId && !samePath(currentSessionFile, session.path),
333
- ),
334
- )
335
- .catch((error: unknown) => {
336
- if (!loadErrorShown) {
337
- loadErrorShown = true;
338
- const reason = error instanceof Error ? error.message : String(error);
339
- ctx.ui.notify(`session-reference: failed to load sessions: ${reason}`, "error");
340
- }
341
- return [];
342
- });
343
- return sessionsPromise;
344
- };
345
-
346
- const getReferences = async (): Promise<SessionReference[]> =>
347
- mergeReferences(await getSessions(), liveSubagentReferences(subagentIds, currentSessionId));
348
-
349
- getAvailableReferences = getReferences;
350
- if (ctx.mode === "tui") {
351
- void getReferences();
352
- // Register after other session_start handlers. pi-fff claims every @
353
- // prefix, so a provider installed before it would never see session mentions.
354
- setTimeout(() => {
355
- if (generation !== sessionGeneration) return;
356
- ctx.ui.addAutocompleteProvider((current) =>
357
- createAutocompleteProvider(current, getReferences, ctx.cwd),
358
- );
359
- }, 0);
360
- }
361
- });
362
-
363
- pi.on("before_agent_start", async (event, ctx) => {
364
- const referenceIds = extractSessionReferenceIds(event.prompt);
365
- if (referenceIds.length === 0) return;
366
-
367
- const currentSessionId = ctx.sessionManager.getSessionId();
368
- const references = await (
369
- getAvailableReferences?.() ??
370
- SessionManager.listAll().then((sessions) =>
371
- mergeReferences(
372
- sessions.filter((session) => session.id !== currentSessionId),
373
- liveSubagentReferences(subagentIds, currentSessionId),
374
- ),
375
- )
376
- );
377
- const referencesById = new Map<string, SessionReference>();
378
- for (const reference of references) {
379
- for (const id of reference.referenceIds) referencesById.set(id, reference);
380
- }
381
- const seenReferences = new Set<SessionReference>();
382
- const matchingReferences = referenceIds
383
- .map((id) => referencesById.get(id))
384
- .filter((reference): reference is SessionReference => {
385
- if (!reference || reference.info.id === currentSessionId || seenReferences.has(reference)) return false;
386
- seenReferences.add(reference);
387
- return true;
388
- });
389
- const selected = matchingReferences.slice(0, MAX_REFERENCED_SESSIONS);
390
-
391
- if (selected.length === 0) {
392
- ctx.ui.notify("session-reference: referenced sessions were not found", "warning");
393
- return;
394
- }
395
-
396
- if (matchingReferences.length > MAX_REFERENCED_SESSIONS) {
397
- ctx.ui.notify(`session-reference: only the first ${MAX_REFERENCED_SESSIONS} sessions were included`, "warning");
398
- }
399
-
400
- const sections: string[] = [];
401
- const referencedSessions: ReferenceDetails["sessions"] = [];
402
- for (const reference of selected) {
403
- try {
404
- const info = reference.info;
405
- const messages = reference.messages ?? (
406
- reference.path
407
- ? SessionManager.open(reference.path).buildSessionContext().messages
408
- : (() => {
409
- throw new Error("reference session is no longer available");
410
- })()
411
- );
412
- const source: ReferenceSource = { info, messages };
413
- sections.push(formatReferenceSession(source));
414
- referencedSessions.push({
415
- id: info.id,
416
- title: sessionTitle(info),
417
- cwd: info.cwd,
418
- });
419
- } catch (error) {
420
- const reason = error instanceof Error ? error.message : String(error);
421
- ctx.ui.notify(`session-reference: failed to read ${reference.info.id}: ${reason}`, "warning");
422
- }
423
- }
424
- if (sections.length === 0) return;
425
-
426
- const details: ReferenceDetails = { sessions: referencedSessions };
427
- return {
428
- message: {
429
- customType: SESSION_REFERENCE_CUSTOM_TYPE,
430
- content: buildReferenceContentFromSections(sections),
431
- display: true,
432
- details,
433
- },
434
- };
435
- });
436
-
437
- pi.on("session_before_switch", () => {
438
- subagentIds.clear();
439
- getAvailableReferences = undefined;
440
- });
441
-
442
- pi.on("session_shutdown", () => {
443
- sessionGeneration++;
444
- for (const unsubscribe of unsubscribeSubagentEvents) unsubscribe();
445
- subagentIds.clear();
446
- getAvailableReferences = undefined;
447
- });
448
- }
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 "./session-reference-core.ts";
24
+
25
+ const MAX_SESSION_SUGGESTIONS = 3;
26
+ const MAX_FILE_SUGGESTIONS = 7;
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 =
76
+ (typeof event.id === "string" ? event.id : undefined) ??
77
+ (typeof event.runId === "string" ? event.runId : undefined);
78
+ if (!runId) return;
79
+ const sessionId = typeof event.sessionId === "string" ? event.sessionId : undefined;
80
+ if (!sessionId) return;
81
+
82
+ const existing = liveSubagentRecords.get(runId);
83
+ if (existing) {
84
+ // Completion event — mark completedAt
85
+ if (typeof event.endedAt === "number" || typeof event.lastUpdate === "number") {
86
+ existing.completedAt = (
87
+ typeof event.endedAt === "number" ? event.endedAt : event.lastUpdate
88
+ ) as number;
89
+ }
90
+ return;
91
+ }
92
+ // Started event — create new record
93
+ const agent = typeof event.agent === "string" ? event.agent : "";
94
+ const cwd = typeof event.cwd === "string" ? event.cwd : "";
95
+ const startedAt = typeof event.startedAt === "number" ? (event.startedAt as number) : Date.now();
96
+ liveSubagentRecords.set(runId, {
97
+ runId,
98
+ sessionId,
99
+ agent,
100
+ cwd,
101
+ startedAt,
102
+ });
103
+ }
104
+
105
+ function getSubagentManager(): SubagentManager | undefined {
106
+ // Try the global manager first (future-proof), fall back to local records.
107
+ const manager = (globalThis as any)[SUBAGENT_MANAGER_KEY] as SubagentManager | undefined;
108
+ if (manager && typeof manager.getRecord === "function") return manager;
109
+ // If no global manager, use our local tracking.
110
+ if (liveSubagentRecords.size === 0) return undefined;
111
+ return {
112
+ getRecord(id: string): SubagentRecord | undefined {
113
+ const record = liveSubagentRecords.get(id);
114
+ if (!record) return undefined;
115
+ return {
116
+ id: record.runId,
117
+ description: record.agent || undefined,
118
+ startedAt: record.startedAt,
119
+ completedAt: record.completedAt,
120
+ };
121
+ },
122
+ };
123
+ }
124
+
125
+ function extractMentionQuery(textBeforeCursor: string): string | undefined {
126
+ return textBeforeCursor.match(MENTION_PATTERN)?.[1];
127
+ }
128
+
129
+ function formatDate(date: Date): string {
130
+ return new Intl.DateTimeFormat(undefined, {
131
+ year: "numeric",
132
+ month: "2-digit",
133
+ day: "2-digit",
134
+ }).format(date);
135
+ }
136
+
137
+ function sessionSearchText(reference: SessionReference): string {
138
+ return [
139
+ reference.info.name,
140
+ reference.info.firstMessage,
141
+ reference.info.cwd,
142
+ reference.info.id,
143
+ ...reference.referenceIds,
144
+ ]
145
+ .filter(Boolean)
146
+ .join(" ");
147
+ }
148
+
149
+ function sessionItem(reference: SessionReference, currentCwd: string): AutocompleteItem {
150
+ const session = reference.info;
151
+ const workspace = samePath(session.cwd, currentCwd)
152
+ ? "current workspace"
153
+ : session.cwd || "unknown workspace";
154
+ const label = reference.kind === "subagent" ? "[SubAgent]" : "[Session]";
155
+ return {
156
+ value: `${SESSION_REFERENCE_PREFIX}${reference.referenceIds[0]}`,
157
+ label: `${label} ${sessionTitle(session)}`,
158
+ description: `${workspace} · ${session.messageCount} messages · ${formatDate(session.modified)}`,
159
+ };
160
+ }
161
+
162
+ function filterSessions(
163
+ references: SessionReference[],
164
+ query: string,
165
+ currentCwd: string,
166
+ ): AutocompleteItem[] {
167
+ if (query.startsWith("session:")) return [];
168
+
169
+ const ordered = [...references].sort((left, right) => {
170
+ const leftLocal = samePath(left.info.cwd, currentCwd) ? 1 : 0;
171
+ const rightLocal = samePath(right.info.cwd, currentCwd) ? 1 : 0;
172
+ return rightLocal - leftLocal || right.info.modified.getTime() - left.info.modified.getTime();
173
+ });
174
+ const matches = query.trim() ? fuzzyFilter(ordered, query, sessionSearchText) : ordered;
175
+ return matches
176
+ .slice(0, MAX_SESSION_SUGGESTIONS)
177
+ .map((reference) => sessionItem(reference, currentCwd));
178
+ }
179
+
180
+ function isPathLikeQuery(query: string): boolean {
181
+ return /[\\/*?]/.test(query) || /\.[A-Za-z0-9_-]{1,12}$/.test(query);
182
+ }
183
+
184
+ function mergeSessionAndFileItems(
185
+ sessionItems: AutocompleteItem[],
186
+ fileItems: AutocompleteItem[],
187
+ query: string,
188
+ ): AutocompleteItem[] {
189
+ const sessions = sessionItems.slice(0, MAX_SESSION_SUGGESTIONS);
190
+ const files = fileItems.slice(0, MAX_FILE_SUGGESTIONS);
191
+ if (isPathLikeQuery(query)) return [...files, ...sessions];
192
+
193
+ const merged: AutocompleteItem[] = [];
194
+ let sessionIndex = 0;
195
+ let fileIndex = 0;
196
+ while (sessionIndex < sessions.length || fileIndex < files.length) {
197
+ for (let count = 0; count < 2 && fileIndex < files.length; count++) {
198
+ merged.push(files[fileIndex++]!);
199
+ }
200
+ if (sessionIndex < sessions.length) merged.push(sessions[sessionIndex++]!);
201
+ }
202
+ return merged;
203
+ }
204
+
205
+ function liveSubagentReferences(
206
+ agentIds: Set<string>,
207
+ currentSessionId: string,
208
+ ): SessionReference[] {
209
+ const manager = getSubagentManager();
210
+ if (!manager) return [];
211
+
212
+ const references: SessionReference[] = [];
213
+ for (const agentId of agentIds) {
214
+ const record = manager.getRecord(agentId);
215
+ if (!record) continue;
216
+
217
+ // Try the global manager's live session first (future-proof).
218
+ const liveRecord = liveSubagentRecords.get(agentId);
219
+ const sessionId = liveRecord?.sessionId ?? record.id;
220
+ if (!sessionId || sessionId === currentSessionId) continue;
221
+
222
+ const name = record.description?.trim() || liveRecord?.agent || undefined;
223
+ const modifiedAt = record.completedAt ?? record.startedAt ?? Date.now();
224
+ references.push({
225
+ kind: "subagent",
226
+ referenceIds: [sessionId, agentId],
227
+ info: {
228
+ id: sessionId,
229
+ name,
230
+ cwd: liveRecord?.cwd ?? "",
231
+ firstMessage: "",
232
+ messageCount: 0,
233
+ modified: new Date(modifiedAt),
234
+ },
235
+ });
236
+ }
237
+ return references;
238
+ }
239
+
240
+ function mergeReferences(
241
+ sessions: SessionInfo[],
242
+ subagents: SessionReference[],
243
+ ): SessionReference[] {
244
+ const bySessionId = new Map<string, SessionReference>();
245
+ for (const session of sessions) {
246
+ bySessionId.set(session.id, {
247
+ kind: "session",
248
+ referenceIds: [session.id],
249
+ info: session,
250
+ path: session.path,
251
+ });
252
+ }
253
+ for (const subagent of subagents) {
254
+ // Prefer the live subagent context when a persisted subagent has the same ID.
255
+ bySessionId.set(subagent.info.id, subagent);
256
+ }
257
+ return [...bySessionId.values()];
258
+ }
259
+
260
+ export function createAutocompleteProvider(
261
+ current: AutocompleteProvider,
262
+ getReferences: () => Promise<SessionReference[]>,
263
+ currentCwd: string,
264
+ ): AutocompleteProvider {
265
+ return {
266
+ triggerCharacters: ["@"],
267
+ async getSuggestions(
268
+ lines,
269
+ cursorLine,
270
+ cursorCol,
271
+ options,
272
+ ): Promise<AutocompleteSuggestions | null> {
273
+ const currentLine = lines[cursorLine] ?? "";
274
+ const query = extractMentionQuery(currentLine.slice(0, cursorCol));
275
+ if (query === undefined) {
276
+ return current.getSuggestions(lines, cursorLine, cursorCol, options);
277
+ }
278
+
279
+ const [baseSuggestions, references] = await Promise.all([
280
+ current.getSuggestions(lines, cursorLine, cursorCol, options),
281
+ getReferences(),
282
+ ]);
283
+ if (options.signal.aborted) return null;
284
+
285
+ const sessionItems = filterSessions(references, query, currentCwd);
286
+ const fileItems = baseSuggestions?.prefix === `@${query}` ? baseSuggestions.items : [];
287
+ const items = mergeSessionAndFileItems(sessionItems, fileItems, query);
288
+ if (items.length === 0) return baseSuggestions;
289
+ return { prefix: `@${query}`, items };
290
+ },
291
+
292
+ applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
293
+ return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
294
+ },
295
+
296
+ shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
297
+ return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
298
+ },
299
+ };
300
+ }
301
+
302
+ function samePath(left: string | undefined, right: string): boolean {
303
+ if (!left) return false;
304
+ const normalizedLeft = resolve(left);
305
+ const normalizedRight = resolve(right);
306
+ return process.platform === "win32"
307
+ ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
308
+ : normalizedLeft === normalizedRight;
309
+ }
310
+
311
+ export default function sessionReferenceExtension(pi: ExtensionAPI): void {
312
+ let getAvailableReferences: (() => Promise<SessionReference[]>) | undefined;
313
+ let sessionGeneration = 0;
314
+ const subagentIds = new Set<string>();
315
+ // pi-subagents emits "subagent:async-started" and "subagent:async-complete" events.
316
+ // We track records locally so @[SubAgent] suggestions work even without a global manager.
317
+ const subagentEventNames = ["subagent:async-started", "subagent:async-complete"];
318
+ const unsubscribeSubagentEvents = subagentEventNames.map((eventName) =>
319
+ pi.events.on(eventName, (data) => {
320
+ trackSubagentFromEvent(data);
321
+ if (!data || typeof data !== "object") return;
322
+ const payload = data as Record<string, unknown>;
323
+ // subagent:async-started uses "id", subagent:async-complete uses "runId"
324
+ const id =
325
+ (typeof payload.id === "string" ? payload.id : undefined) ??
326
+ (typeof payload.runId === "string" ? payload.runId : undefined);
327
+ if (typeof id === "string" && id) subagentIds.add(id);
328
+ }),
329
+ );
330
+
331
+ pi.registerMessageRenderer(SESSION_REFERENCE_CUSTOM_TYPE, (message, _options, theme) => {
332
+ const details = message.details as ReferenceDetails | undefined;
333
+ const sessions = details?.sessions ?? [];
334
+ const labels = sessions.map((session) => session.title).join(", ");
335
+ const summary =
336
+ sessions.length === 1 ? "Referenced 1 session" : `Referenced ${sessions.length} sessions`;
337
+ const text = labels ? `${theme.fg("accent", summary)}\n${theme.fg("dim", labels)}` : summary;
338
+ return new Text(text, 1, 0);
339
+ });
340
+
341
+ pi.on("session_start", (_event, ctx) => {
342
+ const generation = ++sessionGeneration;
343
+ subagentIds.clear();
344
+ let loadErrorShown = false;
345
+ const currentSessionId = ctx.sessionManager.getSessionId();
346
+ const currentSessionFile = ctx.sessionManager.getSessionFile();
347
+ let sessionsPromise: Promise<SessionInfo[]> | undefined;
348
+
349
+ const getSessions = (): Promise<SessionInfo[]> => {
350
+ sessionsPromise ||= SessionManager.listAll()
351
+ .then((sessions) =>
352
+ sessions.filter(
353
+ (session) =>
354
+ session.id !== currentSessionId && !samePath(currentSessionFile, session.path),
355
+ ),
356
+ )
357
+ .catch((error: unknown) => {
358
+ if (!loadErrorShown) {
359
+ loadErrorShown = true;
360
+ const reason = error instanceof Error ? error.message : String(error);
361
+ ctx.ui.notify(`session-reference: failed to load sessions: ${reason}`, "error");
362
+ }
363
+ return [];
364
+ });
365
+ return sessionsPromise;
366
+ };
367
+
368
+ const getReferences = async (): Promise<SessionReference[]> =>
369
+ mergeReferences(await getSessions(), liveSubagentReferences(subagentIds, currentSessionId));
370
+
371
+ getAvailableReferences = getReferences;
372
+ if (ctx.mode === "tui") {
373
+ void getReferences();
374
+ // Register after other session_start handlers. pi-fff claims every @
375
+ // prefix, so a provider installed before it would never see session mentions.
376
+ setTimeout(() => {
377
+ if (generation !== sessionGeneration) return;
378
+ ctx.ui.addAutocompleteProvider((current) =>
379
+ createAutocompleteProvider(current, getReferences, ctx.cwd),
380
+ );
381
+ }, 0);
382
+ }
383
+ });
384
+
385
+ pi.on("before_agent_start", async (event, ctx) => {
386
+ const referenceIds = extractSessionReferenceIds(event.prompt);
387
+ if (referenceIds.length === 0) return;
388
+
389
+ const currentSessionId = ctx.sessionManager.getSessionId();
390
+ const references = await (getAvailableReferences?.() ??
391
+ SessionManager.listAll().then((sessions) =>
392
+ mergeReferences(
393
+ sessions.filter((session) => session.id !== currentSessionId),
394
+ liveSubagentReferences(subagentIds, currentSessionId),
395
+ ),
396
+ ));
397
+ const referencesById = new Map<string, SessionReference>();
398
+ for (const reference of references) {
399
+ for (const id of reference.referenceIds) referencesById.set(id, reference);
400
+ }
401
+ const seenReferences = new Set<SessionReference>();
402
+ const matchingReferences = referenceIds
403
+ .map((id) => referencesById.get(id))
404
+ .filter((reference): reference is SessionReference => {
405
+ if (!reference || reference.info.id === currentSessionId || seenReferences.has(reference))
406
+ return false;
407
+ seenReferences.add(reference);
408
+ return true;
409
+ });
410
+ const selected = matchingReferences.slice(0, MAX_REFERENCED_SESSIONS);
411
+
412
+ if (selected.length === 0) {
413
+ ctx.ui.notify("session-reference: referenced sessions were not found", "warning");
414
+ return;
415
+ }
416
+
417
+ if (matchingReferences.length > MAX_REFERENCED_SESSIONS) {
418
+ ctx.ui.notify(
419
+ `session-reference: only the first ${MAX_REFERENCED_SESSIONS} sessions were included`,
420
+ "warning",
421
+ );
422
+ }
423
+
424
+ const sections: string[] = [];
425
+ const referencedSessions: ReferenceDetails["sessions"] = [];
426
+ for (const reference of selected) {
427
+ try {
428
+ const info = reference.info;
429
+ const messages =
430
+ reference.messages ??
431
+ (reference.path
432
+ ? SessionManager.open(reference.path).buildSessionContext().messages
433
+ : (() => {
434
+ throw new Error("reference session is no longer available");
435
+ })());
436
+ const source: ReferenceSource = { info, messages };
437
+ sections.push(formatReferenceSession(source));
438
+ referencedSessions.push({
439
+ id: info.id,
440
+ title: sessionTitle(info),
441
+ cwd: info.cwd,
442
+ });
443
+ } catch (error) {
444
+ const reason = error instanceof Error ? error.message : String(error);
445
+ ctx.ui.notify(
446
+ `session-reference: failed to read ${reference.info.id}: ${reason}`,
447
+ "warning",
448
+ );
449
+ }
450
+ }
451
+ if (sections.length === 0) return;
452
+
453
+ const details: ReferenceDetails = { sessions: referencedSessions };
454
+ return {
455
+ message: {
456
+ customType: SESSION_REFERENCE_CUSTOM_TYPE,
457
+ content: buildReferenceContentFromSections(sections),
458
+ display: true,
459
+ details,
460
+ },
461
+ };
462
+ });
463
+
464
+ pi.on("session_before_switch", () => {
465
+ subagentIds.clear();
466
+ getAvailableReferences = undefined;
467
+ });
468
+
469
+ pi.on("session_shutdown", () => {
470
+ sessionGeneration++;
471
+ for (const unsubscribe of unsubscribeSubagentEvents) unsubscribe();
472
+ subagentIds.clear();
473
+ getAvailableReferences = undefined;
474
+ });
475
+ }