@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.
- package/README.ja.md +32 -8
- package/README.md +58 -10
- package/README.zh-CN.md +31 -7
- package/dist-electron/main/main.cjs +2453 -0
- package/package.json +18 -5
- package/pi-package/index.ts +228 -4
- package/public/apple-touch-icon.png +0 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/pi.svg +6 -41
- package/public/trayIconTemplate.png +0 -0
- package/server/pi-reader.ts +470 -31
- package/src/App.tsx +0 -2
- package/src/components/dashboard/DashboardPage.tsx +45 -13
- package/src/components/layout/Sidebar.tsx +15 -6
- package/src/components/providers/ProvidersModelsPage.tsx +56 -4
- package/src/components/sessions/SessionsPage.tsx +36 -2
- package/src/components/settings/SettingsPage.tsx +72 -0
- package/src/lib/translations/en.ts +20 -58
- package/src/lib/translations/ja.ts +20 -58
- package/src/lib/translations/zh-CN.ts +20 -58
- package/src/lib/translations/zh-TW.ts +20 -58
- package/src/main.tsx +29 -5
- package/src/types/index.ts +2 -0
- package/vite.config.ts +106 -8
- package/server/agent-session-manager.ts +0 -827
- package/server/chat-api-plugin.ts +0 -488
- package/src/components/chat/ChatInput.tsx +0 -863
- package/src/components/chat/ChatPage.tsx +0 -617
- package/src/components/chat/ChatWindow.tsx +0 -338
- package/src/components/chat/MessageView.tsx +0 -595
- package/src/hooks/useAgentSession.ts +0 -1104
package/server/pi-reader.ts
CHANGED
|
@@ -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
|
|
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
|
|
257
|
+
const files = getAllSessionFiles();
|
|
182
258
|
|
|
183
|
-
for (const
|
|
184
|
-
|
|
185
|
-
|
|
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 =
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
|
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 ?? "";
|
|
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
|
|
1321
|
-
|
|
1322
|
-
|
|
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>
|