pi-cc-extensions 0.1.1

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 (39) hide show
  1. package/README.md +141 -0
  2. package/assets/readme/context-preview.webp +0 -0
  3. package/assets/readme/context-usage.webp +0 -0
  4. package/assets/readme/hero.svg +101 -0
  5. package/assets/readme/session-reference.webp +0 -0
  6. package/assets/readme/showcase.webp +0 -0
  7. package/extensions/README.md +3 -0
  8. package/extensions/ccstyle-powerline/bash-mode/completion.ts +556 -0
  9. package/extensions/ccstyle-powerline/bash-mode/editor.ts +442 -0
  10. package/extensions/ccstyle-powerline/bash-mode/history.ts +151 -0
  11. package/extensions/ccstyle-powerline/bash-mode/shell-session.ts +286 -0
  12. package/extensions/ccstyle-powerline/bash-mode/transcript.ts +108 -0
  13. package/extensions/ccstyle-powerline/bash-mode/types.ts +59 -0
  14. package/extensions/ccstyle-powerline/colors.ts +69 -0
  15. package/extensions/ccstyle-powerline/context-usage.ts +46 -0
  16. package/extensions/ccstyle-powerline/fixed-editor/cluster.ts +107 -0
  17. package/extensions/ccstyle-powerline/fixed-editor/terminal-split.ts +1317 -0
  18. package/extensions/ccstyle-powerline/git-status.ts +218 -0
  19. package/extensions/ccstyle-powerline/icons.ts +181 -0
  20. package/extensions/ccstyle-powerline/powerline-config.ts +234 -0
  21. package/extensions/ccstyle-powerline/powerline.ts +3150 -0
  22. package/extensions/ccstyle-powerline/presets.ts +109 -0
  23. package/extensions/ccstyle-powerline/render-scheduler.ts +24 -0
  24. package/extensions/ccstyle-powerline/segments.ts +476 -0
  25. package/extensions/ccstyle-powerline/separators.ts +41 -0
  26. package/extensions/ccstyle-powerline/shortcuts.ts +57 -0
  27. package/extensions/ccstyle-powerline/theme.json +26 -0
  28. package/extensions/ccstyle-powerline/theme.ts +241 -0
  29. package/extensions/ccstyle-powerline/types.ts +197 -0
  30. package/extensions/ccstyle-powerline/welcome-dismiss.ts +34 -0
  31. package/extensions/ccstyle-powerline/welcome.ts +611 -0
  32. package/extensions/ccstyle-powerline/working-vibes.ts +695 -0
  33. package/extensions/claude-code-style.ts +987 -0
  34. package/extensions/context.ts +362 -0
  35. package/extensions/session-reference/README.md +28 -0
  36. package/extensions/session-reference/core.test.ts +74 -0
  37. package/extensions/session-reference/core.ts +258 -0
  38. package/extensions/session-reference/index.ts +379 -0
  39. package/package.json +33 -0
