@raingor/pi-web-switch 0.4.0 → 0.4.2

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,10 +1,24 @@
1
- import { readFileSync, readdirSync, existsSync, statSync, unlinkSync, writeFileSync, mkdirSync, renameSync } from "fs";
2
- import { homedir } from "os";
1
+ import { readFileSync, readdirSync, existsSync, statSync, unlinkSync, writeFileSync, mkdirSync, renameSync, realpathSync } from "fs";
2
+ import { homedir, platform } from "os";
3
3
  import { join, resolve, dirname, relative, sep } from "path";
4
4
  import { spawnSync } from "child_process";
5
+ import { DatabaseSync } from "node:sqlite";
5
6
 
6
7
  const PI_DIR = join(homedir(), ".pi", "agent");
7
8
 
9
+ // ─── Cindy Pi-Agent Sessions ───────────────────────────
10
+ // When Cindy (the AI assistant) delegates to a pi coding agent, sessions
11
+ // are stored under its own data directory instead of ~/.pi/agent/sessions.
12
+
13
+ function getCindySessionsDir(): string {
14
+ const home = homedir();
15
+ if (platform() === "darwin") {
16
+ return join(home, "Library", "Application Support", "Cindy", "pi-agent-home", "sessions");
17
+ }
18
+ // Linux / Windows fallback
19
+ return join(home, ".config", "cindy", "pi-agent-home", "sessions");
20
+ }
21
+
8
22
  // ─── Config File Paths ───────────────────────────────────
9
23
 
