pyre-cli 10.3.0 → 10.4.0

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 (88) hide show
  1. package/README.md +41 -0
  2. package/dist/{alertChannels-TPSQIJ7S.js → alertChannels-MNX4XH6J.js} +1 -0
  3. package/dist/{anomaliesCmd-25OFKYPK.js → anomaliesCmd-HHTY5LYL.js} +2 -0
  4. package/dist/benchGitCmd-HIXUC5EK.js +87 -0
  5. package/dist/{benchmarkCmd-A3ETK6VY.js → benchmarkCmd-B7DLP2VC.js} +2 -0
  6. package/dist/{blender-FGH3ASEQ.js → blender-6ZX5SPYC.js} +3 -1
  7. package/dist/{brewHealth-P2GW5Y5C.js → brewHealth-VFW33QB3.js} +1 -0
  8. package/dist/budgetCmd-TGO3RYLN.js +11 -0
  9. package/dist/{buildCmd-CDPZ2ZFA.js → buildCmd-E7KUCFYG.js} +3 -1
  10. package/dist/{checkCmd-WQAHAUWO.js → checkCmd-QLYTP77Y.js} +5 -3
  11. package/dist/chunk-3RG5ZIWI.js +10 -0
  12. package/dist/{chunk-VIACN2RU.js → chunk-FYCBGUTW.js} +41 -19
  13. package/dist/chunk-JH7P6KLN.js +143 -0
  14. package/dist/{chunk-QM5L43EK.js → chunk-LDJB5V4N.js} +5 -1
  15. package/dist/chunk-NMGQVRCA.js +111 -0
  16. package/dist/chunk-OCL7P7N7.js +936 -0
  17. package/dist/{chunk-23YWCI3O.js → chunk-QEH4LR6P.js} +28 -686
  18. package/dist/{chunk-KVUMOA6C.js → chunk-RKPGPVTO.js} +77 -4
  19. package/dist/chunk-UF3XQ3R3.js +3800 -0
  20. package/dist/{collectors-JDN5Y7EV.js → collectors-V7SCIIDH.js} +5 -3
  21. package/dist/{completions-ZVTNE52B.js → completions-FTPXDSR2.js} +44 -3
  22. package/dist/{config-J4YMADEF.js → config-5WW5M63G.js} +2 -1
  23. package/dist/containersCmd-FSMYOBG2.js +84 -0
  24. package/dist/{doctor-LHFW4UJP.js → doctor-O4DIAMHK.js} +3 -2
  25. package/dist/{explainCmd-MF6PIMLW.js → explainCmd-7PBPA7O5.js} +1 -0
  26. package/dist/{extensions-XJGHG7X3.js → extensions-QQEV7U3D.js} +1 -0
  27. package/dist/{firebaseSync-ON3RZ6KX.js → firebaseSync-LRN67UTV.js} +2 -0
  28. package/dist/flightRecorder-GJIAQOXC.js +162 -0
  29. package/dist/index.js +736 -4125
  30. package/dist/inspectCmd-5FXJBJTK.js +137 -0
  31. package/dist/menu-AJFGDLBP.js +1233 -0
  32. package/dist/monitors/platform/windows-ui.ps1 +31 -29
  33. package/dist/{netUsageCmd-67IBZZYN.js → netUsageCmd-JP6YKBPF.js} +7 -5
  34. package/dist/optimizeCmd-WZKLFYZU.js +1110 -0
  35. package/dist/{pipeCmd-2N4JPKYE.js → pipeCmd-CPK6FVKB.js} +5 -3
  36. package/dist/playbookCmd-FRROIEXR.js +145 -0
  37. package/dist/{profileProcCmd-N4H6MCBT.js → profileProcCmd-5Y3733E7.js} +64 -13
  38. package/dist/{prometheus-5BL5PVLC.js → prometheus-CYNUR3QT.js} +1 -0
  39. package/dist/{prometheusCmd-LEUM5WCZ.js → prometheusCmd-G2CSEZRQ.js} +5 -3
  40. package/dist/{replayCmd-Z3PYXY7V.js → replayCmd-ODB5SPOQ.js} +25 -6
  41. package/dist/{run-HOGQDDTA.js → run-UJQYT2TO.js} +1 -0
  42. package/dist/{serveCmd-L7LVJOZN.js → serveCmd-KDPWLVQI.js} +5 -3
  43. package/dist/{service-7GIX3YNC.js → service-LIYYR3UI.js} +1 -0
  44. package/dist/{smartCmd-ODWKSFNG.js → smartCmd-2BJJ37GV.js} +130 -5
  45. package/dist/statuslineCmd-EUKYRR2G.js +75 -0
  46. package/dist/{stressCmd-MFXMFXYX.js → stressCmd-GKHGY26X.js} +2 -0
  47. package/dist/{syncCmd-42IRF633.js → syncCmd-MIBFKX24.js} +16 -5
  48. package/dist/topoCmd-RTXQJ6XA.js +189 -0
  49. package/dist/{updateCheck-52X6LJG5.js → updateCheck-LJHEMG3G.js} +2 -0
  50. package/dist/{watchdogCmd-6GR4UN6B.js → watchdogCmd-XM6OPTKM.js} +41 -6
  51. package/dist/{wizard-JQMQWFAW.js → wizard-PRDOJROQ.js} +2 -1
  52. package/package.json +2 -2
  53. package/src/benchGitCmd.ts +110 -0
  54. package/src/budgetCmd.ts +199 -0
  55. package/src/completions.ts +42 -3
  56. package/src/containersCmd.ts +106 -0
  57. package/src/doctor.ts +86 -6
  58. package/src/easy_features.test.ts +83 -0
  59. package/src/flightRecorder.ts +214 -0
  60. package/src/index.ts +181 -8
  61. package/src/inspectCmd.ts +165 -0
  62. package/src/live/input.ts +70 -3
  63. package/src/live/render.ts +1 -1
  64. package/src/live/state.ts +2 -1
  65. package/src/menu/commands.ts +754 -0
  66. package/src/menu/index.ts +358 -0
  67. package/src/menu/render.ts +272 -0
  68. package/src/menu/types.ts +63 -0
  69. package/src/menu.test.ts +110 -0
  70. package/src/monitors/blender.ts +49 -19
  71. package/src/monitors/buildTracker.ts +72 -54
  72. package/src/monitors/platform/windows-ui.ps1 +31 -29
  73. package/src/monitors/platform/windows.ts +613 -280
  74. package/src/optimizeCmd.ts +1248 -0
  75. package/src/platform.test.ts +135 -0
  76. package/src/playbookCmd.ts +156 -0
  77. package/src/power_features.test.ts +251 -0
  78. package/src/profileProcCmd.ts +68 -21
  79. package/src/replayCmd.ts +15 -6
  80. package/src/smartCmd.ts +149 -10
  81. package/src/state/config.ts +13 -0
  82. package/src/statuslineCmd.ts +87 -0
  83. package/src/syncCmd.ts +12 -0
  84. package/src/topoCmd.ts +83 -50
  85. package/src/watchdogCmd.ts +40 -3
  86. package/src/xbar.ts +195 -17
  87. package/dist/chunk-M73HCQD2.js +0 -95
  88. package/dist/topoCmd-DZ3YS5IL.js +0 -148