@@ -0,0 +1,611 @@
1
+ import { readdirSync, existsSync, statSync, readFileSync } from "node:fs";
2
+ import { join, basename } from "node:path";
3
+ import { homedir as osHomedir } from "node:os";
4
+ import type { Component } from "@earendil-works/pi-tui";
5
+ import { truncateToWidth as tuiTruncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
6
+ import { ansi, fgOnly, getFgAnsiCode } from "./colors.ts";
7
+
8
+ export interface RecentSession {
9
+ name: string;
10
+ timeAgo: string;
11
+ }
12
+
13
+ export interface LoadedCounts {
14
+ contextFiles: number;
15
+ extensions: number;
16
+ skills: number;
17
+ promptTemplates: number;
18
+ }
19
+
20
+ // ═══════════════════════════════════════════════════════════════════════════
21
+ // Shared rendering utilities
22
+ // ═══════════════════════════════════════════════════════════════════════════
23
+
24
+ const PI_LOGO = [
25
+ "██████████ ",
26
+ "████ ████ ",
27
+ "████ ████ ",
28
+ "████████ ████",
29
+ "████ ████",
30
+ "████ ████",
31
+ ];
32
+
33
+ const GRADIENT_COLORS = [
34
+ "\x1b[38;5;199m",
35
+ "\x1b[38;5;171m",
36
+ "\x1b[38;5;135m",
37
+ "\x1b[38;5;99m",
38
+ "\x1b[38;5;75m",
39
+ "\x1b[38;5;51m",
40
+ ];
41
+
42
+ function bold(text: string): string {
43
+ return `\x1b[1m${text}\x1b[22m`;
44
+ }
45
+
46
+ function dim(text: string): string {
47
+ return getFgAnsiCode("sep") + text + ansi.reset;
48
+ }
49
+
50
+ function gradientLine(line: string): string {
51
+ const reset = ansi.reset;
52
+ let result = "";
53
+ let colorIdx = 0;
54
+ const step = Math.max(1, Math.floor(line.length / GRADIENT_COLORS.length));
55
+
56
+ for (let i = 0; i < line.length; i++) {
57
+ if (i > 0 && i % step === 0 && colorIdx < GRADIENT_COLORS.length - 1) colorIdx++;
58
+ const char = line[i];
59
+ if (char !== " ") {
60
+ result += GRADIENT_COLORS[colorIdx] + char + reset;
61
+ } else {
62
+ result += char;
63
+ }
64
+ }
65
+ return result;
66
+ }
67
+
68
+ function centerText(text: string, width: number): string {
69
+ const visLen = visibleWidth(text);
70
+ if (visLen > width) return tuiTruncateToWidth(text, width, "…");
71
+ if (visLen === width) return text;
72
+ const leftPad = Math.floor((width - visLen) / 2);
73
+ const rightPad = width - visLen - leftPad;
74
+ return " ".repeat(leftPad) + text + " ".repeat(rightPad);
75
+ }
76
+
77
+ function fitToWidth(str: string, width: number): string {
78
+ const visLen = visibleWidth(str);
79
+ if (visLen > width) return tuiTruncateToWidth(str, width, "…");
80
+ return str + " ".repeat(width - visLen);
81
+ }
82
+
83
+ interface WelcomeData {
84
+ modelName: string;
85
+ providerName: string;
86
+ recentSessions: RecentSession[];
87
+ loadedCounts: LoadedCounts;
88
+ }
89
+
90
+ function buildLeftColumn(data: WelcomeData, colWidth: number): string[] {
91
+ const logoColored = PI_LOGO.map((line) => gradientLine(line));
92
+
93
+ return [
94
+ "",
95
+ centerText(bold("Welcome back!"), colWidth),
96
+ "",
97
+ ...logoColored.map((l) => centerText(l, colWidth)),
98
+ "",
99
+ centerText(fgOnly("model", data.modelName), colWidth),
100
+ centerText(dim(data.providerName), colWidth),
101
+ ];
102
+ }
103
+
104
+ function buildRightColumn(data: WelcomeData, colWidth: number): string[] {
105
+ const hChar = "─";
106
+ const separator = ` ${dim(hChar.repeat(colWidth - 2))}`;
107
+
108
+ // Session lines
109
+ const sessionLines: string[] = [];
110
+ if (data.recentSessions.length === 0) {
111
+ sessionLines.push(` ${dim("No recent sessions")}`);
112
+ } else {
113
+ for (const session of data.recentSessions.slice(0, 3)) {
114
+ sessionLines.push(
115
+ ` ${dim("• ")}${fgOnly("path", session.name)}${dim(` (${session.timeAgo})`)}`,
116
+ );
117
+ }
118
+ }
119
+
120
+ // Loaded counts lines
121
+ const countLines: string[] = [];
122
+ const { contextFiles, extensions, skills, promptTemplates } = data.loadedCounts;
123
+ const itemPrefix = dim("- ");
124
+
125
+ if (contextFiles > 0 || extensions > 0 || skills > 0 || promptTemplates > 0) {
126
+ if (contextFiles > 0) {
127
+ countLines.push(` ${itemPrefix}${fgOnly("gitClean", `${contextFiles}`)} context file${contextFiles !== 1 ? "s" : ""}`);
128
+ }
129
+ if (extensions > 0) {
130
+ countLines.push(` ${itemPrefix}${fgOnly("gitClean", `${extensions}`)} extension${extensions !== 1 ? "s" : ""}`);
131
+ }
132
+ if (skills > 0) {
133
+ countLines.push(` ${itemPrefix}${fgOnly("gitClean", `${skills}`)} skill${skills !== 1 ? "s" : ""}`);
134
+ }
135
+ if (promptTemplates > 0) {
136
+ countLines.push(` ${itemPrefix}${fgOnly("gitClean", `${promptTemplates}`)} prompt template${promptTemplates !== 1 ? "s" : ""}`);
137
+ }
138
+ } else {
139
+ countLines.push(` ${dim("No extensions loaded")}`);
140
+ }
141
+
142
+ return [
143
+ ` ${bold(fgOnly("accent", "Tips"))}`,
144
+ ` ${dim("/")} for commands`,
145
+ ` ${dim("!")} to run bash`,
146
+ ` ${dim("Shift+Tab")} cycle thinking`,
147
+ separator,
148
+ ` ${bold(fgOnly("accent", "Loaded"))}`,
149
+ ...countLines,
150
+ separator,
151
+ ` ${bold(fgOnly("accent", "Recent sessions"))}`,
152
+ ...sessionLines,
153
+ "",
154
+ ];
155
+ }
156
+
157
+ function renderWelcomeBox(
158
+ data: WelcomeData,
159
+ termWidth: number,
160
+ bottomLine: string,
161
+ ): string[] {
162
+ // Minimum width for two-column layout: leftCol(26) + separator(3) + minRightCol(15) = 44
163
+ const minLayoutWidth = 44;
164
+
165
+ // If terminal is too narrow for the layout, return empty (skip welcome box)
166
+ if (termWidth < minLayoutWidth) {
167
+ return [];
168
+ }
169
+
170
+ const minWidth = 76;
171
+ const maxWidth = 96;
172
+ // Clamp to termWidth to prevent crash on narrow terminals
173
+ const boxWidth = Math.min(termWidth, Math.max(minWidth, Math.min(termWidth - 2, maxWidth)));
174
+ const leftCol = 26;
175
+ const rightCol = Math.max(1, boxWidth - leftCol - 3); // Ensure rightCol is at least 1
176
+
177
+ const hChar = "─";
178
+ const v = dim("│");
179
+ const tl = dim("╭");
180
+ const tr = dim("╮");
181
+ const bl = dim("╰");
182
+ const br = dim("╯");
183
+
184
+ const leftLines = buildLeftColumn(data, leftCol);
185
+ const rightLines = buildRightColumn(data, rightCol);
186
+
187
+ const lines: string[] = [];
188
+
189
+ // Top border with title
190
+ const title = " pi agent ";
191
+ const titlePrefix = dim(hChar.repeat(3));
192
+ const titleStyled = titlePrefix + fgOnly("model", title);
193
+ const titleVisLen = 3 + visibleWidth(title);
194
+ const afterTitle = boxWidth - 2 - titleVisLen;
195
+ const afterTitleText = afterTitle > 0 ? dim(hChar.repeat(afterTitle)) : "";
196
+ lines.push(tl + titleStyled + afterTitleText + tr);
197
+
198
+ // Content rows
199
+ const maxRows = Math.max(leftLines.length, rightLines.length);
200
+ for (let i = 0; i < maxRows; i++) {
201
+ const left = fitToWidth(leftLines[i] ?? "", leftCol);
202
+ const right = fitToWidth(rightLines[i] ?? "", rightCol);
203
+ lines.push(v + left + v + right + v);
204
+ }
205
+
206
+ // Bottom border
207
+ lines.push(bl + bottomLine + br);
208
+
209
+ return lines;
210
+ }
211
+
212
+ // ═══════════════════════════════════════════════════════════════════════════
213
+ // Welcome Components
214
+ // ═══════════════════════════════════════════════════════════════════════════
215
+
216
+ /**
217
+ * Welcome overlay component for pi agent.
218
+ * Displays a branded splash screen with logo, tips, and loaded counts.
219
+ */
220
+ export class WelcomeComponent implements Component {
221
+ private data: WelcomeData;
222
+ private countdown: number = 30;
223
+
224
+ constructor(
225
+ modelName: string,
226
+ providerName: string,
227
+ recentSessions: RecentSession[] = [],
228
+ loadedCounts: LoadedCounts = { contextFiles: 0, extensions: 0, skills: 0, promptTemplates: 0 },
229
+ ) {
230
+ this.data = { modelName, providerName, recentSessions, loadedCounts };
231
+ }
232
+
233
+ setCountdown(seconds: number): void {
234
+ this.countdown = seconds;
235
+ }
236
+
237
+ invalidate(): void {}
238
+
239
+ render(termWidth: number): string[] {
240
+ // Minimum width for two-column layout (must match renderWelcomeBox)
241
+ const minLayoutWidth = 44;
242
+ if (termWidth < minLayoutWidth) {
243
+ return [];
244
+ }
245
+
246
+ const minWidth = 76;
247
+ const maxWidth = 96;
248
+ // Clamp to termWidth to prevent crash on narrow terminals
249
+ const boxWidth = Math.min(termWidth, Math.max(minWidth, Math.min(termWidth - 2, maxWidth)));
250
+
251
+ // Bottom line with countdown
252
+ const countdownText = ` Press any key to continue (${this.countdown}s) `;
253
+ const countdownStyled = dim(countdownText);
254
+ const bottomContentWidth = boxWidth - 2;
255
+ const countdownVisLen = visibleWidth(countdownText);
256
+ const leftPad = Math.floor((bottomContentWidth - countdownVisLen) / 2);
257
+ const rightPad = bottomContentWidth - countdownVisLen - leftPad;
258
+ const hChar = "─";
259
+ const bottomLine = dim(hChar.repeat(Math.max(0, leftPad))) +
260
+ countdownStyled +
261
+ dim(hChar.repeat(Math.max(0, rightPad)));
262
+
263
+ return renderWelcomeBox(this.data, termWidth, bottomLine);
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Welcome header - same layout as overlay but persistent (no countdown).
269
+ * Used when quietStartup: true.
270
+ */
271
+ export class WelcomeHeader implements Component {
272
+ private data: WelcomeData;
273
+
274
+ constructor(
275
+ modelName: string,
276
+ providerName: string,
277
+ recentSessions: RecentSession[] = [],
278
+ loadedCounts: LoadedCounts = { contextFiles: 0, extensions: 0, skills: 0, promptTemplates: 0 },
279
+ ) {
280
+ this.data = { modelName, providerName, recentSessions, loadedCounts };
281
+ }
282
+
283
+ invalidate(): void {}
284
+
285
+ render(termWidth: number): string[] {
286
+ // Minimum width for two-column layout (must match renderWelcomeBox)
287
+ const minLayoutWidth = 44;
288
+ if (termWidth < minLayoutWidth) {
289
+ return [];
290
+ }
291
+
292
+ const minWidth = 76;
293
+ const maxWidth = 96;
294
+ // Clamp to termWidth to prevent crash on narrow terminals
295
+ const boxWidth = Math.min(termWidth, Math.max(minWidth, Math.min(termWidth - 2, maxWidth)));
296
+ const hChar = "─";
297
+
298
+ // Bottom line with column separator (leftCol=26, rightCol=boxWidth-29)
299
+ const leftCol = 26;
300
+ const rightCol = Math.max(1, boxWidth - leftCol - 3);
301
+ const bottomLine = dim(hChar.repeat(leftCol)) + dim("┴") + dim(hChar.repeat(rightCol));
302
+
303
+ const lines = renderWelcomeBox(this.data, termWidth, bottomLine);
304
+ if (lines.length > 0) {
305
+ lines.push(""); // Add empty line for spacing only if we rendered content
306
+ }
307
+ return lines;
308
+ }
309
+ }
310
+
311
+ // ═══════════════════════════════════════════════════════════════════════════
312
+ // Discovery functions
313
+ // ═══════════════════════════════════════════════════════════════════════════
314
+
315
+ const loggedDiscoveryErrors = new Set<string>();
316
+
317
+ function logDiscoveryError(scope: string, error: unknown): void {
318
+ const message = error instanceof Error ? error.message : String(error);
319
+ const key = `${scope}:${message}`;
320
+ if (loggedDiscoveryErrors.has(key)) {
321
+ return;
322
+ }
323
+
324
+ loggedDiscoveryErrors.add(key);
325
+ if (loggedDiscoveryErrors.size > 500) {
326
+ loggedDiscoveryErrors.clear();
327
+ }
328
+
329
+ console.debug(`[powerline-welcome] ${scope}:`, error);
330
+ }
331
+
332
+ /**
333
+ * Discover loaded counts by scanning filesystem.
334
+ */
335
+ export function discoverLoadedCounts(): LoadedCounts {
336
+ const homeDir = process.env.HOME || process.env.USERPROFILE || osHomedir();
337
+ const cwd = process.cwd();
338
+
339
+ let contextFiles = 0;
340
+ let extensions = 0;
341
+ let skills = 0;
342
+ let promptTemplates = 0;
343
+
344
+ const agentsMdPaths = [
345
+ join(homeDir, ".pi", "agent", "AGENTS.md"),
346
+ join(homeDir, ".claude", "AGENTS.md"),
347
+ join(cwd, "AGENTS.md"),
348
+ join(cwd, ".pi", "AGENTS.md"),
349
+ join(cwd, ".claude", "AGENTS.md"),
350
+ ];
351
+
352
+ for (const path of agentsMdPaths) {
353
+ if (existsSync(path)) contextFiles++;
354
+ }
355
+
356
+ const extensionDirs = [
357
+ join(homeDir, ".pi", "agent", "extensions"),
358
+ join(cwd, "extensions"),
359
+ join(cwd, ".pi", "extensions"),
360
+ ];
361
+
362
+ const countedExtensions = new Set<string>();
363
+
364
+ const settingsPaths = [
365
+ join(homeDir, ".pi", "agent", "settings.json"),
366
+ join(cwd, ".pi", "settings.json"),
367
+ ];
368
+
369
+ for (const settingsPath of settingsPaths) {
370
+ if (!existsSync(settingsPath)) {
371
+ continue;
372
+ }
373
+
374
+ try {
375
+ const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
376
+ let packages: unknown = null;
377
+ if (typeof settings === "object" && settings !== null && !Array.isArray(settings)) {
378
+ packages = (settings as { packages?: unknown }).packages;
379
+ }
380
+
381
+ if (Array.isArray(packages)) {
382
+ for (const pkg of packages) {
383
+ let source: unknown = null;
384
+ let extensionsFilter: unknown = null;
385
+
386
+ if (typeof pkg === "string") {
387
+ source = pkg;
388
+ } else if (typeof pkg === "object" && pkg !== null && !Array.isArray(pkg)) {
389
+ source = (pkg as { source?: unknown }).source;
390
+ extensionsFilter = (pkg as { extensions?: unknown }).extensions;
391
+ }
392
+
393
+ if (typeof source !== "string") {
394
+ continue;
395
+ }
396
+
397
+ const normalizedSource = source.trim();
398
+ if (!normalizedSource.startsWith("npm:")) {
399
+ continue;
400
+ }
401
+
402
+ if (Array.isArray(extensionsFilter) && extensionsFilter.length === 0) {
403
+ continue;
404
+ }
405
+
406
+ const body = normalizedSource.slice(4);
407
+ const versionIndex = body.lastIndexOf("@");
408
+ const name = versionIndex > 0 ? body.slice(0, versionIndex) : body;
409
+ if (!name || countedExtensions.has(name)) {
410
+ continue;
411
+ }
412
+
413
+ countedExtensions.add(name);
414
+ extensions++;
415
+ }
416
+ }
417
+ } catch (error) {
418
+ logDiscoveryError(`Failed to read settings at ${settingsPath}`, error);
419
+ }
420
+ }
421
+
422
+ for (const dir of extensionDirs) {
423
+ if (existsSync(dir)) {
424
+ try {
425
+ const entries = readdirSync(dir);
426
+ for (const entry of entries) {
427
+ const entryPath = join(dir, entry);
428
+
429
+ try {
430
+ const stats = statSync(entryPath);
431
+
432
+ if (stats.isDirectory()) {
433
+ if (
434
+ existsSync(join(entryPath, "index.ts")) ||
435
+ existsSync(join(entryPath, "index.js")) ||
436
+ existsSync(join(entryPath, "package.json"))
437
+ ) {
438
+ if (!countedExtensions.has(entry)) {
439
+ countedExtensions.add(entry);
440
+ extensions++;
441
+ }
442
+ }
443
+ } else if ((entry.endsWith(".ts") || entry.endsWith(".js")) && !entry.startsWith(".")) {
444
+ const ext = entry.endsWith(".ts") ? ".ts" : ".js";
445
+ const name = basename(entry, ext);
446
+ if (!countedExtensions.has(name)) {
447
+ countedExtensions.add(name);
448
+ extensions++;
449
+ }
450
+ }
451
+ } catch (error) {
452
+ logDiscoveryError(`Failed to inspect extension entry ${entryPath}`, error);
453
+ }
454
+ }
455
+ } catch (error) {
456
+ logDiscoveryError(`Failed to scan extensions dir ${dir}`, error);
457
+ }
458
+ }
459
+ }
460
+
461
+ const skillDirs = [
462
+ join(homeDir, ".pi", "agent", "skills"),
463
+ join(cwd, ".pi", "skills"),
464
+ join(cwd, "skills"),
465
+ ];
466
+
467
+ const countedSkills = new Set<string>();
468
+
469
+ for (const dir of skillDirs) {
470
+ if (existsSync(dir)) {
471
+ try {
472
+ const entries = readdirSync(dir);
473
+ for (const entry of entries) {
474
+ const entryPath = join(dir, entry);
475
+ try {
476
+ if (statSync(entryPath).isDirectory()) {
477
+ if (existsSync(join(entryPath, "SKILL.md"))) {
478
+ if (!countedSkills.has(entry)) {
479
+ countedSkills.add(entry);
480
+ skills++;
481
+ }
482
+ }
483
+ }
484
+ } catch (error) {
485
+ logDiscoveryError(`Failed to inspect skill entry ${entryPath}`, error);
486
+ }
487
+ }
488
+ } catch (error) {
489
+ logDiscoveryError(`Failed to scan skills dir ${dir}`, error);
490
+ }
491
+ }
492
+ }
493
+
494
+ const templateDirs = [
495
+ join(homeDir, ".pi", "agent", "commands"),
496
+ join(homeDir, ".claude", "commands"),
497
+ join(cwd, ".pi", "commands"),
498
+ join(cwd, ".claude", "commands"),
499
+ ];
500
+
501
+ const countedTemplates = new Set<string>();
502
+
503
+ function countTemplatesInDir(dir: string) {
504
+ if (!existsSync(dir)) return;
505
+ try {
506
+ const entries = readdirSync(dir);
507
+ for (const entry of entries) {
508
+ const entryPath = join(dir, entry);
509
+ try {
510
+ const stats = statSync(entryPath);
511
+ if (stats.isDirectory()) {
512
+ countTemplatesInDir(entryPath);
513
+ } else if (entry.endsWith(".md")) {
514
+ const name = basename(entry, ".md");
515
+ if (!countedTemplates.has(name)) {
516
+ countedTemplates.add(name);
517
+ promptTemplates++;
518
+ }
519
+ }
520
+ } catch (error) {
521
+ logDiscoveryError(`Failed to inspect prompt template entry ${entryPath}`, error);
522
+ }
523
+ }
524
+ } catch (error) {
525
+ logDiscoveryError(`Failed to scan prompt template dir ${dir}`, error);
526
+ }
527
+ }
528
+
529
+ for (const dir of templateDirs) {
530
+ countTemplatesInDir(dir);
531
+ }
532
+
533
+ return { contextFiles, extensions, skills, promptTemplates };
534
+ }
535
+
536
+ /**
537
+ * Get recent sessions from the sessions directory.
538
+ */
539
+ export function getRecentSessions(maxCount: number = 3): RecentSession[] {
540
+ const homeDir = process.env.HOME || process.env.USERPROFILE || osHomedir();
541
+
542
+ const sessionsDirs = [
543
+ join(homeDir, ".pi", "agent", "sessions"),
544
+ join(homeDir, ".pi", "sessions"),
545
+ ];
546
+
547
+ const sessions: { name: string; mtime: number }[] = [];
548
+
549
+ function scanDir(dir: string) {
550
+ if (!existsSync(dir)) return;
551
+ try {
552
+ const entries = readdirSync(dir);
553
+ for (const entry of entries) {
554
+ const entryPath = join(dir, entry);
555
+ try {
556
+ const stats = statSync(entryPath);
557
+ if (stats.isDirectory()) {
558
+ scanDir(entryPath);
559
+ } else if (entry.endsWith(".jsonl")) {
560
+ const parentName = basename(dir);
561
+ let projectName = parentName;
562
+ if (parentName.startsWith("--")) {
563
+ const parts = parentName.split("-").filter(p => p);
564
+ projectName = parts[parts.length - 1] || parentName;
565
+ }
566
+ sessions.push({ name: projectName, mtime: stats.mtimeMs });
567
+ }
568
+ } catch (error) {
569
+ logDiscoveryError(`Failed to inspect session entry ${entryPath}`, error);
570
+ }
571
+ }
572
+ } catch (error) {
573
+ logDiscoveryError(`Failed to scan sessions dir ${dir}`, error);
574
+ }
575
+ }
576
+
577
+ for (const sessionsDir of sessionsDirs) {
578
+ scanDir(sessionsDir);
579
+ }
580
+
581
+ if (sessions.length === 0) return [];
582
+
583
+ sessions.sort((a, b) => b.mtime - a.mtime);
584
+
585
+ const seen = new Set<string>();
586
+ const uniqueSessions: typeof sessions = [];
587
+ for (const s of sessions) {
588
+ if (!seen.has(s.name)) {
589
+ seen.add(s.name);
590
+ uniqueSessions.push(s);
591
+ }
592
+ }
593
+
594
+ const now = Date.now();
595
+ return uniqueSessions.slice(0, maxCount).map(s => ({
596
+ name: s.name.length > 20 ? s.name.slice(0, 17) + "…" : s.name,
597
+ timeAgo: formatTimeAgo(now - s.mtime),
598
+ }));
599
+ }
600
+
601
+ function formatTimeAgo(ms: number): string {
602
+ const seconds = Math.floor(ms / 1000);
603
+ const minutes = Math.floor(seconds / 60);
604
+ const hours = Math.floor(minutes / 60);
605
+ const days = Math.floor(hours / 24);
606
+
607
+ if (days > 0) return `${days}d ago`;
608
+ if (hours > 0) return `${hours}h ago`;
609
+ if (minutes > 0) return `${minutes}m ago`;
610
+ return "just now";
611
+ }