10
24
  function piPath(filename: string): string {
@@ -111,6 +125,70 @@ function getSessionDirs(): string[] {
111
125
  .filter((dir) => statSync(dir).isDirectory());
112
126
  }
113
127
 
128
+ /**
129
+ * Recursively walk a project directory to find all session JSONL files.
130
+ *
131
+ * Supports both the legacy flat layout and the current nested layout:
132
+ * Legacy: --project--/session.jsonl (any .jsonl file at the project root)
133
+ * Current: --project--/{sessionId}/{hash}/run-0/session.jsonl
134
+ */
135
+ function walkSessionJsonl(dir: string, out: string[]): void {
136
+ let entries: string[];
137
+ try {
138
+ entries = readdirSync(dir);
139
+ } catch {
140
+ return;
141
+ }
142
+ for (const name of entries) {
143
+ const p = join(dir, name);
144
+ try {
145
+ const stat = statSync(p);
146
+ if (stat.isDirectory()) {
147
+ walkSessionJsonl(p, out);
148
+ } else if (name.endsWith(".jsonl")) {
149
+ out.push(p);
150
+ }
151
+ } catch {
152
+ // skip unreadable entries
153
+ }
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Return every session JSONL file across all project directories, handling
159
+ * both the legacy flat layout and the current nested run-0 layout.
160
+ */
161
+ function getAllSessionFiles(): string[] {
162
+ const files: string[] = [];
163
+ for (const dir of getSessionDirs()) {
164
+ walkSessionJsonl(dir, files);
165
+ }
166
+ return files;
167
+ }
168
+
169
+ // Usage stats are bucketed in China time (UTC+8) regardless of the machine's
170
+ // system timezone, so daily totals stay consistent for a Beijing-based user.
171
+ const CN_TZ = "Asia/Shanghai";
172
+
173
+ function cnDateParts(ts: string | number): { date: string; hour: number } {
174
+ const d = new Date(ts);
175
+ if (isNaN(d.getTime())) return { date: "unknown", hour: 0 };
176
+ const date = new Intl.DateTimeFormat("en-CA", {
177
+ timeZone: CN_TZ,
178
+ year: "numeric",
179
+ month: "2-digit",
180
+ day: "2-digit",
181
+ }).format(d); // "2026-08-06"
182
+ const hour = Number(
183
+ new Intl.DateTimeFormat("en-US", {
184
+ timeZone: CN_TZ,
185
+ hour: "2-digit",
186
+ hour12: false,
187
+ }).format(d)
188
+ );
189
+ return { date, hour: hour === 24 ? 0 : hour };
190
+ }
191
+
114
192
  function parseSessionFile(filePath: string): UsageRecord[] {
115
193
  const records: UsageRecord[] = [];
116
194
  try {
@@ -136,9 +214,7 @@ function parseSessionFile(filePath: string): UsageRecord[] {
136
214
  if (!usage || !usage.input) continue;
137
215
 
138
216
  const timestamp = obj.timestamp || obj.message.timestamp;
139
- const d = new Date(timestamp);
140
- const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
141
- const hour = d.getHours();
217
+ const { date, hour } = cnDateParts(timestamp);
142
218
 
143
219
  records.push({
144
220
  date,
@@ -163,20 +239,142 @@ function parseSessionFile(filePath: string): UsageRecord[] {
163
239
  return records;
164
240
  }
165
241
 
242
+ // Scanned session usage is cached briefly: the same 150MB+ of JSONL gets
243
+ // re-read on every dashboard request otherwise, which stalls the page.
244
+ const USAGE_CACHE_TTL_MS = 30_000;
245
+ let usageCache: { records: UsageRecord[]; at: number } | null = null;
246
+
247
+ /** Drop cached session usage — called when the UI requests a forced refresh. */
248
+ export function clearUsageCache(): void {
249
+ usageCache = null;
250
+ }
251
+
166
252
  export function readAllUsage(): UsageRecord[] {
253
+ if (usageCache && Date.now() - usageCache.at < USAGE_CACHE_TTL_MS) {
254
+ return usageCache.records;
255
+ }
167
256
  const allRecords: UsageRecord[] = [];
168
- const dirs = getSessionDirs();
257
+ const files = getAllSessionFiles();
169
258
 
170
- for (const dir of dirs) {
259
+ for (const filePath of files) {
260
+ const records = parseSessionFile(filePath);
261
+ allRecords.push(...records);
262
+ }
263
+
264
+ allRecords.sort((a, b) => a.date.localeCompare(b.date));
265
+ usageCache = { records: allRecords, at: Date.now() };
266
+ return allRecords;
267
+ }
268
+
269
+ // ─── Cindy Pi-Agent Usage ──────────────────────────────
270
+
271
+ /**
272
+ * Read usage records from Cindy's pi-agent sessions.
273
+ * Cindy stores pi-agent sessions in its own data directory
274
+ * (~/Library/Application Support/Cindy/pi-agent-home/sessions/)
275
+ * rather than ~/.pi/agent/sessions/. The JSONL format is identical,
276
+ * so we reuse the same parseSessionFile() function.
277
+ */
278
+ export function readCindyUsage(): UsageRecord[] {
279
+ const allRecords: UsageRecord[] = [];
280
+ const cindyDir = getCindySessionsDir();
281
+
282
+ if (!existsSync(cindyDir)) return allRecords;
283
+
284
+ try {
285
+ // Cindy's sessions are flat (no subdirectory per project)
286
+ const files = readdirSync(cindyDir).filter((f) => f.endsWith(".jsonl"));
287
+ for (const file of files) {
288
+ const filePath = join(cindyDir, file);
289
+ if (!statSync(filePath).isFile()) continue;
290
+ const records = parseSessionFile(filePath);
291
+ allRecords.push(...records);
292
+ }
293
+ } catch {
294
+ // skip unreadable directory
295
+ }
296
+
297
+ // Sort by date ascending
298
+ allRecords.sort((a, b) => a.date.localeCompare(b.date));
299
+ return allRecords;
300
+ }
301
+
302
+ // ─── Claude Usage (from Cindy SQLite) ──────────────────
303
+
304
+ /**
305
+ * Find all Cindy SQLite database files that may contain usage data.
306
+ * Cindy stores session/usage data in cindy-cms*.db files under its
307
+ * application support directory.
308
+ */
309
+ function getCindyDbPaths(): string[] {
310
+ const home = homedir();
311
+ let cindyAppDir: string;
312
+ if (platform() === "darwin") {
313
+ cindyAppDir = join(home, "Library", "Application Support", "Cindy");
314
+ } else if (platform() === "win32") {
315
+ cindyAppDir = join(process.env.APPDATA ?? join(home, "AppData", "Roaming"), "Cindy");
316
+ } else {
317
+ cindyAppDir = join(home, ".config", "Cindy");
318
+ }
319
+
320
+ if (!existsSync(cindyAppDir)) return [];
321
+
322
+ try {
323
+ return readdirSync(cindyAppDir)
324
+ .filter((f) => f.startsWith("cindy-cms") && f.endsWith(".db"))
325
+ .map((f) => join(cindyAppDir, f));
326
+ } catch {
327
+ return [];
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Read Claude usage records from Cindy's daily_model_usage table.
333
+ * Cindy tracks every agent_kind (claude-code, pi, codex) in the same
334
+ * table; we filter to agent_kind = 'claude-code' for Claude stats.
335
+ */
336
+ export function readClaudeUsage(): UsageRecord[] {
337
+ const allRecords: UsageRecord[] = [];
338
+ const dbPaths = getCindyDbPaths();
339
+ if (dbPaths.length === 0) return allRecords;
340
+
341
+ for (const dbPath of dbPaths) {
171
342
  try {
172
- const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
173
- for (const file of files) {
174
- const filePath = join(dir, file);
175
- const records = parseSessionFile(filePath);
176
- allRecords.push(...records);
343
+ const query = "SELECT day, model, cost_usd, input_tokens, output_tokens, cache_read_tokens, cache_create_tokens FROM daily_model_usage WHERE agent_kind = 'claude-code' ORDER BY day";
344
+ const result = spawnSync("sqlite3", [dbPath, "-json", query], {
345
+ encoding: "utf8",
346
+ timeout: 10000,
347
+ });
348
+
349
+ if (result.status !== 0) continue;
350
+ const output = result.stdout?.trim();
351
+ if (!output) continue;
352
+
353
+ const rows = JSON.parse(output) as Array<{
354
+ day: string;
355
+ model: string;
356
+ cost_usd: number;
357
+ input_tokens: number;
358
+ output_tokens: number;
359
+ cache_read_tokens: number;
360
+ cache_create_tokens: number;
361
+ }>;
362
+
363
+ for (const row of rows) {
364
+ allRecords.push({
365
+ date: row.day,
366
+ providerId: "claude",
367
+ modelId: row.model,
368
+ inputTokens: row.input_tokens ?? 0,
369
+ outputTokens: row.output_tokens ?? 0,
370
+ cacheReadTokens: row.cache_read_tokens ?? 0,
371
+ cacheWriteTokens: row.cache_create_tokens ?? 0,
372
+ requests: 1,
373
+ cost: row.cost_usd ?? 0,
374
+ });
177
375
  }
178
376
  } catch {
179
- // skip unreadable directories
377
+ // skip this db
180
378
  }
181
379
  }
182
380
 
@@ -185,6 +383,327 @@ export function readAllUsage(): UsageRecord[] {
185
383
  return allRecords;
186
384
  }
187
385
 
386
+ /**
387
+ * Read Codex usage records from Cindy's daily_model_usage table.
388
+ * Filters to agent_kind = 'codex'.
389
+ */
390
+ export function readCodexUsage(): UsageRecord[] {
391
+ const allRecords: UsageRecord[] = [];
392
+ const dbPaths = getCindyDbPaths();
393
+ if (dbPaths.length === 0) return allRecords;
394
+
395
+ for (const dbPath of dbPaths) {
396
+ try {
397
+ const query = "SELECT day, model, cost_usd, input_tokens, output_tokens, cache_read_tokens, cache_create_tokens FROM daily_model_usage WHERE agent_kind = 'codex' ORDER BY day";
398
+ const result = spawnSync("sqlite3", [dbPath, "-json", query], {
399
+ encoding: "utf8",
400
+ timeout: 10000,
401
+ });
402
+
403
+ if (result.status !== 0) continue;
404
+ const output = result.stdout?.trim();
405
+ if (!output) continue;
406
+
407
+ const rows = JSON.parse(output) as Array<{
408
+ day: string;
409
+ model: string;
410
+ cost_usd: number;
411
+ input_tokens: number;
412
+ output_tokens: number;
413
+ cache_read_tokens: number;
414
+ cache_create_tokens: number;
415
+ }>;
416
+
417
+ for (const row of rows) {
418
+ allRecords.push({
419
+ date: row.day,
420
+ providerId: "codex",
421
+ modelId: row.model,
422
+ inputTokens: row.input_tokens ?? 0,
423
+ outputTokens: row.output_tokens ?? 0,
424
+ cacheReadTokens: row.cache_read_tokens ?? 0,
425
+ cacheWriteTokens: row.cache_create_tokens ?? 0,
426
+ requests: 1,
427
+ cost: row.cost_usd ?? 0,
428
+ });
429
+ }
430
+ } catch {
431
+ // skip this db
432
+ }
433
+ }
434
+
435
+ allRecords.sort((a, b) => a.date.localeCompare(b.date));
436
+ return allRecords;
437
+ }
438
+
439
+ // ─── Combined All Sources ──────────────────────────────
440
+
441
+ /**
442
+ * Combine usage from all sources: local pi, Cindy pi-agent, Claude, Codex.
443
+ * Used for the "All" tab that shows everything in one view.
444
+ */
445
+ export function readAllCombinedUsage(): UsageRecord[] {
446
+ const all: UsageRecord[] = [
447
+ ...readAllUsage(),
448
+ ...readCindyUsage(),
449
+ ...readClaudeUsage(),
450
+ ...readCodexUsage(),
451
+ ...readAtomcodeUsage(),
452
+ ...readCopilotUsage(),
453
+ ];
454
+ all.sort((a, b) => a.date.localeCompare(b.date));
455
+ return all;
456
+ }
457
+
458
+ // ─── AtomCode Usage ────────────────────────────────────
459
+
460
+ const ATOMCODE_DIR = join(homedir(), ".atomcode");
461
+
462
+ /**
463
+ * Read usage records from AtomCode sessions under ~/.atomcode/sessions.
464
+ * Each JSONL line carries an optional usage object:
465
+ * { prompt, completion, cached } — mapped to input/output/cacheRead.
466
+ * Model id is sniffed from the snapshot system prompt (e.g.
467
+ * "running the deepseek-v4-flash model"), defaulting to "atomcode".
468
+ */
469
+ export function readAtomcodeUsage(): UsageRecord[] {
470
+ const allRecords: UsageRecord[] = [];
471
+ const sessionsDir = join(ATOMCODE_DIR, "sessions");
472
+ if (!existsSync(sessionsDir)) return allRecords;
473
+
474
+ let sessionDirs: string[] = [];
475
+ try {
476
+ sessionDirs = readdirSync(sessionsDir)
477
+ .map((name) => join(sessionsDir, name))
478
+ .filter((dir) => statSync(dir).isDirectory());
479
+ } catch {
480
+ return allRecords;
481
+ }
482
+
483
+ for (const dir of sessionDirs) {
484
+ const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
485
+ for (const file of files) {
486
+ const filePath = join(dir, file);
487
+ try {
488
+ const raw = readFileSync(filePath, "utf-8");
489
+ const model = sniffAtomcodeModel(join(dir, file.replace(/\.jsonl$/, ".snapshot")));
490
+ for (const line of raw.split("\n").filter((l) => l.trim())) {
491
+ try {
492
+ const obj = JSON.parse(line);
493
+ const usage = obj.usage;
494
+ if (!usage || typeof usage.prompt !== "number") continue;
495
+ const { date, hour } = cnDateParts(obj.iso ?? obj.ts ?? "");
496
+ allRecords.push({
497
+ date,
498
+ hour,
499
+ providerId: "atomcode",
500
+ modelId: model,
501
+ inputTokens: usage.prompt ?? 0,
502
+ outputTokens: usage.completion ?? 0,
503
+ cacheReadTokens: usage.cached ?? 0,
504
+ cacheWriteTokens: 0,
505
+ requests: 1,
506
+ cost: 0,
507
+ });
508
+ } catch {
509
+ // skip malformed lines
510
+ }
511
+ }
512
+ } catch {
513
+ // skip unreadable files
514
+ }
515
+ }
516
+ }
517
+
518
+ allRecords.sort((a, b) => a.date.localeCompare(b.date));
519
+ return allRecords;
520
+ }
521
+
522
+ /** Extract the model name from an AtomCode snapshot system prompt. */
523
+ function sniffAtomcodeModel(snapshotPath: string): string {
524
+ try {
525
+ if (!existsSync(snapshotPath)) return "atomcode";
526
+ const txt = readFileSync(snapshotPath, "utf-8");
527
+ const m = txt.match(/running the ([\w.-]+) model/i);
528
+ return m?.[1] ?? "atomcode";
529
+ } catch {
530
+ return "atomcode";
531
+ }
532
+ }
533
+
534
+ // ─── Copilot Usage (Local session-store.db) ────────────
535
+ //
536
+ // The Copilot CLI records every assistant turn's token usage in a local
537
+ // SQLite database (~/.copilot/session-store.db, table
538
+ // assistant_usage_events). We read it directly — no GitHub Billing REST
539
+ // API or PAT required, and it works even when the account isn't on the
540
+ // enhanced billing platform.
541
+
542
+ const COPILOT_CONFIG_PATH = join(PI_DIR, "copilot.json");
543
+
544
+ interface CopilotConfig {
545
+ username?: string;
546
+ token?: string;
547
+ }
548
+
549
+ export function readCopilotConfig(): CopilotConfig {
550
+ try {
551
+ if (!existsSync(COPILOT_CONFIG_PATH)) return {};
552
+ const raw = readFileSync(COPILOT_CONFIG_PATH, "utf-8");
553
+ const parsed = JSON.parse(raw) as CopilotConfig;
554
+ return {
555
+ username: typeof parsed.username === "string" ? parsed.username : undefined,
556
+ token: typeof parsed.token === "string" ? parsed.token : undefined,
557
+ };
558
+ } catch {
559
+ return {};
560
+ }
561
+ }
562
+
563
+ export function writeCopilotConfig(cfg: CopilotConfig): boolean {
564
+ try {
565
+ const clean: CopilotConfig = {
566
+ username: cfg.username?.trim() || undefined,
567
+ token: cfg.token?.trim() || undefined,
568
+ };
569
+ writeFileSync(COPILOT_CONFIG_PATH, JSON.stringify(clean, null, 2), "utf-8");
570
+ return true;
571
+ } catch {
572
+ return false;
573
+ }
574
+ }
575
+
576
+ const COPILOT_STORE_PATH = join(homedir(), ".copilot", "session-store.db");
577
+
578
+ function copilotNum(v: unknown): number {
579
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
580
+ }
581
+
582
+ /**
583
+ * Read all usage events from the local Copilot session-store.db and map them
584
+ * to UsageRecords bucketed in China time (UTC+8) like every other source.
585
+ * Each event = one assistant turn → one request. Local DB has no cost info,
586
+ * so cost is always 0 (token counts are still fully populated).
587
+ */
588
+ function readCopilotStore(): UsageRecord[] {
589
+ const records: UsageRecord[] = [];
590
+ let db: DatabaseSync | null = null;
591
+ try {
592
+ db = new DatabaseSync(COPILOT_STORE_PATH, { readOnly: true });
593
+ const rows = db
594
+ .prepare(
595
+ `SELECT model, input_tokens, output_tokens, cache_read_tokens,
596
+ cache_write_tokens, created_at
597
+ FROM assistant_usage_events`
598
+ )
599
+ .all() as Array<Record<string, unknown>>;
600
+
601
+ for (const row of rows) {
602
+ const ts = row.created_at;
603
+ if (typeof ts !== "string" || !ts) continue;
604
+ const { date, hour } = cnDateParts(ts);
605
+ records.push({
606
+ date,
607
+ hour,
608
+ providerId: "copilot",
609
+ modelId: typeof row.model === "string" && row.model ? row.model : "copilot",
610
+ inputTokens: copilotNum(row.input_tokens),
611
+ outputTokens: copilotNum(row.output_tokens),
612
+ cacheReadTokens: copilotNum(row.cache_read_tokens),
613
+ cacheWriteTokens: copilotNum(row.cache_write_tokens),
614
+ requests: 1,
615
+ cost: 0,
616
+ });
617
+ }
618
+ } catch {
619
+ // DB missing, locked by a running Copilot CLI, or node:sqlite not
620
+ // available — report zero usage instead of failing the dashboard.
621
+ } finally {
622
+ try {
623
+ db?.close();
624
+ } catch {
625
+ /* ignore */
626
+ }
627
+ }
628
+
629
+ records.sort((a, b) => a.date.localeCompare(b.date));
630
+ return records;
631
+ }
632
+
633
+ // Brief cache so the dashboard's auto-refresh doesn't re-open the SQLite DB
634
+ // on every request. Reads are cheap (~700 rows), so a short TTL is fine.
635
+ const COPILOT_USAGE_TTL_MS = 30_000;
636
+ let copilotUsageCache: { records: UsageRecord[]; at: number } | null = null;
637
+
638
+ /**
639
+ * Synchronous accessor used by the combined view and the Copilot tab.
640
+ * Returns the latest local Copilot records, re-reading the DB when stale.
641
+ */
642
+ export function readCopilotUsage(): UsageRecord[] {
643
+ if (copilotUsageCache && Date.now() - copilotUsageCache.at < COPILOT_USAGE_TTL_MS) {
644
+ return copilotUsageCache.records;
645
+ }
646
+ const records = readCopilotStore();
647
+ copilotUsageCache = { records, at: Date.now() };
648
+ return records;
649
+ }
650
+
651
+ /** Drop cached Copilot data — called after config changes so usage refetches. */
652
+ export function clearCopilotCaches(): void {
653
+ copilotUsageCache = null;
654
+ }
655
+
656
+ // ─── Provider-Based Filtering ──────────────────────────
657
+
658
+ /**
659
+ * Provider filter patterns. Each provider has a list of regex patterns
660
+ * that match against providerId and modelId to classify records.
661
+ */
662
+ export interface ProviderFilter {
663
+ id: string;
664
+ label: string;
665
+ patterns: RegExp[];
666
+ }
667
+
668
+ export const PROVIDER_FILTERS: ProviderFilter[] = [
669
+ {
670
+ id: "copilot",
671
+ label: "Copilot",
672
+ patterns: [/^copilot$/i],
673
+ },
674
+ {
675
+ id: "atomcode",
676
+ label: "AtomCode",
677
+ patterns: [/^atomcode$/i],
678
+ },
679
+ {
680
+ id: "opencode",
681
+ label: "OpenCode",
682
+ patterns: [/^opencode$/, /^opencode-go$/i],
683
+ },
684
+ {
685
+ id: "gemini",
686
+ label: "Gemini",
687
+ patterns: [/^google$/, /gemini/i],
688
+ },
689
+ {
690
+ id: "grok",
691
+ label: "Grok",
692
+ patterns: [/^xai$/, /grok/i],
693
+ },
694
+ ];
695
+
696
+ /**
697
+ * Filter usage records by provider. Matches against both providerId and modelId.
698
+ */
699
+ export function filterByProvider(records: UsageRecord[], providerId: string): UsageRecord[] {
700
+ const filter = PROVIDER_FILTERS.find((f) => f.id === providerId);
701
+ if (!filter) return records;
702
+ return records.filter((r) =>
703
+ filter.patterns.some((p) => p.test(r.providerId) || p.test(r.modelId))
704
+ );
705
+ }
706
+
188
707
  // ─── Aggregation Helpers ────────────────────────────────
189
708
 
190
709
  export function getDailyAggregates(records: UsageRecord[]) {
@@ -320,6 +839,8 @@ export function getUsageByRange(records: UsageRecord[], fromDate: string, toDate
320
839
  totalRequests += r.requests;
321
840
  }
322
841
 
842
+ // totalTokens counts all processed tokens including cached context reads.
843
+ // Cache hits are billed at a lower rate, but they still count as usage.
323
844
  const totalTokens = totalInput + totalOutput + totalCacheRead + totalCacheWrite;
324
845
  const cacheHitRate = totalTokens > 0 ? ((totalCacheRead + totalCacheWrite) / totalTokens) * 100 : 0;
325
846
 
@@ -573,13 +1094,12 @@ export function listSessions(): ProjectGroup[] {
573
1094
  }
574
1095
 
575
1096
  const group = groups.get(projectPath)!;
576
- const files = readdirSync(dir)
577
- .filter((f) => f.endsWith(".jsonl"))
578
- .sort()
579
- .reverse(); // newest first
1097
+ const files: string[] = [];
1098
+ walkSessionJsonl(dir, files);
1099
+ // Sort by path descending (newest session directories first as a rough proxy)
1100
+ files.sort().reverse();
580
1101
 
581
- for (const file of files) {
582
- const filePath = join(dir, file);
1102
+ for (const filePath of files) {
583
1103
  const session = parseSessionFileInfo(filePath);
584
1104
  if (session) {
585
1105
  group.sessions.push(session);
@@ -588,11 +1108,10 @@ export function listSessions(): ProjectGroup[] {
588
1108
 
589
1109
  group.totalSessions = group.sessions.length;
590
1110
  if (group.sessions.length > 0) {
591
- group.lastActive = group.sessions[0]?.timestamp ?? ""; // already sorted newest-first
1111
+ group.lastActive = group.sessions[0]?.timestamp ?? "";
592
1112
  }
593
1113
  }
594
1114
 
595
- // Sort groups by lastActive descending
596
1115
  return Array.from(groups.values())
597
1116
  .filter((g) => g.sessions.length > 0)
598
1117
  .sort((a, b) => b.lastActive.localeCompare(a.lastActive));
@@ -779,6 +1298,74 @@ export function permanentlyDeleteTrash(trashPath: string): boolean {
779
1298
  }
780
1299
  }
781
1300
 
1301
+ // ─── Session Auto-Expiry ──────────────────────────────────
1302
+
1303
+ const AUTO_EXPIRE_INTERVAL_MS = 24 * 60 * 60 * 1000; // every 24h
1304
+ let autoExpireTimer: ReturnType<typeof setInterval> | null = null;
1305
+
1306
+ function getSessionExpiryDays(): number {
1307
+ const settings = readSettings();
1308
+ const val = settings?.sessionExpiryDays;
1309
+ const n = typeof val === "number" ? val : Number(val);
1310
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 7;
1311
+ }
1312
+
1313
+ interface ExpireResult {
1314
+ expired: string[];
1315
+ skipped: string[];
1316
+ errors: string[];
1317
+ }
1318
+
1319
+ export function autoExpireSessions(): ExpireResult {
1320
+ const result: ExpireResult = { expired: [], skipped: [], errors: [] };
1321
+ const expiryDays = getSessionExpiryDays();
1322
+ const cutoffMs = Date.now() - expiryDays * 24 * 60 * 60 * 1000;
1323
+
1324
+ const dirs = getSessionDirs();
1325
+ for (const dir of dirs) {
1326
+ let files: string[];
1327
+ try {
1328
+ files = readdirSync(dir)
1329
+ .filter((f) => f.endsWith(".jsonl"))
1330
+ .map((f) => join(dir, f));
1331
+ } catch {
1332
+ continue;
1333
+ }
1334
+
1335
+ for (const filePath of files) {
1336
+ try {
1337
+ const info = parseSessionFileInfo(filePath);
1338
+ if (!info) { result.skipped.push(filePath); continue; }
1339
+
1340
+ const lastActiveTs = new Date(info.lastActive || info.timestamp).getTime();
1341
+ if (isNaN(lastActiveTs)) { result.skipped.push(filePath); continue; }
1342
+
1343
+ if (lastActiveTs < cutoffMs) {
1344
+ const ok = trashSessionFile(filePath);
1345
+ if (ok) {
1346
+ result.expired.push(filePath);
1347
+ } else {
1348
+ result.errors.push(`trash failed: ${filePath}`);
1349
+ }
1350
+ }
1351
+ } catch {
1352
+ result.errors.push(`scan failed: ${filePath}`);
1353
+ }
1354
+ }
1355
+ }
1356
+
1357
+ return result;
1358
+ }
1359
+
1360
+ export function startAutoExpiryTimer(): void {
1361
+ if (autoExpireTimer) return;
1362
+ // Run once at startup (fire-and-forget, swallow errors)
1363
+ try { autoExpireSessions(); } catch { /* ignore */ }
1364
+ autoExpireTimer = setInterval(() => {
1365
+ try { autoExpireSessions(); } catch { /* ignore */ }
1366
+ }, AUTO_EXPIRE_INTERVAL_MS);
1367
+ }
1368
+
782
1369
  // ─── Session Preview ────────────────────────────────
783
1370
 
784
1371
  export interface SessionPreviewMessage {
@@ -1060,6 +1647,93 @@ export interface ApplyUpdateResult {
1060
1647
  message?: string;
1061
1648
  }
1062
1649
 
1650
+ // ─── npm resolution ──────────────────────────────────────
1651
+ // GUI-launched apps (Finder / Launchpad) inherit a minimal PATH
1652
+ // (/usr/bin:/bin:/usr/sbin:/sbin) that does NOT contain the user's npm or
1653
+ // node, so `spawnSync("npm", ...)` fails with ENOENT in the packaged app.
1654
+ // Instead of relying on PATH we locate the real npm-cli.js + node binary
1655
+ // from common install locations and run npm via that explicit node.
1656
+
1657
+ function realpathOr(p: string): string | null {
1658
+ try {
1659
+ return realpathSync(p);
1660
+ } catch {
1661
+ return null;
1662
+ }
1663
+ }
1664
+
1665
+ /** Candidate npm binaries across common layouts (PATH + known locations). */
1666
+ function npmBinCandidates(): string[] {
1667
+ const home = homedir();
1668
+ const fromPath = (process.env.PATH || "")
1669
+ .split(":")
1670
+ .filter(Boolean)
1671
+ .map((d) => join(d, "npm"));
1672
+ return [
1673
+ ...fromPath,
1674
+ `${home}/.npm-global/bin/npm`,
1675
+ `${home}/.npm-packages/bin/npm`,
1676
+ `${home}/.local/share/pnpm/npm`,
1677
+ "/usr/local/bin/npm",
1678
+ "/opt/homebrew/bin/npm",
1679
+ // pi-node bundles its own node/npm under ~/.local/share/pi-node/node-*/
1680
+ ...(() => {
1681
+ const base = join(home, ".local", "share", "pi-node");
1682
+ try {
1683
+ return readdirSync(base)
1684
+ .filter((n) => n.startsWith("node-"))
1685
+ .map((n) => join(base, n, "bin", "npm"));
1686
+ } catch {
1687
+ return [];
1688
+ }
1689
+ })(),
1690
+ ];
1691
+ }
1692
+
1693
+ /** Resolve the npm CLI entry (npm-cli.js) — npm bins are symlinks to it. */
1694
+ function resolveNpmCliJs(): string | null {
1695
+ for (const bin of npmBinCandidates()) {
1696
+ const real = realpathOr(bin);
1697
+ if (real && existsSync(real) && /npm-cli\.js$/.test(real)) return real;
1698
+ }
1699
+ return null;
1700
+ }
1701
+
1702
+ /** Candidate node binaries (PATH + common locations + pi-node bundle). */
1703
+ function resolveNodeBin(): string | null {
1704
+ const home = homedir();
1705
+ const fromPath = (process.env.PATH || "")
1706
+ .split(":")
1707
+ .filter(Boolean)
1708
+ .map((d) => join(d, "node"));
1709
+ const candidates = [
1710
+ ...fromPath,
1711
+ `${home}/.npm-global/bin/node`,
1712
+ `${home}/.npm-packages/bin/node`,
1713
+ "/usr/local/bin/node",
1714
+ "/opt/homebrew/bin/node",
1715
+ ...(() => {
1716
+ const base = join(home, ".local", "share", "pi-node");
1717
+ try {
1718
+ return readdirSync(base)
1719
+ .filter((n) => n.startsWith("node-"))
1720
+ .map((n) => join(base, n, "bin", "node"));
1721
+ } catch {
1722
+ return [];
1723
+ }
1724
+ })(),
1725
+ ];
1726
+ for (const bin of candidates) {
1727
+ try {
1728
+ const out = spawnSync(bin, ["--version"], { encoding: "utf8", timeout: 5000 });
1729
+ if (out.status === 0 && out.stdout) return bin;
1730
+ } catch {
1731
+ // try next candidate
1732
+ }
1733
+ }
1734
+ return null;
1735
+ }
1736
+
1063
1737
  /**
1064
1738
  * One-click update: npm install <name>@latest inside ~/.pi/agent/npm.
1065
1739
  * Only packages already installed there are accepted (pi core is excluded —
@@ -1068,6 +1742,8 @@ export interface ApplyUpdateResult {
1068
1742
  export function applyExtensionUpdates(names: string[]): ApplyUpdateResult[] {
1069
1743
  const dir = join(PI_DIR, "npm");
1070
1744
  const installed = new Set(listInstalledExtensions().map((e) => e.name));
1745
+ const npmCliJs = resolveNpmCliJs();
1746
+ const nodeBin = resolveNodeBin();
1071
1747
 
1072
1748
  return names.map((name) => {
1073
1749
  if (!installed.has(name)) {
@@ -1076,15 +1752,19 @@ export function applyExtensionUpdates(names: string[]): ApplyUpdateResult[] {
1076
1752
  try {
1077
1753
  // --legacy-peer-deps: peer deps (e.g. pi core) are provided by the pi host,
1078
1754
  // not installed here — strict resolution would fail with ERESOLVE.
1079
- const out = spawnSync(
1080
- "npm",
1081
- ["install", `${name}@latest`, "--no-audit", "--no-fund", "--legacy-peer-deps"],
1082
- {
1755
+ const args = ["install", `${name}@latest`, "--no-audit", "--no-fund", "--legacy-peer-deps"];
1756
+ let out;
1757
+ if (nodeBin && npmCliJs) {
1758
+ // Explicit node + npm-cli.js — works even when PATH lacks npm
1759
+ out = spawnSync(nodeBin, [npmCliJs, ...args], {
1083
1760
  cwd: dir,
1084
1761
  encoding: "utf8",
1085
1762
  timeout: 120000,
1086
- }
1087
- );
1763
+ });
1764
+ } else {
1765
+ // Fall back to PATH resolution (dev / terminal environments)
1766
+ out = spawnSync("npm", args, { cwd: dir, encoding: "utf8", timeout: 120000 });
1767
+ }
1088
1768
  if (out.status === 0) return { name, success: true };
1089
1769
  const stderr = (out.stderr || "").trim().split("\n").slice(-3).join(" ");
1090
1770
  return { name, success: false, message: stderr || `npm exited with ${out.status}` };