@raingor/pi-web-switch 0.4.1 → 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,7 +1,8 @@
1
- import { readFileSync, readdirSync, existsSync, statSync, unlinkSync, writeFileSync, mkdirSync, renameSync } from "fs";
1
+ import { readFileSync, readdirSync, existsSync, statSync, unlinkSync, writeFileSync, mkdirSync, renameSync, realpathSync } from "fs";
2
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
 
@@ -124,6 +125,70 @@ function getSessionDirs(): string[] {
124
125
  .filter((dir) => statSync(dir).isDirectory());
125
126
  }
126
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
+
127
192
  function parseSessionFile(filePath: string): UsageRecord[] {
128
193
  const records: UsageRecord[] = [];
129
194
  try {
@@ -149,9 +214,7 @@ function parseSessionFile(filePath: string): UsageRecord[] {
149
214
  if (!usage || !usage.input) continue;
150
215
 
151
216
  const timestamp = obj.timestamp || obj.message.timestamp;
152
- const d = new Date(timestamp);
153
- const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
154
- const hour = d.getHours();
217
+ const { date, hour } = cnDateParts(timestamp);
155
218
 
156
219
  records.push({
157
220
  date,
@@ -176,25 +239,30 @@ function parseSessionFile(filePath: string): UsageRecord[] {
176
239
  return records;
177
240
  }
178
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
+
179
252
  export function readAllUsage(): UsageRecord[] {
253
+ if (usageCache && Date.now() - usageCache.at < USAGE_CACHE_TTL_MS) {
254
+ return usageCache.records;
255
+ }
180
256
  const allRecords: UsageRecord[] = [];
181
- const dirs = getSessionDirs();
257
+ const files = getAllSessionFiles();
182
258
 
183
- for (const dir of dirs) {
184
- try {
185
- const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
186
- for (const file of files) {
187
- const filePath = join(dir, file);
188
- const records = parseSessionFile(filePath);
189
- allRecords.push(...records);
190
- }
191
- } catch {
192
- // skip unreadable directories
193
- }
259
+ for (const filePath of files) {
260
+ const records = parseSessionFile(filePath);
261
+ allRecords.push(...records);
194
262
  }
195
263
 
196
- // Sort by date ascending
197
264
  allRecords.sort((a, b) => a.date.localeCompare(b.date));
265
+ usageCache = { records: allRecords, at: Date.now() };
198
266
  return allRecords;
199
267
  }
200
268
 
@@ -380,11 +448,211 @@ export function readAllCombinedUsage(): UsageRecord[] {
380
448
  ...readCindyUsage(),
381
449
  ...readClaudeUsage(),
382
450
  ...readCodexUsage(),
451
+ ...readAtomcodeUsage(),
452
+ ...readCopilotUsage(),
383
453
  ];
384
454
  all.sort((a, b) => a.date.localeCompare(b.date));
385
455
  return all;
386
456
  }
387
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
+
388
656
  // ─── Provider-Based Filtering ──────────────────────────
389
657
 
390
658
  /**
@@ -398,6 +666,16 @@ export interface ProviderFilter {
398
666
  }
399
667
 
400
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
+ },
401
679
  {
402
680
  id: "opencode",
403
681
  label: "OpenCode",
@@ -561,6 +839,8 @@ export function getUsageByRange(records: UsageRecord[], fromDate: string, toDate
561
839
  totalRequests += r.requests;
562
840
  }
563
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.
564
844
  const totalTokens = totalInput + totalOutput + totalCacheRead + totalCacheWrite;
565
845
  const cacheHitRate = totalTokens > 0 ? ((totalCacheRead + totalCacheWrite) / totalTokens) * 100 : 0;
566
846
 
@@ -814,13 +1094,12 @@ export function listSessions(): ProjectGroup[] {
814
1094
  }
815
1095
 
816
1096
  const group = groups.get(projectPath)!;
817
- const files = readdirSync(dir)
818
- .filter((f) => f.endsWith(".jsonl"))
819
- .sort()
820
- .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();
821
1101
 
822
- for (const file of files) {
823
- const filePath = join(dir, file);
1102
+ for (const filePath of files) {
824
1103
  const session = parseSessionFileInfo(filePath);
825
1104
  if (session) {
826
1105
  group.sessions.push(session);
@@ -829,11 +1108,10 @@ export function listSessions(): ProjectGroup[] {
829
1108
 
830
1109
  group.totalSessions = group.sessions.length;
831
1110
  if (group.sessions.length > 0) {
832
- group.lastActive = group.sessions[0]?.timestamp ?? ""; // already sorted newest-first
1111
+ group.lastActive = group.sessions[0]?.timestamp ?? "";
833
1112
  }
834
1113
  }
835
1114
 
836
- // Sort groups by lastActive descending
837
1115
  return Array.from(groups.values())
838
1116
  .filter((g) => g.sessions.length > 0)
839
1117
  .sort((a, b) => b.lastActive.localeCompare(a.lastActive));
@@ -1020,6 +1298,74 @@ export function permanentlyDeleteTrash(trashPath: string): boolean {
1020
1298
  }
1021
1299
  }
1022
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
+
1023
1369
  // ─── Session Preview ────────────────────────────────
1024
1370
 
1025
1371
  export interface SessionPreviewMessage {
@@ -1301,6 +1647,93 @@ export interface ApplyUpdateResult {
1301
1647
  message?: string;
1302
1648
  }
1303
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
+
1304
1737
  /**
1305
1738
  * One-click update: npm install <name>@latest inside ~/.pi/agent/npm.
1306
1739
  * Only packages already installed there are accepted (pi core is excluded —
@@ -1309,6 +1742,8 @@ export interface ApplyUpdateResult {
1309
1742
  export function applyExtensionUpdates(names: string[]): ApplyUpdateResult[] {
1310
1743
  const dir = join(PI_DIR, "npm");
1311
1744
  const installed = new Set(listInstalledExtensions().map((e) => e.name));
1745
+ const npmCliJs = resolveNpmCliJs();
1746
+ const nodeBin = resolveNodeBin();
1312
1747
 
1313
1748
  return names.map((name) => {
1314
1749
  if (!installed.has(name)) {
@@ -1317,15 +1752,19 @@ export function applyExtensionUpdates(names: string[]): ApplyUpdateResult[] {
1317
1752
  try {
1318
1753
  // --legacy-peer-deps: peer deps (e.g. pi core) are provided by the pi host,
1319
1754
  // not installed here — strict resolution would fail with ERESOLVE.
1320
- const out = spawnSync(
1321
- "npm",
1322
- ["install", `${name}@latest`, "--no-audit", "--no-fund", "--legacy-peer-deps"],
1323
- {
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], {
1324
1760
  cwd: dir,
1325
1761
  encoding: "utf8",
1326
1762
  timeout: 120000,
1327
- }
1328
- );
1763
+ });
1764
+ } else {
1765
+ // Fall back to PATH resolution (dev / terminal environments)
1766
+ out = spawnSync("npm", args, { cwd: dir, encoding: "utf8", timeout: 120000 });
1767
+ }
1329
1768
  if (out.status === 0) return { name, success: true };
1330
1769
  const stderr = (out.stderr || "").trim().split("\n").slice(-3).join(" ");
1331
1770
  return { name, success: false, message: stderr || `npm exited with ${out.status}` };
package/src/App.tsx CHANGED
@@ -6,7 +6,6 @@ import { MemoryPage } from "@/components/sessions/MemoryPage";
6
6
  import { ProvidersModelsPage } from "@/components/providers/ProvidersModelsPage";
7
7
  import { SubagentsPage } from "@/components/subagents/SubagentsPage";
8
8
  import { SettingsPage } from "@/components/settings/SettingsPage";
9
- import { ChatPage } from "@/components/chat/ChatPage";
10
9
 
11
10
  export default function App() {
12
11
  return (
@@ -19,7 +18,6 @@ export default function App() {
19
18
  <Route path="/providers" element={<ProvidersModelsPage />} />
20
19
  <Route path="/models" element={<ProvidersModelsPage />} />
21
20
  <Route path="/subagents" element={<SubagentsPage />} />
22
- <Route path="/chat" element={<ChatPage />} />
23
21
  <Route path="/settings" element={<SettingsPage />} />
24
22
  </Route>
25
23
  </Routes>