@@ -0,0 +1,1248 @@
1
+ /**
2
+ * pyre optimize — macOS optimizer.
3
+ *
4
+ * Finds GUI apps that are open but haven't been used recently and quits them
5
+ * gracefully (apps still get their normal "Save changes?" prompt), plus a set
6
+ * of housekeeping tools: memory pressure, caches, trash, startup items, DNS.
7
+ *
8
+ * "Recently used" is determined from two signals:
9
+ * 1. `lsappinfo visibleProcessList` — apps ordered by most-recent activation
10
+ * (always available, gives a ranking but no timestamps).
11
+ * 2. An optional background tracker (`pyre optimize track`) that polls the
12
+ * frontmost app and records real last-active timestamps in
13
+ * ~/.config/pyre/app-usage.json.
14
+ */
15
+ import fs from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import readline from 'node:readline/promises';
19
+ import { execFile, spawn, spawnSync } from 'node:child_process';
20
+ import { promisify } from 'node:util';
21
+ import chalk from 'chalk';
22
+ import { getConfigDir, readConfig, writeConfig } from './state/config.js';
23
+
24
+ const execFileAsync = promisify(execFile);
25
+
26
+ // ─── Types ──────────────────────────────────────────────────────────────────
27
+
28
+ export interface RunningApp {
29
+ asn: string;
30
+ name: string;
31
+ bundleId: string;
32
+ pid: number;
33
+ /** Launch time (ms since epoch), from lsappinfo "checkin time" */
34
+ launchedAt?: number;
35
+ /** 0 = most recently activated */
36
+ rank: number;
37
+ cpu: number;
38
+ rssKb: number;
39
+ frontmost: boolean;
40
+ }
41
+
42
+ export interface UsageData {
43
+ version: 1;
44
+ /** Start of the current continuous tracking window (ms) */
45
+ trackingSince: number;
46
+ /** Last time the tracker wrote this file (ms) */
47
+ updatedAt: number;
48
+ apps: Record<string, { name: string; lastActive: number }>;
49
+ }
50
+
51
+ export interface SelectContext {
52
+ now: number;
53
+ idleThresholdMs: number;
54
+ keepRecent: number;
55
+ protectedApps: string[];
56
+ selfPids: Set<number>;
57
+ usage?: UsageData | null;
58
+ cpuBusyPct?: number;
59
+ }
60
+
61
+ export interface IdleCandidate {
62
+ app: RunningApp;
63
+ /** Known idle time in ms (only when tracking data is fresh) */
64
+ idleMs?: number;
65
+ }
66
+
67
+ export interface SkippedApp {
68
+ app: RunningApp;
69
+ reason: string;
70
+ }
71
+
72
+ export interface OptimizeOptions {
73
+ idle?: string;
74
+ keep?: string;
75
+ yes?: boolean;
76
+ force?: boolean;
77
+ clean?: boolean;
78
+ empty?: boolean;
79
+ dryRun?: boolean;
80
+ install?: boolean;
81
+ uninstall?: boolean;
82
+ autoQuit?: string;
83
+ }
84
+
85
+ // ─── Constants ──────────────────────────────────────────────────────────────
86
+
87
+ /** Apps that are never quit automatically, in addition to user-protected ones. */
88
+ export const BUILTIN_PROTECTED = [
89
+ 'com.apple.finder',
90
+ 'com.apple.Terminal',
91
+ 'com.googlecode.iterm2',
92
+ 'dev.warp.Warp-Stable',
93
+ 'com.mitchellh.ghostty',
94
+ 'net.kovidgoyal.kitty',
95
+ 'com.github.wez.wezterm',
96
+ 'io.alacritty',
97
+ 'com.apple.systempreferences',
98
+ ];
99
+
100
+ /** Media apps count as busy at a much lower CPU level (likely playing audio/video). */
101
+ const MEDIA_BUNDLES = new Set([
102
+ 'com.apple.music',
103
+ 'com.spotify.client',
104
+ 'com.apple.podcasts',
105
+ 'com.apple.tv',
106
+ 'com.colliderli.iina',
107
+ 'org.videolan.vlc',
108
+ ]);
109
+
110
+ const DEFAULT_CPU_BUSY_PCT = 5;
111
+ const MEDIA_CPU_BUSY_PCT = 1;
112
+ const TRACK_INTERVAL_MS = 5000;
113
+ const TRACK_FRESH_MS = 120_000;
114
+ const TRACKER_LABEL = 'com.pyre.optimize-tracker';
115
+ const TRACKER_PLIST = path.join(os.homedir(), 'Library', 'LaunchAgents', `${TRACKER_LABEL}.plist`);
116
+
117
+ function usageFile(): string {
118
+ return path.join(getConfigDir(), 'app-usage.json');
119
+ }
120
+
121
+ // ─── Pure helpers (exported for tests) ──────────────────────────────────────
122
+
123
+ /** Parse "2h", "30m", "90s", "1d", "1.5h". A bare number means minutes. Returns NaN if invalid. */
124
+ export function parseDuration(input: string | undefined, fallbackMs: number): number {
125
+ if (input === undefined || input === null || String(input).trim() === '') return fallbackMs;
126
+ const m = String(input).trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*(s|sec|secs|m|min|mins|h|hr|hrs|d|day|days)?$/);
127
+ if (!m) return NaN;
128
+ const n = parseFloat(m[1]);
129
+ const unit = m[2] ?? 'm';
130
+ if (unit.startsWith('s')) return n * 1000;
131
+ if (unit.startsWith('h')) return n * 3_600_000;
132
+ if (unit.startsWith('d')) return n * 86_400_000;
133
+ return n * 60_000;
134
+ }
135
+
136
+ export function formatDuration(ms: number): string {
137
+ const totalMin = Math.floor(ms / 60_000);
138
+ if (totalMin < 1) return `${Math.max(0, Math.floor(ms / 1000))}s`;
139
+ const d = Math.floor(totalMin / 1440);
140
+ const h = Math.floor((totalMin % 1440) / 60);
141
+ const m = totalMin % 60;
142
+ if (d > 0) return `${d}d ${h}h`;
143
+ if (h > 0) return `${h}h ${m}m`;
144
+ return `${m}m`;
145
+ }
146
+
147
+ export function formatKb(kb: number): string {
148
+ const bytes = kb * 1024;
149
+ if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
150
+ if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(0)} MB`;
151
+ return `${Math.round(kb)} KB`;
152
+ }
153
+
154
+ /** Extract ASNs in order from `lsappinfo visibleProcessList` / `lsappinfo front` output. */
155
+ export function parseAsnList(raw: string): string[] {
156
+ const out: string[] = [];
157
+ const re = /ASN:(0x[0-9a-f]+-0x[0-9a-f]+)/gi;
158
+ let m: RegExpExecArray | null;
159
+ while ((m = re.exec(raw)) !== null) {
160
+ const asn = `ASN:${m[1].toLowerCase()}`;
161
+ if (!out.includes(asn)) out.push(asn);
162
+ }
163
+ return out;
164
+ }
165
+
166
+ /** Parse `lsappinfo list` output, returning only regular (Foreground/Dock) GUI apps. */
167
+ export function parseLsappinfoList(raw: string): Omit<RunningApp, 'rank' | 'cpu' | 'rssKb' | 'frontmost'>[] {
168
+ const apps: Omit<RunningApp, 'rank' | 'cpu' | 'rssKb' | 'frontmost'>[] = [];
169
+ const blocks = raw.split(/\r?\n(?=\s*\d+\)\s+")/);
170
+ for (const block of blocks) {
171
+ const header = block.match(/^\s*\d+\)\s+"(.*?)"\s+ASN:(0x[0-9a-f]+-0x[0-9a-f]+):/i);
172
+ if (!header) continue;
173
+ if (!/type="Foreground"/.test(block)) continue;
174
+ const pidMatch = block.match(/\bpid\s*=\s*(\d+)/);
175
+ if (!pidMatch) continue;
176
+ const bundleMatch = block.match(/bundleID="([^"]+)"/);
177
+ const checkin = block.match(/checkin time = (\d{4})\/(\d{2})\/(\d{2}) (\d{2}):(\d{2}):(\d{2})/);
178
+ let launchedAt: number | undefined;
179
+ if (checkin) {
180
+ const [, y, mo, d, h, mi, s] = checkin;
181
+ launchedAt = new Date(+y, +mo - 1, +d, +h, +mi, +s).getTime();
182
+ }
183
+ apps.push({
184
+ asn: `ASN:${header[2].toLowerCase()}`,
185
+ name: header[1],
186
+ bundleId: bundleMatch?.[1] ?? '',
187
+ pid: parseInt(pidMatch[1], 10),
188
+ launchedAt,
189
+ });
190
+ }
191
+ return apps;
192
+ }
193
+
194
+ export function isTrackingFresh(usage: UsageData | null | undefined, now: number): boolean {
195
+ return !!usage && now - usage.updatedAt < TRACK_FRESH_MS;
196
+ }
197
+
198
+ /**
199
+ * Conservative idle time: the time since the app was last seen frontmost, where
200
+ * "seen" can be no earlier than the start of continuous tracking or the app's launch.
201
+ */
202
+ export function computeIdleMs(app: RunningApp, usage: UsageData | null | undefined, now: number): number | undefined {
203
+ if (app.frontmost) return 0;
204
+ if (!isTrackingFresh(usage, now)) return undefined;
205
+ const last = usage!.apps[app.bundleId]?.lastActive ?? 0;
206
+ const base = Math.max(usage!.trackingSince, app.launchedAt ?? 0);
207
+ return Math.max(0, now - Math.max(last, base));
208
+ }
209
+
210
+ export function isProtected(app: RunningApp, protectedApps: string[]): boolean {
211
+ const list = [...BUILTIN_PROTECTED, ...protectedApps].map(s => s.toLowerCase());
212
+ return list.includes(app.bundleId.toLowerCase()) || list.includes(app.name.toLowerCase());
213
+ }
214
+
215
+ /** Decide which running apps are idle and safe to quit. */
216
+ export function selectIdleApps(apps: RunningApp[], ctx: SelectContext): { candidates: IdleCandidate[]; skipped: SkippedApp[] } {
217
+ const candidates: IdleCandidate[] = [];
218
+ const skipped: SkippedApp[] = [];
219
+ const fresh = isTrackingFresh(ctx.usage, ctx.now);
220
+ const busyPct = ctx.cpuBusyPct ?? DEFAULT_CPU_BUSY_PCT;
221
+
222
+ for (const app of [...apps].sort((a, b) => a.rank - b.rank)) {
223
+ if (app.frontmost) { skipped.push({ app, reason: 'frontmost' }); continue; }
224
+ if (isProtected(app, ctx.protectedApps)) { skipped.push({ app, reason: 'protected' }); continue; }
225
+ if (ctx.selfPids.has(app.pid)) { skipped.push({ app, reason: 'running pyre' }); continue; }
226
+ if (app.rank < ctx.keepRecent) { skipped.push({ app, reason: `top ${ctx.keepRecent} recent` }); continue; }
227
+
228
+ const threshold = MEDIA_BUNDLES.has(app.bundleId.toLowerCase()) ? MEDIA_CPU_BUSY_PCT : busyPct;
229
+ if (app.cpu >= threshold) { skipped.push({ app, reason: `busy (${app.cpu.toFixed(1)}% CPU)` }); continue; }
230
+
231
+ if (app.launchedAt && ctx.now - app.launchedAt < ctx.idleThresholdMs) {
232
+ skipped.push({ app, reason: `launched ${formatDuration(ctx.now - app.launchedAt)} ago` });
233
+ continue;
234
+ }
235
+
236
+ if (fresh) {
237
+ const idleMs = computeIdleMs(app, ctx.usage, ctx.now)!;
238
+ if (idleMs < ctx.idleThresholdMs) { skipped.push({ app, reason: `used ${formatDuration(idleMs)} ago` }); continue; }
239
+ candidates.push({ app, idleMs });
240
+ } else {
241
+ candidates.push({ app });
242
+ }
243
+ }
244
+ return { candidates, skipped };
245
+ }
246
+
247
+ // ─── System helpers ─────────────────────────────────────────────────────────
248
+
249
+ async function run(cmd: string, args: string[], timeoutMs = 8000): Promise<string> {
250
+ try {
251
+ const { stdout } = await execFileAsync(cmd, args, { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 });
252
+ return stdout;
253
+ } catch (err: any) {
254
+ // Many tools (du, plutil) exit non-zero on partial failure but still print useful output.
255
+ return typeof err?.stdout === 'string' ? err.stdout : '';
256
+ }
257
+ }
258
+
259
+ export async function listRunningApps(): Promise<RunningApp[]> {
260
+ const [listRaw, visibleRaw, frontRaw] = await Promise.all([
261
+ run('lsappinfo', ['list']),
262
+ run('lsappinfo', ['visibleProcessList']),
263
+ run('lsappinfo', ['front']),
264
+ ]);
265
+ const base = parseLsappinfoList(listRaw);
266
+ const order = parseAsnList(visibleRaw);
267
+ const front = parseAsnList(frontRaw)[0];
268
+
269
+ const stats = new Map<number, { cpu: number; rss: number }>();
270
+ if (base.length) {
271
+ // One ps call for the whole process table; sum each app's own usage plus its helper/child processes.
272
+ const ps = await run('ps', ['-axo', 'pid=,ppid=,%cpu=,rss=']);
273
+ const children = new Map<number, number[]>();
274
+ const own = new Map<number, { cpu: number; rss: number }>();
275
+ for (const line of ps.split('\n')) {
276
+ const [pid, ppid, cpu, rss] = line.trim().split(/\s+/);
277
+ if (!pid) continue;
278
+ const p = parseInt(pid, 10);
279
+ const pp = parseInt(ppid, 10);
280
+ own.set(p, { cpu: parseFloat(cpu) || 0, rss: parseInt(rss, 10) || 0 });
281
+ if (!children.has(pp)) children.set(pp, []);
282
+ children.get(pp)!.push(p);
283
+ }
284
+ for (const app of base) {
285
+ if (!own.has(app.pid)) continue;
286
+ let cpu = 0;
287
+ let rss = 0;
288
+ const stack = [app.pid];
289
+ const seen = new Set<number>();
290
+ while (stack.length) {
291
+ const p = stack.pop()!;
292
+ if (seen.has(p)) continue;
293
+ seen.add(p);
294
+ const s = own.get(p);
295
+ if (s) { cpu += s.cpu; rss += s.rss; }
296
+ stack.push(...(children.get(p) ?? []));
297
+ }
298
+ stats.set(app.pid, { cpu, rss });
299
+ }
300
+ }
301
+
302
+ // Apps missing from the visible list (e.g. hidden with ⌘H) go to the end, oldest launch last.
303
+ const unranked = base.filter(a => !order.includes(a.asn)).sort((a, b) => (b.launchedAt ?? 0) - (a.launchedAt ?? 0));
304
+ return base
305
+ .filter(a => stats.has(a.pid))
306
+ .map(a => {
307
+ const idx = order.indexOf(a.asn);
308
+ const s = stats.get(a.pid)!;
309
+ return {
310
+ ...a,
311
+ rank: idx >= 0 ? idx : order.length + unranked.indexOf(a),
312
+ cpu: s.cpu,
313
+ rssKb: s.rss,
314
+ frontmost: a.asn === front,
315
+ };
316
+ })
317
+ .sort((a, b) => a.rank - b.rank);
318
+ }
319
+
320
+ /** PIDs of this process and all its ancestors (protects the terminal/editor running pyre). */
321
+ async function getSelfPids(): Promise<Set<number>> {
322
+ const raw = await run('ps', ['-axo', 'pid=,ppid=']);
323
+ const parent = new Map<number, number>();
324
+ for (const line of raw.split('\n')) {
325
+ const [pid, ppid] = line.trim().split(/\s+/).map(n => parseInt(n, 10));
326
+ if (pid) parent.set(pid, ppid);
327
+ }
328
+ const set = new Set<number>();
329
+ let cur: number | undefined = process.pid;
330
+ while (cur && cur > 1 && !set.has(cur)) {
331
+ set.add(cur);
332
+ cur = parent.get(cur);
333
+ }
334
+ return set;
335
+ }
336
+
337
+ export function loadUsage(): UsageData | null {
338
+ try {
339
+ const parsed = JSON.parse(fs.readFileSync(usageFile(), 'utf-8'));
340
+ if (parsed?.version === 1 && parsed.apps) return parsed as UsageData;
341
+ } catch { /* none yet */ }
342
+ return null;
343
+ }
344
+
345
+ function saveUsage(data: UsageData): void {
346
+ try {
347
+ fs.mkdirSync(getConfigDir(), { recursive: true });
348
+ const tmp = usageFile() + '.tmp';
349
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
350
+ fs.renameSync(tmp, usageFile());
351
+ } catch { /* ignore */ }
352
+ }
353
+
354
+ function isAlive(pid: number): boolean {
355
+ try { process.kill(pid, 0); return true; } catch { return false; }
356
+ }
357
+
358
+ const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
359
+
360
+ type QuitResult = 'quit' | 'forced' | 'still-open' | 'failed';
361
+
362
+ /** Ask an app to quit the normal way (same as ⌘Q), optionally SIGTERM if it doesn't. */
363
+ export async function quitApp(app: RunningApp, force = false, timeoutMs = 10_000): Promise<QuitResult> {
364
+ const script = `ObjC.import('AppKit');
365
+ var a = $.NSRunningApplication.runningApplicationWithProcessIdentifier(${Math.trunc(app.pid)});
366
+ a.isNil() ? 'gone' : (a.terminate ? 'ok' : 'refused');`;
367
+ const out = (await run('osascript', ['-l', 'JavaScript', '-e', script], 5000)).trim();
368
+ if (out === 'gone') return 'quit';
369
+
370
+ const deadline = Date.now() + timeoutMs;
371
+ while (Date.now() < deadline) {
372
+ if (!isAlive(app.pid)) return 'quit';
373
+ await sleep(250);
374
+ }
375
+ if (!force) return out === 'ok' || out === 'refused' ? 'still-open' : 'failed';
376
+ try { process.kill(app.pid, 'SIGTERM'); } catch { return isAlive(app.pid) ? 'failed' : 'quit'; }
377
+ await sleep(3000);
378
+ return isAlive(app.pid) ? 'failed' : 'forced';
379
+ }
380
+
381
+ async function ask(question: string): Promise<string> {
382
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
383
+ try {
384
+ return (await rl.question(question)).trim().toLowerCase();
385
+ } finally {
386
+ rl.close();
387
+ }
388
+ }
389
+
390
+ async function confirm(question: string, yes?: boolean): Promise<boolean> {
391
+ if (yes) return true;
392
+ if (!process.stdin.isTTY) return false;
393
+ const a = await ask(` ${question} ${chalk.dim('[y/N]')} `);
394
+ return a === 'y' || a === 'yes';
395
+ }
396
+
397
+ function notify(title: string, message: string): void {
398
+ const esc = (s: string) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
399
+ execFile('osascript', ['-e', `display notification "${esc(message)}" with title "${esc(title)}"`], () => {});
400
+ }
401
+
402
+ function pad(s: string, n: number): string {
403
+ return s.length >= n ? s.slice(0, n - 1) + '…' : s + ' '.repeat(n - s.length);
404
+ }
405
+
406
+ function header(title: string): void {
407
+ console.log(chalk.bold(`\n 🔥 pyre optimize — ${title}\n`));
408
+ }
409
+
410
+ // ─── Idle apps ──────────────────────────────────────────────────────────────
411
+
412
+ interface ResolvedSettings {
413
+ idleMs: number;
414
+ keep: number;
415
+ protectedApps: string[];
416
+ }
417
+
418
+ function resolveSettings(opts: OptimizeOptions): ResolvedSettings {
419
+ const cfg = readConfig();
420
+ const idleMs = parseDuration(opts.idle, parseDuration(cfg.optimizeIdleThreshold, 2 * 3_600_000));
421
+ if (isNaN(idleMs)) {
422
+ console.error(chalk.red(` ✖ Invalid --idle value "${opts.idle}". Use e.g. 30m, 2h, 1d.`));
423
+ process.exit(1);
424
+ }
425
+ const keepRaw = opts.keep !== undefined ? parseInt(opts.keep, 10) : cfg.optimizeKeepRecent;
426
+ const keep = Number.isFinite(keepRaw) && keepRaw >= 0 ? keepRaw : 5;
427
+ return { idleMs, keep, protectedApps: cfg.optimizeProtectedApps ?? [] };
428
+ }
429
+
430
+ async function analyzeApps(settings: ResolvedSettings) {
431
+ const [apps, selfPids] = await Promise.all([listRunningApps(), getSelfPids()]);
432
+ const usage = loadUsage();
433
+ const now = Date.now();
434
+ const result = selectIdleApps(apps, {
435
+ now,
436
+ idleThresholdMs: settings.idleMs,
437
+ keepRecent: settings.keep,
438
+ protectedApps: settings.protectedApps,
439
+ selfPids,
440
+ usage,
441
+ });
442
+ return { apps, usage, now, fresh: isTrackingFresh(usage, now), ...result };
443
+ }
444
+
445
+ function printCandidates(candidates: IdleCandidate[], fresh: boolean): void {
446
+ console.log(chalk.dim(` ${pad('App', 28)}${pad(fresh ? 'Idle' : 'Recency', 12)}${pad('RAM', 10)}CPU`));
447
+ for (const c of candidates) {
448
+ const when = c.idleMs !== undefined ? formatDuration(c.idleMs) : `#${c.app.rank + 1}`;
449
+ console.log(` ${pad(c.app.name, 28)}${chalk.yellow(pad(when, 12))}${pad(formatKb(c.app.rssKb), 10)}${c.app.cpu.toFixed(1)}%`);
450
+ }
451
+ const total = candidates.reduce((s, c) => s + c.app.rssKb, 0);
452
+ console.log(chalk.dim(`\n ${candidates.length} app(s) using ~${formatKb(total)} RAM`));
453
+ }
454
+
455
+ function trackingHint(fresh: boolean): void {
456
+ if (!fresh) {
457
+ console.log(chalk.dim(' ℹ No live usage tracking. Ranking is based on activation order only.'));
458
+ console.log(chalk.dim(' For real idle times run: ') + chalk.cyan('pyre optimize track --install') + '\n');
459
+ }
460
+ }
461
+
462
+ async function runApps(opts: OptimizeOptions): Promise<void> {
463
+ header('Idle Apps');
464
+ const settings = resolveSettings(opts);
465
+ const { candidates, skipped, fresh } = await analyzeApps(settings);
466
+ trackingHint(fresh);
467
+
468
+ console.log(chalk.dim(` Rules: idle ≥ ${formatDuration(settings.idleMs)}, keep ${settings.keep} most recent, skip busy/protected apps\n`));
469
+
470
+ if (skipped.length) {
471
+ console.log(chalk.dim(' Keeping:'));
472
+ for (const s of skipped) console.log(chalk.dim(` ${pad(s.app.name, 28)}${s.reason}`));
473
+ console.log();
474
+ }
475
+
476
+ if (!candidates.length) {
477
+ console.log(chalk.green(' ✔ No idle apps to quit.\n'));
478
+ return;
479
+ }
480
+
481
+ console.log(chalk.bold(' Idle apps:'));
482
+ printCandidates(candidates, fresh);
483
+ console.log();
484
+
485
+ if (opts.dryRun) {
486
+ console.log(chalk.dim(' Dry run: nothing was quit.\n'));
487
+ return;
488
+ }
489
+
490
+ let selected: IdleCandidate[] = [];
491
+ if (opts.yes) {
492
+ selected = candidates;
493
+ } else if (!process.stdin.isTTY) {
494
+ console.log(chalk.yellow(' Not an interactive terminal. Re-run with --yes to quit these apps.\n'));
495
+ return;
496
+ } else {
497
+ const a = await ask(` Quit these apps? ${chalk.dim('[a]ll / [s]elect / [N]one')} `);
498
+ if (a === 'a' || a === 'all' || a === 'y' || a === 'yes') {
499
+ selected = candidates;
500
+ } else if (a === 's' || a === 'select') {
501
+ for (const c of candidates) {
502
+ if (await confirm(`Quit ${chalk.bold(c.app.name)} (${formatKb(c.app.rssKb)})?`)) selected.push(c);
503
+ }
504
+ }
505
+ }
506
+
507
+ if (!selected.length) {
508
+ console.log(chalk.dim(' Nothing quit.\n'));
509
+ return;
510
+ }
511
+
512
+ console.log();
513
+ let freed = 0;
514
+ for (const c of selected) {
515
+ process.stdout.write(` Quitting ${c.app.name}… `);
516
+ const res = await quitApp(c.app, !!opts.force);
517
+ if (res === 'quit' || res === 'forced') {
518
+ freed += c.app.rssKb;
519
+ console.log(res === 'forced' ? chalk.yellow('force-quit') : chalk.green('✔'));
520
+ } else if (res === 'still-open') {
521
+ console.log(chalk.yellow('still open (it may be waiting for you to save; use --force to terminate)'));
522
+ } else {
523
+ console.log(chalk.red('failed'));
524
+ }
525
+ }
526
+ console.log(chalk.green(`\n ✔ Freed ~${formatKb(freed)} RAM\n`));
527
+ }
528
+
529
+ // ─── Tracker ────────────────────────────────────────────────────────────────
530
+
531
+ async function frontApp(): Promise<{ bundleId: string; name: string } | null> {
532
+ const asn = parseAsnList(await run('lsappinfo', ['front'], 3000))[0];
533
+ if (!asn) return null;
534
+ const info = await run('lsappinfo', ['info', '-only', 'bundleid', '-only', 'name', asn], 3000);
535
+ const bundleId = info.match(/"CFBundleIdentifier"="([^"]+)"/)?.[1];
536
+ const name = info.match(/"LSDisplayName"="([^"]+)"/)?.[1] ?? bundleId ?? '';
537
+ return bundleId ? { bundleId, name } : null;
538
+ }
539
+
540
+ async function runTracker(opts: OptimizeOptions): Promise<void> {
541
+ if (opts.install) return installTracker(opts);
542
+ if (opts.uninstall) return uninstallTracker();
543
+
544
+ const autoQuitMs = opts.autoQuit ? parseDuration(opts.autoQuit, NaN) : undefined;
545
+ if (autoQuitMs !== undefined && (isNaN(autoQuitMs) || autoQuitMs < 60_000)) {
546
+ console.error(chalk.red(` ✖ Invalid --auto-quit value "${opts.autoQuit}". Use e.g. 2h (minimum 1m).`));
547
+ process.exit(1);
548
+ }
549
+
550
+ const now = Date.now();
551
+ let usage = loadUsage();
552
+ if (!usage) {
553
+ usage = { version: 1, trackingSince: now, updatedAt: now, apps: {} };
554
+ } else if (now - usage.updatedAt > TRACK_FRESH_MS) {
555
+ // Gap in tracking: we can't vouch for anything before now.
556
+ usage.trackingSince = now;
557
+ }
558
+ usage.updatedAt = now;
559
+ saveUsage(usage);
560
+
561
+ console.log(chalk.bold('\n 🔥 pyre optimize — usage tracker'));
562
+ console.log(chalk.dim(` Recording frontmost app every ${TRACK_INTERVAL_MS / 1000}s → ${usageFile()}`));
563
+ if (autoQuitMs) console.log(chalk.yellow(` Auto-quit enabled: apps idle ≥ ${formatDuration(autoQuitMs)} will be asked to quit`));
564
+ console.log(chalk.dim(' Press Ctrl+C to stop.\n'));
565
+
566
+ let lastFront = '';
567
+ let lastSave = 0;
568
+ let lastAutoQuit = 0;
569
+ const attempted = new Set<number>();
570
+ const settings = resolveSettings({ ...opts, idle: opts.autoQuit });
571
+
572
+ const stop = () => { saveUsage(usage!); process.exit(0); };
573
+ process.on('SIGINT', stop);
574
+ process.on('SIGTERM', stop);
575
+
576
+ for (;;) {
577
+ const t = Date.now();
578
+ const front = await frontApp();
579
+ if (front) {
580
+ usage.apps[front.bundleId] = { name: front.name, lastActive: t };
581
+ }
582
+ usage.updatedAt = t;
583
+ const frontKey = front?.bundleId ?? '';
584
+ if (frontKey !== lastFront || t - lastSave > 30_000) {
585
+ saveUsage(usage);
586
+ lastSave = t;
587
+ if (frontKey !== lastFront && process.stdout.isTTY && front) {
588
+ console.log(chalk.dim(` ${new Date(t).toLocaleTimeString()} `) + front.name);
589
+ }
590
+ lastFront = frontKey;
591
+ }
592
+
593
+ if (autoQuitMs && t - lastAutoQuit > 60_000) {
594
+ lastAutoQuit = t;
595
+ try {
596
+ const { candidates } = await analyzeApps(settings);
597
+ const quitNames: string[] = [];
598
+ for (const c of candidates) {
599
+ if (c.idleMs === undefined || attempted.has(c.app.pid)) continue;
600
+ attempted.add(c.app.pid);
601
+ const res = await quitApp(c.app, false, 8000);
602
+ if (res === 'quit') quitNames.push(c.app.name);
603
+ console.log(` ${new Date().toLocaleTimeString()} auto-quit ${c.app.name} (idle ${formatDuration(c.idleMs)}): ${res}`);
604
+ }
605
+ if (quitNames.length) {
606
+ notify('pyre optimize', `Quit ${quitNames.length} idle app(s): ${quitNames.join(', ')}`);
607
+ }
608
+ } catch { /* keep tracking */ }
609
+ }
610
+
611
+ await sleep(TRACK_INTERVAL_MS);
612
+ }
613
+ }
614
+
615
+ function installTracker(opts: OptimizeOptions): void {
616
+ const xml = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
617
+ const args = [process.execPath, path.resolve(process.argv[1]), 'optimize', 'track', '--no-wizard'];
618
+ if (opts.autoQuit) {
619
+ if (isNaN(parseDuration(opts.autoQuit, NaN))) {
620
+ console.error(chalk.red(` ✖ Invalid --auto-quit value "${opts.autoQuit}".`));
621
+ process.exit(1);
622
+ }
623
+ args.push('--auto-quit', opts.autoQuit);
624
+ }
625
+ const logDir = getConfigDir();
626
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
627
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
628
+ <plist version="1.0">
629
+ <dict>
630
+ <key>Label</key>
631
+ <string>${TRACKER_LABEL}</string>
632
+ <key>ProgramArguments</key>
633
+ <array>
634
+ ${args.map(a => ` <string>${xml(a)}</string>`).join('\n')}
635
+ </array>
636
+ <key>RunAtLoad</key>
637
+ <true/>
638
+ <key>KeepAlive</key>
639
+ <true/>
640
+ <key>ProcessType</key>
641
+ <string>Background</string>
642
+ <key>StandardOutPath</key>
643
+ <string>${xml(path.join(logDir, 'optimize-tracker.log'))}</string>
644
+ <key>StandardErrorPath</key>
645
+ <string>${xml(path.join(logDir, 'optimize-tracker.err'))}</string>
646
+ </dict>
647
+ </plist>
648
+ `;
649
+ try {
650
+ fs.mkdirSync(path.dirname(TRACKER_PLIST), { recursive: true });
651
+ fs.mkdirSync(logDir, { recursive: true });
652
+ fs.writeFileSync(TRACKER_PLIST, plist);
653
+ spawnSyncQuiet('launchctl', ['unload', TRACKER_PLIST]);
654
+ spawnSyncQuiet('launchctl', ['load', TRACKER_PLIST]);
655
+ console.log(chalk.green(`\n ✔ Usage tracker installed and started (${TRACKER_LABEL})`));
656
+ if (opts.autoQuit) console.log(chalk.yellow(` Auto-quit: apps idle ≥ ${opts.autoQuit} will be asked to quit`));
657
+ console.log(chalk.dim(` Plist: ${TRACKER_PLIST}`));
658
+ console.log(chalk.dim(' Remove with: pyre optimize track --uninstall\n'));
659
+ } catch (err: any) {
660
+ console.log(chalk.red(` ✖ Failed to install tracker: ${err.message}`));
661
+ }
662
+ }
663
+
664
+ function uninstallTracker(): void {
665
+ if (!fs.existsSync(TRACKER_PLIST)) {
666
+ console.log(chalk.yellow(`\n No tracker installed at ${TRACKER_PLIST}\n`));
667
+ return;
668
+ }
669
+ spawnSyncQuiet('launchctl', ['unload', TRACKER_PLIST]);
670
+ fs.unlinkSync(TRACKER_PLIST);
671
+ console.log(chalk.green(`\n ✔ Usage tracker uninstalled\n`));
672
+ }
673
+
674
+ function spawnSyncQuiet(cmd: string, args: string[]): void {
675
+ try {
676
+ spawnSync(cmd, args, { stdio: 'ignore' });
677
+ } catch { /* ignore */ }
678
+ }
679
+
680
+ // ─── Memory ─────────────────────────────────────────────────────────────────
681
+
682
+ interface MemoryInfo {
683
+ totalBytes: number;
684
+ freePct?: number;
685
+ swapUsedMb?: number;
686
+ swapTotalMb?: number;
687
+ compressedBytes?: number;
688
+ top: { pid: number; name: string; rssKb: number }[];
689
+ }
690
+
691
+ async function collectMemory(): Promise<MemoryInfo> {
692
+ const [pressure, swap, vm, ps] = await Promise.all([
693
+ run('memory_pressure', ['-Q'], 5000),
694
+ run('sysctl', ['-n', 'vm.swapusage']),
695
+ run('vm_stat', []),
696
+ run('ps', ['-axo', 'rss=,pid=,comm=']),
697
+ ]);
698
+ const freePct = pressure.match(/free percentage:\s*(\d+)%/i)?.[1];
699
+ const swapTotal = swap.match(/total = ([\d.]+)M/)?.[1];
700
+ const swapUsed = swap.match(/used = ([\d.]+)M/)?.[1];
701
+ const pageSize = parseInt(vm.match(/page size of (\d+) bytes/)?.[1] ?? '16384', 10);
702
+ const compressedPages = vm.match(/Pages occupied by compressor:\s+(\d+)/)?.[1];
703
+
704
+ const top = ps.split('\n')
705
+ .map(l => l.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/))
706
+ .filter((m): m is RegExpMatchArray => !!m)
707
+ .map(m => ({ rssKb: parseInt(m[1], 10), pid: parseInt(m[2], 10), name: path.basename(m[3]) }))
708
+ .sort((a, b) => b.rssKb - a.rssKb)
709
+ .slice(0, 10);
710
+
711
+ return {
712
+ totalBytes: os.totalmem(),
713
+ freePct: freePct ? parseInt(freePct, 10) : undefined,
714
+ swapUsedMb: swapUsed ? parseFloat(swapUsed) : undefined,
715
+ swapTotalMb: swapTotal ? parseFloat(swapTotal) : undefined,
716
+ compressedBytes: compressedPages ? parseInt(compressedPages, 10) * pageSize : undefined,
717
+ top,
718
+ };
719
+ }
720
+
721
+ function pressureLabel(freePct?: number): string {
722
+ if (freePct === undefined) return chalk.dim('unknown');
723
+ if (freePct >= 50) return chalk.green(`normal (${freePct}% free)`);
724
+ if (freePct >= 25) return chalk.yellow(`elevated (${freePct}% free)`);
725
+ return chalk.red(`high (${freePct}% free)`);
726
+ }
727
+
728
+ function printMemorySummary(m: MemoryInfo): void {
729
+ console.log(` Memory pressure: ${pressureLabel(m.freePct)}`);
730
+ if (m.swapUsedMb !== undefined) {
731
+ const c = m.swapUsedMb > 2048 ? chalk.yellow : chalk.reset;
732
+ console.log(` Swap used: ${c(formatKb(m.swapUsedMb * 1024))}${m.swapTotalMb ? chalk.dim(` / ${formatKb(m.swapTotalMb * 1024)}`) : ''}`);
733
+ }
734
+ if (m.compressedBytes !== undefined) console.log(` Compressed: ${formatKb(m.compressedBytes / 1024)}`);
735
+ }
736
+
737
+ async function runMemory(opts: OptimizeOptions): Promise<void> {
738
+ header('Memory');
739
+ const m = await collectMemory();
740
+ console.log(` Installed RAM: ${formatKb(m.totalBytes / 1024)}`);
741
+ printMemorySummary(m);
742
+ console.log(chalk.bold('\n Top memory users:'));
743
+ for (const p of m.top) console.log(` ${pad(p.name, 32)}${pad(formatKb(p.rssKb), 10)}${chalk.dim(`pid ${p.pid}`)}`);
744
+ console.log();
745
+
746
+ if (opts.dryRun) return;
747
+ if (await confirm('Run `sudo purge` to flush inactive file cache? (asks for your password)', opts.yes)) {
748
+ await runInherited('sudo', ['purge']);
749
+ const after = await collectMemory();
750
+ console.log(`\n After purge: ${pressureLabel(after.freePct)}\n`);
751
+ }
752
+ }
753
+
754
+ function runInherited(cmd: string, args: string[]): Promise<number> {
755
+ return new Promise(resolve => {
756
+ const p = spawn(cmd, args, { stdio: 'inherit' });
757
+ p.on('close', code => resolve(code ?? 1));
758
+ p.on('error', () => resolve(1));
759
+ });
760
+ }
761
+
762
+ // ─── Caches ─────────────────────────────────────────────────────────────────
763
+
764
+ interface CacheCategory {
765
+ name: string;
766
+ dir: string;
767
+ /** Only delete these children (absolute paths); default = all children of dir */
768
+ children?: string[];
769
+ sizeKb: number;
770
+ note?: string;
771
+ }
772
+
773
+ async function duKb(paths: string[]): Promise<Map<string, number>> {
774
+ const out = new Map<string, number>();
775
+ const existing = paths.filter(p => fs.existsSync(p));
776
+ if (!existing.length) return out;
777
+ const raw = await run('du', ['-sk', ...existing], 120_000);
778
+ for (const line of raw.split('\n')) {
779
+ const m = line.match(/^(\d+)\s+(.+)$/);
780
+ if (m) out.set(m[2], parseInt(m[1], 10));
781
+ }
782
+ return out;
783
+ }
784
+
785
+ async function collectCaches(): Promise<CacheCategory[]> {
786
+ const home = os.homedir();
787
+ const libCaches = path.join(home, 'Library', 'Caches');
788
+ const known: Omit<CacheCategory, 'sizeKb'>[] = [
789
+ { name: 'Xcode DerivedData', dir: path.join(home, 'Library', 'Developer', 'Xcode', 'DerivedData') },
790
+ { name: 'Homebrew downloads', dir: path.join(libCaches, 'Homebrew') },
791
+ { name: 'npm cache', dir: path.join(home, '.npm', '_cacache') },
792
+ { name: 'Yarn cache', dir: path.join(libCaches, 'Yarn') },
793
+ { name: 'pnpm store', dir: path.join(home, 'Library', 'pnpm', 'store') },
794
+ { name: 'pip cache', dir: path.join(libCaches, 'pip') },
795
+ ];
796
+ const knownDirs = new Set(known.map(k => k.dir));
797
+
798
+ // Other per-app caches: skip Apple system caches and caches of currently running apps.
799
+ const running = new Set((await listRunningApps()).map(a => a.bundleId.toLowerCase()).filter(Boolean));
800
+ let otherChildren: string[] = [];
801
+ let skippedRunning = 0;
802
+ try {
803
+ otherChildren = fs.readdirSync(libCaches)
804
+ .map(n => path.join(libCaches, n))
805
+ .filter(p => {
806
+ const n = path.basename(p).toLowerCase();
807
+ if (knownDirs.has(p) || n.startsWith('com.apple.') || n.startsWith('.')) return false;
808
+ if (running.has(n)) { skippedRunning++; return false; }
809
+ return true;
810
+ });
811
+ } catch { /* unreadable */ }
812
+
813
+ const sizes = await duKb([...known.map(k => k.dir), ...otherChildren]);
814
+ const cats: CacheCategory[] = known.map(k => ({ ...k, sizeKb: sizes.get(k.dir) ?? 0 }));
815
+ cats.push({
816
+ name: 'Other app caches',
817
+ dir: libCaches,
818
+ children: otherChildren,
819
+ sizeKb: otherChildren.reduce((s, c) => s + (sizes.get(c) ?? 0), 0),
820
+ note: skippedRunning ? `${skippedRunning} cache(s) of running apps skipped` : undefined,
821
+ });
822
+ return cats;
823
+ }
824
+
825
+ function removeContents(cat: CacheCategory): number {
826
+ let failed = 0;
827
+ let targets: string[] = cat.children ?? [];
828
+ if (!cat.children) {
829
+ try { targets = fs.readdirSync(cat.dir).map(n => path.join(cat.dir, n)); } catch { return 1; }
830
+ }
831
+ for (const t of targets) {
832
+ try { fs.rmSync(t, { recursive: true, force: true }); } catch { failed++; }
833
+ }
834
+ return failed;
835
+ }
836
+
837
+ async function runCaches(opts: OptimizeOptions): Promise<void> {
838
+ header('Caches');
839
+ process.stdout.write(chalk.dim(' Measuring cache sizes…\r'));
840
+ const cats = await collectCaches();
841
+ const total = cats.reduce((s, c) => s + c.sizeKb, 0);
842
+ for (const c of cats) {
843
+ const size = c.sizeKb > 1024 * 1024 ? chalk.yellow(formatKb(c.sizeKb)) : formatKb(c.sizeKb);
844
+ console.log(` ${pad(c.name, 24)}${pad(size, 12)}${chalk.dim(c.note ?? '')}`);
845
+ }
846
+ console.log(chalk.bold(`\n Total: ${formatKb(total)}\n`));
847
+
848
+ if (!opts.clean) {
849
+ console.log(chalk.dim(' To delete caches: ') + chalk.cyan('pyre optimize caches --clean') + '\n');
850
+ return;
851
+ }
852
+ if (opts.dryRun) { console.log(chalk.dim(' Dry run: nothing was deleted.\n')); return; }
853
+ if (!opts.yes && !process.stdin.isTTY) {
854
+ console.log(chalk.yellow(' Not an interactive terminal. Re-run with --yes to delete.\n'));
855
+ return;
856
+ }
857
+
858
+ console.log(chalk.dim(' Tip: quit the related apps first. Caches are rebuilt automatically when needed.\n'));
859
+ let freed = 0;
860
+ for (const c of cats) {
861
+ if (c.sizeKb < 1024) continue;
862
+ if (!(await confirm(`Delete ${c.name} (${formatKb(c.sizeKb)})?`, opts.yes))) continue;
863
+ const failed = removeContents(c);
864
+ const after = c.children ? 0 : (await duKb([c.dir])).get(c.dir) ?? 0;
865
+ freed += Math.max(0, c.sizeKb - after);
866
+ console.log(failed ? chalk.yellow(` ✔ cleaned (${failed} item(s) could not be removed)`) : chalk.green(' ✔ cleaned'));
867
+ }
868
+ console.log(chalk.green(`\n ✔ Freed ~${formatKb(freed)}\n`));
869
+ }
870
+
871
+ // ─── Trash ──────────────────────────────────────────────────────────────────
872
+
873
+ async function trashSizeKb(): Promise<number | undefined> {
874
+ const trash = path.join(os.homedir(), '.Trash');
875
+ try { fs.readdirSync(trash); } catch { return undefined; }
876
+ return (await duKb([trash])).get(trash) ?? 0;
877
+ }
878
+
879
+ async function runTrash(opts: OptimizeOptions): Promise<void> {
880
+ header('Trash');
881
+ const size = await trashSizeKb();
882
+ console.log(` Trash size: ${size === undefined ? chalk.dim('unknown (grant your terminal Full Disk Access to measure)') : formatKb(size)}\n`);
883
+ if (!opts.empty) {
884
+ console.log(chalk.dim(' To empty: ') + chalk.cyan('pyre optimize trash --empty') + '\n');
885
+ return;
886
+ }
887
+ if (size === 0) { console.log(chalk.green(' ✔ Trash is already empty.\n')); return; }
888
+ if (opts.dryRun) { console.log(chalk.dim(' Dry run: nothing was deleted.\n')); return; }
889
+ if (!(await confirm('Permanently delete everything in the Trash?', opts.yes))) return;
890
+
891
+ const trash = path.join(os.homedir(), '.Trash');
892
+ let ok = false;
893
+ if (size !== undefined) {
894
+ let failed = 0;
895
+ for (const n of fs.readdirSync(trash)) {
896
+ try { fs.rmSync(path.join(trash, n), { recursive: true, force: true }); } catch { failed++; }
897
+ }
898
+ ok = failed === 0;
899
+ }
900
+ if (!ok) {
901
+ // Fall back to Finder (handles locked items / no Full Disk Access).
902
+ ok = (await runInherited('osascript', ['-e', 'tell application "Finder" to empty trash'])) === 0;
903
+ }
904
+ console.log(ok ? chalk.green('\n ✔ Trash emptied\n') : chalk.red('\n ✖ Could not empty the Trash\n'));
905
+ }
906
+
907
+ // ─── Startup items ──────────────────────────────────────────────────────────
908
+
909
+ interface LaunchItem {
910
+ label: string;
911
+ file: string;
912
+ scope: string;
913
+ program?: string;
914
+ missing: boolean;
915
+ disabled: boolean;
916
+ loaded: boolean;
917
+ }
918
+
919
+ async function collectLaunchItems(): Promise<LaunchItem[]> {
920
+ const dirs: [string, string][] = [
921
+ [path.join(os.homedir(), 'Library', 'LaunchAgents'), 'user agent'],
922
+ ['/Library/LaunchAgents', 'global agent'],
923
+ ['/Library/LaunchDaemons', 'daemon'],
924
+ ];
925
+ const loadedRaw = await run('launchctl', ['list']);
926
+ const loaded = new Set(loadedRaw.split('\n').slice(1).map(l => l.trim().split(/\s+/)[2]).filter(Boolean));
927
+
928
+ const jobs: Promise<LaunchItem | null>[] = [];
929
+ for (const [dir, scope] of dirs) {
930
+ let files: string[] = [];
931
+ try { files = fs.readdirSync(dir).filter(f => f.endsWith('.plist')); } catch { continue; }
932
+ for (const f of files) {
933
+ const file = path.join(dir, f);
934
+ jobs.push((async () => {
935
+ const raw = await run('plutil', ['-convert', 'json', '-o', '-', file], 3000);
936
+ let j: any = {};
937
+ try { j = JSON.parse(raw); } catch { /* binary/unreadable */ }
938
+ const label = j.Label ?? f.replace(/\.plist$/, '');
939
+ const program: string | undefined = j.Program ?? (Array.isArray(j.ProgramArguments) ? j.ProgramArguments[0] : undefined);
940
+ const missing = !!program && path.isAbsolute(program) && !fs.existsSync(program);
941
+ return { label, file, scope, program, missing, disabled: j.Disabled === true, loaded: loaded.has(label) };
942
+ })());
943
+ }
944
+ }
945
+ return (await Promise.all(jobs)).filter((x): x is LaunchItem => !!x);
946
+ }
947
+
948
+ async function runStartup(): Promise<void> {
949
+ header('Startup Items');
950
+ const loginRaw = await run('osascript', ['-e', 'tell application "System Events" to get the name of every login item'], 8000);
951
+ const loginItems = loginRaw.trim() ? loginRaw.trim().split(/,\s*/) : [];
952
+ console.log(chalk.bold(' Login items:'));
953
+ if (loginItems.length) for (const li of loginItems) console.log(` • ${li}`);
954
+ else console.log(chalk.dim(' none found (or System Events access was denied)'));
955
+
956
+ const items = await collectLaunchItems();
957
+ console.log(chalk.bold(`\n Launch agents & daemons (${items.length}):`));
958
+ console.log(chalk.dim(` ${pad('Label', 52)}${pad('Scope', 14)}Status`));
959
+ for (const it of items.sort((a, b) => a.scope.localeCompare(b.scope) || a.label.localeCompare(b.label))) {
960
+ const status = it.missing ? chalk.red('broken: program missing')
961
+ : it.disabled ? chalk.dim('disabled')
962
+ : it.loaded ? chalk.green('loaded')
963
+ : chalk.dim('not loaded');
964
+ console.log(` ${pad(it.label, 52)}${pad(it.scope, 14)}${status}`);
965
+ }
966
+ const broken = items.filter(i => i.missing);
967
+ if (broken.length) {
968
+ console.log(chalk.yellow(`\n ⚠ ${broken.length} item(s) point to programs that no longer exist (leftovers from uninstalled apps):`));
969
+ for (const b of broken) console.log(chalk.dim(` ${b.file}`));
970
+ console.log(chalk.dim(' Review and delete these plist files manually if you no longer need them.'));
971
+ }
972
+ console.log(chalk.dim('\n Manage login items in System Settings → General → Login Items.\n'));
973
+ }
974
+
975
+ // ─── DNS ────────────────────────────────────────────────────────────────────
976
+
977
+ async function runDns(opts: OptimizeOptions): Promise<void> {
978
+ header('DNS Cache');
979
+ if (opts.dryRun) { console.log(chalk.dim(' Dry run: would run `sudo dscacheutil -flushcache` and `sudo killall -HUP mDNSResponder`.\n')); return; }
980
+ if (!(await confirm('Flush the DNS cache? (asks for your password)', opts.yes))) return;
981
+ const a = await runInherited('sudo', ['dscacheutil', '-flushcache']);
982
+ const b = a === 0 ? await runInherited('sudo', ['killall', '-HUP', 'mDNSResponder']) : 1;
983
+ console.log(a === 0 && b === 0 ? chalk.green('\n ✔ DNS cache flushed\n') : chalk.red('\n ✖ Failed to flush DNS cache\n'));
984
+ }
985
+
986
+ // ─── Protect list ───────────────────────────────────────────────────────────
987
+
988
+ function runProtect(add: boolean, names: string[]): void {
989
+ const cfg = readConfig();
990
+ const list = new Set(cfg.optimizeProtectedApps ?? []);
991
+ if (!names.length) {
992
+ console.log(chalk.bold('\n Protected apps (never quit):\n'));
993
+ for (const b of BUILTIN_PROTECTED) console.log(chalk.dim(` ${b} (built-in)`));
994
+ for (const p of list) console.log(` ${p}`);
995
+ console.log(chalk.dim('\n Add with: pyre optimize protect "<App Name or bundle id>"\n'));
996
+ return;
997
+ }
998
+ for (const n of names) {
999
+ if (add) list.add(n);
1000
+ else {
1001
+ for (const existing of [...list]) if (existing.toLowerCase() === n.toLowerCase()) list.delete(existing);
1002
+ if (BUILTIN_PROTECTED.some(b => b.toLowerCase() === n.toLowerCase())) {
1003
+ console.log(chalk.yellow(` ⚠ ${n} is built-in protected and can't be removed.`));
1004
+ }
1005
+ }
1006
+ }
1007
+ writeConfig({ optimizeProtectedApps: [...list] });
1008
+ console.log(chalk.green(` ✔ ${add ? 'Protected' : 'Unprotected'}: ${names.join(', ')}`));
1009
+ }
1010
+
1011
+ // ─── Overview report ────────────────────────────────────────────────────────
1012
+
1013
+ async function runOverview(opts: OptimizeOptions): Promise<void> {
1014
+ header('Overview');
1015
+ const settings = resolveSettings(opts);
1016
+ const [analysis, mem, trash, launch] = await Promise.all([
1017
+ analyzeApps(settings),
1018
+ collectMemory(),
1019
+ trashSizeKb(),
1020
+ collectLaunchItems(),
1021
+ ]);
1022
+ const { candidates, fresh, apps } = analysis;
1023
+
1024
+ console.log(chalk.bold(' Apps'));
1025
+ console.log(` Open apps: ${apps.length}`);
1026
+ console.log(` Usage tracker: ${fresh ? chalk.green('running') : fs.existsSync(TRACKER_PLIST) ? chalk.yellow('installed, not reporting') : chalk.dim('off')}`);
1027
+ if (candidates.length) {
1028
+ const ram = candidates.reduce((s, c) => s + c.app.rssKb, 0);
1029
+ console.log(` Idle apps: ${chalk.yellow(String(candidates.length))} ${chalk.dim(`(~${formatKb(ram)} RAM)`)}: ${candidates.map(c => c.app.name).join(', ')}`);
1030
+ } else {
1031
+ console.log(` Idle apps: ${chalk.green('none')}`);
1032
+ }
1033
+
1034
+ console.log(chalk.bold('\n Memory'));
1035
+ printMemorySummary(mem);
1036
+
1037
+ console.log(chalk.bold('\n Storage'));
1038
+ console.log(` Trash: ${trash === undefined ? chalk.dim('unknown') : formatKb(trash)}`);
1039
+ console.log(chalk.dim(' Caches: run `pyre optimize caches` to measure'));
1040
+
1041
+ const broken = launch.filter(i => i.missing).length;
1042
+ console.log(chalk.bold('\n Startup'));
1043
+ console.log(` Launch items: ${launch.length}${broken ? chalk.yellow(` (${broken} broken)`) : ''}`);
1044
+
1045
+ const tips: string[] = [];
1046
+ if (candidates.length) tips.push(`${chalk.cyan('pyre optimize apps')} quit ${candidates.length} idle app(s)`);
1047
+ if (!fresh) tips.push(`${chalk.cyan('pyre optimize track --install')} track real app idle times`);
1048
+ if (mem.freePct !== undefined && mem.freePct < 25) tips.push(`${chalk.cyan('pyre optimize memory')} memory pressure is high`);
1049
+ if (trash !== undefined && trash > 1024 * 1024) tips.push(`${chalk.cyan('pyre optimize trash --empty')} free ${formatKb(trash)}`);
1050
+ if (broken) tips.push(`${chalk.cyan('pyre optimize startup')} review ${broken} broken launch item(s)`);
1051
+ tips.push(`${chalk.cyan('pyre optimize caches')} see reclaimable cache space`);
1052
+
1053
+ console.log(chalk.bold('\n Suggestions'));
1054
+ for (const t of tips) console.log(` • ${t}`);
1055
+ console.log();
1056
+ }
1057
+
1058
+ // ─── Entry point ────────────────────────────────────────────────────────────
1059
+
1060
+ const USAGE = `
1061
+ Usage: pyre optimize [subcommand] [options]
1062
+
1063
+ Subcommands:
1064
+ (none) Overview report with suggestions (changes nothing)
1065
+ apps Quit apps that are open but haven't been used recently
1066
+ track Record app usage for real idle times (--install / --uninstall / --auto-quit <dur>)
1067
+ memory Memory pressure, swap, top RAM users, optional purge
1068
+ caches Measure caches (--clean to delete after confirmation)
1069
+ trash Trash size (--empty to empty after confirmation)
1070
+ startup Login items and launch agents, flags broken leftovers
1071
+ dns Flush the DNS cache
1072
+ protect [app] List or add apps that are never quit
1073
+ unprotect <app> Remove an app from the protected list
1074
+
1075
+ Options:
1076
+ --idle <dur> Idle threshold, e.g. 30m, 2h (default from config: 2h)
1077
+ --keep <n> Always keep the N most recently used apps (default 5)
1078
+ --dry-run Show what would happen without changing anything
1079
+ -y, --yes Don't ask for confirmation
1080
+ --force Terminate apps that don't quit within 10s
1081
+ `;
1082
+
1083
+ async function runWindowsDns(): Promise<void> {
1084
+ console.log(chalk.bold('\n pyre optimize dns — Flush DNS Resolver Cache\n'));
1085
+ try {
1086
+ const { execSync } = await import('node:child_process');
1087
+ execSync('ipconfig /flushdns', { stdio: 'inherit' });
1088
+ console.log(chalk.green('\n ✔ Windows DNS Resolver Cache flushed successfully.\n'));
1089
+ } catch (err: any) {
1090
+ console.log(chalk.red(`\n ✖ Failed to flush DNS cache: ${err.message}\n`));
1091
+ }
1092
+ }
1093
+
1094
+ async function runWindowsTrash(opts: OptimizeOptions): Promise<void> {
1095
+ console.log(chalk.bold('\n pyre optimize trash — Windows Recycle Bin\n'));
1096
+ if (opts.empty || opts.yes) {
1097
+ try {
1098
+ const { runPowerShell } = await import('./monitors/run.js');
1099
+ await runPowerShell('Clear-RecycleBin -Force -ErrorAction SilentlyContinue', '');
1100
+ console.log(chalk.green(' ✔ Emptied Recycle Bin.\n'));
1101
+ } catch (err: any) {
1102
+ console.log(chalk.red(` ✖ Could not empty Recycle Bin: ${err.message}\n`));
1103
+ }
1104
+ } else {
1105
+ console.log(chalk.cyan(' Pass --empty or -y to empty the Recycle Bin.\n'));
1106
+ }
1107
+ }
1108
+
1109
+ async function runWindowsCaches(opts: OptimizeOptions): Promise<void> {
1110
+ console.log(chalk.bold('\n pyre optimize caches — Windows Temporary Files\n'));
1111
+ const tempDir = os.tmpdir();
1112
+ let totalBytes = 0;
1113
+ let fileCount = 0;
1114
+ try {
1115
+ if (fs.existsSync(tempDir)) {
1116
+ const files = fs.readdirSync(tempDir);
1117
+ for (const f of files) {
1118
+ try {
1119
+ const stat = fs.statSync(path.join(tempDir, f));
1120
+ if (stat.isFile()) {
1121
+ totalBytes += stat.size;
1122
+ fileCount++;
1123
+ }
1124
+ } catch {}
1125
+ }
1126
+ }
1127
+ } catch {}
1128
+
1129
+ const mb = Math.round((totalBytes / (1024 * 1024)) * 10) / 10;
1130
+ console.log(` Temporary files: ${chalk.bold(String(fileCount))} files (${chalk.yellow(`${mb} MB`)}) in ${chalk.dim(tempDir)}`);
1131
+
1132
+ if (opts.clean || opts.yes) {
1133
+ let deleted = 0;
1134
+ try {
1135
+ const files = fs.readdirSync(tempDir);
1136
+ for (const f of files) {
1137
+ try {
1138
+ const full = path.join(tempDir, f);
1139
+ const stat = fs.statSync(full);
1140
+ if (stat.isFile()) {
1141
+ fs.unlinkSync(full);
1142
+ deleted++;
1143
+ }
1144
+ } catch {}
1145
+ }
1146
+ } catch {}
1147
+ console.log(chalk.green(` ✔ Cleaned ${deleted} temporary cache files.\n`));
1148
+ } else {
1149
+ console.log(chalk.dim(' Run with --clean or -y to remove temporary cache files.\n'));
1150
+ }
1151
+ }
1152
+
1153
+ async function runWindowsStartup(): Promise<void> {
1154
+ console.log(chalk.bold('\n pyre optimize startup — Windows Startup Applications\n'));
1155
+ try {
1156
+ const { runPowerShell } = await import('./monitors/run.js');
1157
+ const raw = await runPowerShell(
1158
+ `$hkcu = Get-ItemProperty 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' -ErrorAction SilentlyContinue; $hklm = Get-ItemProperty 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' -ErrorAction SilentlyContinue; if ($hkcu) { $hkcu.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | ForEach-Object { "User|$($_.Name)|$($_.Value)" } }; if ($hklm) { $hklm.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | ForEach-Object { "System|$($_.Name)|$($_.Value)" } }`,
1159
+ ''
1160
+ );
1161
+ const lines = raw.split('\n').filter(l => l.trim().length > 0);
1162
+ if (lines.length === 0) {
1163
+ console.log(chalk.dim(' No registry startup entries found.\n'));
1164
+ return;
1165
+ }
1166
+ for (const l of lines) {
1167
+ const [scope, name, val] = l.split('|');
1168
+ console.log(` • ${chalk.bold(name)} [${chalk.cyan(scope)}]`);
1169
+ console.log(` ${chalk.dim(val)}`);
1170
+ }
1171
+ console.log();
1172
+ } catch {
1173
+ console.log(chalk.dim(' Could not query startup entries.\n'));
1174
+ }
1175
+ }
1176
+
1177
+ export async function runOptimizeCommand(sub: string | undefined, args: string[], opts: OptimizeOptions): Promise<void> {
1178
+ if (process.platform === 'win32') {
1179
+ switch (sub) {
1180
+ case 'dns':
1181
+ return runWindowsDns();
1182
+ case 'caches':
1183
+ case 'cache':
1184
+ return runWindowsCaches(opts);
1185
+ case 'trash':
1186
+ return runWindowsTrash(opts);
1187
+ case 'startup':
1188
+ return runWindowsStartup();
1189
+ case 'protect':
1190
+ return runProtect(true, args);
1191
+ case 'unprotect':
1192
+ return runProtect(false, args);
1193
+ case 'help':
1194
+ console.log(USAGE);
1195
+ return;
1196
+ case undefined:
1197
+ case 'report':
1198
+ console.log(chalk.bold('\n pyre optimize — Windows Housekeeping\n'));
1199
+ console.log(' Available subcommands on Windows:');
1200
+ console.log(` ${chalk.cyan('pyre optimize dns')} Flush DNS Resolver Cache`);
1201
+ console.log(` ${chalk.cyan('pyre optimize caches')} Scan and clean temporary files (%TEMP%)`);
1202
+ console.log(` ${chalk.cyan('pyre optimize trash')} Inspect and empty Recycle Bin`);
1203
+ console.log(` ${chalk.cyan('pyre optimize startup')} Inspect Windows startup items\n`);
1204
+ return;
1205
+ default:
1206
+ console.log(chalk.yellow(` Subcommand "${sub}" requires macOS WindowServer APIs.`));
1207
+ console.log(' On Windows, use: dns, caches, trash, startup, protect, unprotect.\n');
1208
+ return;
1209
+ }
1210
+ }
1211
+
1212
+ if (process.platform !== 'darwin') {
1213
+ console.log(chalk.yellow(' pyre optimize is only available on macOS and Windows.'));
1214
+ return;
1215
+ }
1216
+ switch (sub) {
1217
+ case undefined:
1218
+ case 'report':
1219
+ return runOverview(opts);
1220
+ case 'apps':
1221
+ return runApps(opts);
1222
+ case 'track':
1223
+ return runTracker(opts);
1224
+ case 'memory':
1225
+ case 'mem':
1226
+ return runMemory(opts);
1227
+ case 'caches':
1228
+ case 'cache':
1229
+ return runCaches(opts);
1230
+ case 'trash':
1231
+ return runTrash(opts);
1232
+ case 'startup':
1233
+ return runStartup();
1234
+ case 'dns':
1235
+ return runDns(opts);
1236
+ case 'protect':
1237
+ return runProtect(true, args);
1238
+ case 'unprotect':
1239
+ return runProtect(false, args);
1240
+ case 'help':
1241
+ console.log(USAGE);
1242
+ return;
1243
+ default:
1244
+ console.log(chalk.red(` ✖ Unknown subcommand "${sub}"`));
1245
+ console.log(USAGE);
1246
+ process.exitCode = 1;
1247
+ }
1248
+ }