@phenx-inc/ctlsurf 0.16.1 → 0.17.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.
- package/out/headless/index.mjs +549 -116
- package/out/headless/index.mjs.map +4 -4
- package/out/main/index.js +416 -38
- package/out/preload/index.js +3 -0
- package/out/renderer/assets/{cssMode-DwHD6JSn.js → cssMode-BtQxI2Eb.js} +3 -3
- package/out/renderer/assets/{freemarker2-Na9-bXYw.js → freemarker2-DRW5dmY5.js} +1 -1
- package/out/renderer/assets/{handlebars-D7gzKLS8.js → handlebars-CFLF3cP7.js} +1 -1
- package/out/renderer/assets/{html-DDK7xN27.js → html-DtnBOkwp.js} +1 -1
- package/out/renderer/assets/{htmlMode-BfbLv-NB.js → htmlMode-DMVysDCg.js} +3 -3
- package/out/renderer/assets/{index-DID1LlMU.css → index-CEjwKjtd.css} +45 -0
- package/out/renderer/assets/{index-HoDaBul3.js → index-Dm70hk5s.js} +252 -26
- package/out/renderer/assets/{javascript-DJjWOxID.js → javascript-ChlyA1sf.js} +2 -2
- package/out/renderer/assets/{jsonMode-BileWbig.js → jsonMode-D1_oHzeN.js} +3 -3
- package/out/renderer/assets/{liquid-DvNU_dI9.js → liquid-D4ftdy1G.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-Bb27EY8c.js → lspLanguageFeatures-Ckqu5ubG.js} +1 -1
- package/out/renderer/assets/{mdx-CLkNPVgD.js → mdx-B9wsVYSC.js} +1 -1
- package/out/renderer/assets/{python-DOJdL4kC.js → python-e23tD6IS.js} +1 -1
- package/out/renderer/assets/{razor-CSr8UlJc.js → razor-CrDyXj3d.js} +1 -1
- package/out/renderer/assets/{tsMode-sbsAvk21.js → tsMode-BwGC5o4O.js} +1 -1
- package/out/renderer/assets/{typescript-r5QB4yRW.js → typescript-Do-98d_O.js} +1 -1
- package/out/renderer/assets/{xml-CYhKh3Pi.js → xml-Dpdu5R3m.js} +1 -1
- package/out/renderer/assets/{yaml-B8DsoaYW.js → yaml-B0Zt7LCJ.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +1 -1
- package/src/main/headless.ts +12 -0
- package/src/main/index.ts +8 -0
- package/src/main/monitor.ts +540 -0
- package/src/main/orchestrator.ts +10 -0
- package/src/main/pty.ts +5 -0
- package/src/main/tui.ts +37 -3
- package/src/preload/index.ts +4 -0
- package/src/renderer/App.tsx +9 -1
- package/src/renderer/components/Resources.tsx +217 -0
- package/src/renderer/components/StatusBar.tsx +6 -1
- package/src/renderer/styles.css +45 -0
package/src/main/headless.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { Orchestrator } from './orchestrator'
|
|
|
31
31
|
import { getSettingsDir } from './settingsDir'
|
|
32
32
|
import { getBuiltinAgents, getAvailableAgents, isCodingAgent, type AgentConfig } from './agents'
|
|
33
33
|
import { Tui } from './tui'
|
|
34
|
+
import { ResourceMonitor, summarizeSnapshot } from './monitor'
|
|
34
35
|
import { fetchLatestNpmVersion, compareSemver, NPM_PACKAGE } from './updateCheck'
|
|
35
36
|
|
|
36
37
|
function getCurrentVersion(): string {
|
|
@@ -177,6 +178,16 @@ async function main() {
|
|
|
177
178
|
await orchestrator.spawnAgent(HEADLESS_TAB, agent, args.cwd, { trackTime: trackTimeOverride })
|
|
178
179
|
orchestrator.resizePty(HEADLESS_TAB, ptySize.cols, ptySize.rows)
|
|
179
180
|
|
|
181
|
+
// CPU / RAM and the agent's plan quota, appended to the tab title.
|
|
182
|
+
const monitor = new ResourceMonitor(() => orchestrator.getTabProcesses())
|
|
183
|
+
const refreshResources = () => {
|
|
184
|
+
monitor.snapshot()
|
|
185
|
+
.then(snap => tui.update({ resources: summarizeSnapshot(snap) }))
|
|
186
|
+
.catch(() => { /* keep the last title */ })
|
|
187
|
+
}
|
|
188
|
+
refreshResources()
|
|
189
|
+
setInterval(refreshResources, 10_000).unref()
|
|
190
|
+
|
|
180
191
|
// For coding agents, send an initial prompt to kick-start them
|
|
181
192
|
if (isCodingAgent(agent)) {
|
|
182
193
|
setTimeout(() => {
|
|
@@ -221,6 +232,7 @@ async function main() {
|
|
|
221
232
|
if (process.stdin.isTTY) {
|
|
222
233
|
process.stdin.setRawMode(false)
|
|
223
234
|
}
|
|
235
|
+
monitor.stop()
|
|
224
236
|
tui.destroy()
|
|
225
237
|
await orchestrator.shutdown()
|
|
226
238
|
process.exit(0)
|
package/src/main/index.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { getSettingsDir } from './settingsDir'
|
|
|
27
27
|
import { Orchestrator } from './orchestrator'
|
|
28
28
|
import { AttentionController } from './agentState'
|
|
29
29
|
import { listSshHosts, getRecentHosts, addRecentHost, probeRemote, buildRemoteAgent, type RemoteAction } from './ssh'
|
|
30
|
+
import { ResourceMonitor } from './monitor'
|
|
30
31
|
|
|
31
32
|
const SETTINGS_DIR = getSettingsDir(true)
|
|
32
33
|
|
|
@@ -498,6 +499,12 @@ async function checkForUpdate(): Promise<void> {
|
|
|
498
499
|
ipcMain.handle('app:getVersion', () => app.getVersion())
|
|
499
500
|
ipcMain.handle('app:getUpdateInfo', () => updateInfo)
|
|
500
501
|
|
|
502
|
+
// ─── Resources + plan quota (status bar) ──────────
|
|
503
|
+
|
|
504
|
+
// Samples only while the renderer keeps asking; `fast` = the panel is open.
|
|
505
|
+
const resourceMonitor = new ResourceMonitor(() => orchestrator.getTabProcesses())
|
|
506
|
+
ipcMain.handle('monitor:snapshot', (_event, fast: boolean) => resourceMonitor.snapshot(!!fast))
|
|
507
|
+
|
|
501
508
|
// ─── Filesystem IPC ───────────────────────────────
|
|
502
509
|
|
|
503
510
|
ipcMain.handle('fs:readDir', async (_event, dirPath: string) => {
|
|
@@ -831,6 +838,7 @@ let shutdownStarted = false
|
|
|
831
838
|
function shutdownThenQuit(): void {
|
|
832
839
|
if (shutdownStarted) return
|
|
833
840
|
shutdownStarted = true
|
|
841
|
+
resourceMonitor.stop()
|
|
834
842
|
void Promise.race([
|
|
835
843
|
orchestrator.shutdown(),
|
|
836
844
|
new Promise((resolve) => setTimeout(resolve, 10_000)),
|
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resource monitor
|
|
3
|
+
*
|
|
4
|
+
* Live resource use of this machine (CPU, RAM, disk, Apple GPU), the CPU and
|
|
5
|
+
* memory of each tab's agent process tree, and the plan quota of the LLM
|
|
6
|
+
* service the agents run on (Claude Code, Codex). Ported from
|
|
7
|
+
* ctlsurf_research's monitor.py.
|
|
8
|
+
*
|
|
9
|
+
* Design notes:
|
|
10
|
+
* - Sampled on demand: about every 3 s while the desktop resource panel is
|
|
11
|
+
* open, every 10 s otherwise, and not at all once nobody has asked for a
|
|
12
|
+
* snapshot in IDLE_STOP_MS. Read-only: vm_stat / ioreg / ps on macOS,
|
|
13
|
+
* /proc on Linux.
|
|
14
|
+
* - Quota is fetched only for services some open tab actually uses, and the
|
|
15
|
+
* UI shows the active tab's. Claude's comes from `claude -p /usage` (a local
|
|
16
|
+
* command: no model call, nothing counted against the plan); Codex's from
|
|
17
|
+
* the rate limits Codex writes into its newest session file as it runs.
|
|
18
|
+
* - Remote (SSH) tabs are reported against this machine only: their process
|
|
19
|
+
* tree is the local ssh client, and their quota is the local account's —
|
|
20
|
+
* right for Claude when the remote is logged in to the same account (usage
|
|
21
|
+
* is account-wide), but Codex only reflects Codex runs on this machine.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { execFile } from 'child_process'
|
|
25
|
+
import fs from 'fs'
|
|
26
|
+
import os from 'os'
|
|
27
|
+
import path from 'path'
|
|
28
|
+
import { AgentConfig, isRemoteAgent } from './agents'
|
|
29
|
+
import { log } from './logger'
|
|
30
|
+
|
|
31
|
+
const FAST_MS = 3_000
|
|
32
|
+
const SLOW_MS = 10_000
|
|
33
|
+
const IDLE_STOP_MS = 120_000
|
|
34
|
+
const HISTORY_MS = 600_000
|
|
35
|
+
// How long one `fast` snapshot request keeps the fast cadence going.
|
|
36
|
+
const FAST_HOLD_MS = 15_000
|
|
37
|
+
// Plan usage: every 5 min, every minute while the panel is open.
|
|
38
|
+
const USAGE_SLOW_MS = 300_000
|
|
39
|
+
const USAGE_FAST_MS = 60_000
|
|
40
|
+
// Codex session files grow large; its rate limits are on every token-count
|
|
41
|
+
// event, so the tail is enough.
|
|
42
|
+
const CODEX_TAIL_BYTES = 512 * 1024
|
|
43
|
+
|
|
44
|
+
// ─── Types ────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export type Service = 'claude' | 'codex'
|
|
47
|
+
|
|
48
|
+
export interface UsageWindow {
|
|
49
|
+
/** Shown in the panel: "session (5h)", "week, all models", "week, Fable". */
|
|
50
|
+
label: string
|
|
51
|
+
/** Shown on the status-bar gauge; windows without one appear only in the panel. */
|
|
52
|
+
short: string | null
|
|
53
|
+
pct: number
|
|
54
|
+
/** Human-readable reset time, or null when the service doesn't say. */
|
|
55
|
+
resets: string | null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface PlanUsage {
|
|
59
|
+
ok: boolean
|
|
60
|
+
windows: UsageWindow[]
|
|
61
|
+
plan?: string | null
|
|
62
|
+
/** Where the numbers came from. */
|
|
63
|
+
source: string
|
|
64
|
+
/** When the numbers were current (ISO). */
|
|
65
|
+
ts: string
|
|
66
|
+
error?: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface MachineSample {
|
|
70
|
+
ok: boolean
|
|
71
|
+
error?: string
|
|
72
|
+
ts: string
|
|
73
|
+
cpu?: { pct: number | null; cores: number; load: number[] }
|
|
74
|
+
mem?: { total: number; used: number; pct: number }
|
|
75
|
+
disk?: { total: number; free: number } | null
|
|
76
|
+
/** Apple GPU utilization %, macOS only. */
|
|
77
|
+
gpu?: number | null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface HistoryPoint {
|
|
81
|
+
t: number
|
|
82
|
+
cpu: number | null
|
|
83
|
+
mem: number | null
|
|
84
|
+
gpu: number | null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface AgentProcess {
|
|
88
|
+
tabId: string
|
|
89
|
+
name: string
|
|
90
|
+
active: boolean
|
|
91
|
+
remote: boolean
|
|
92
|
+
service: Service | null
|
|
93
|
+
pid: number | null
|
|
94
|
+
/** Summed over the agent's process tree; % of one core, like `ps`. */
|
|
95
|
+
cpu: number | null
|
|
96
|
+
mem: number | null
|
|
97
|
+
procs: number
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface MonitorSnapshot {
|
|
101
|
+
machine: MachineSample & { label: string; history: HistoryPoint[] }
|
|
102
|
+
agents: AgentProcess[]
|
|
103
|
+
activeService: Service | null
|
|
104
|
+
usage: Partial<Record<Service, PlanUsage>>
|
|
105
|
+
interval: number
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface TabProcess {
|
|
109
|
+
tabId: string
|
|
110
|
+
agent: AgentConfig
|
|
111
|
+
pid: number | null
|
|
112
|
+
active: boolean
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── Helpers ──────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
function now(): string {
|
|
118
|
+
return new Date().toISOString()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function round(x: number, digits = 1): number {
|
|
122
|
+
const f = 10 ** digits
|
|
123
|
+
return Math.round(x * f) / f
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function run(cmd: string, args: string[], opts: { cwd?: string; timeout?: number } = {}): Promise<string> {
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
execFile(cmd, args, { timeout: opts.timeout ?? 5_000, cwd: opts.cwd, maxBuffer: 16 * 1024 * 1024 }, (err, stdout) => {
|
|
129
|
+
if (err && !stdout) reject(err)
|
|
130
|
+
else resolve(stdout)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Which plan an agent runs on, from the CLI it launches. Remote agents carry
|
|
136
|
+
* it in the ssh command line (args are ['-t', target, remoteCmd]); a bare
|
|
137
|
+
* attach to an existing tmux session doesn't say, so it gets none. */
|
|
138
|
+
export function serviceFor(agent: AgentConfig): Service | null {
|
|
139
|
+
const cmd = isRemoteAgent(agent) ? agent.args.slice(2).join(' ') : path.basename(agent.command)
|
|
140
|
+
if (/\bcodex\b/.test(cmd)) return 'codex'
|
|
141
|
+
if (/\bclaude\b/.test(cmd)) return 'claude'
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ─── Machine sampling ─────────────────────────────
|
|
146
|
+
|
|
147
|
+
function cpuTimes(): { idle: number; total: number } {
|
|
148
|
+
let idle = 0
|
|
149
|
+
let total = 0
|
|
150
|
+
for (const c of os.cpus()) {
|
|
151
|
+
const t = c.times
|
|
152
|
+
idle += t.idle
|
|
153
|
+
total += t.user + t.nice + t.sys + t.idle + t.irq
|
|
154
|
+
}
|
|
155
|
+
return { idle, total }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function memory(): Promise<{ total: number; used: number; pct: number }> {
|
|
159
|
+
const total = os.totalmem()
|
|
160
|
+
let avail = os.freemem()
|
|
161
|
+
try {
|
|
162
|
+
if (process.platform === 'darwin') {
|
|
163
|
+
// os.freemem() counts only free pages. Like psutil, also treat inactive
|
|
164
|
+
// and speculative pages as available — macOS reclaims them on demand.
|
|
165
|
+
const out = await run('vm_stat', [])
|
|
166
|
+
const page = Number(/page size of (\d+) bytes/.exec(out)?.[1]) || 4096
|
|
167
|
+
const pages = (name: string) => Number(new RegExp(`Pages ${name}:\\s+(\\d+)`).exec(out)?.[1] ?? 0)
|
|
168
|
+
avail = (pages('free') + pages('inactive') + pages('speculative')) * page
|
|
169
|
+
} else if (process.platform === 'linux') {
|
|
170
|
+
const m = /MemAvailable:\s+(\d+) kB/.exec(await fs.promises.readFile('/proc/meminfo', 'utf-8'))
|
|
171
|
+
if (m) avail = Number(m[1]) * 1024
|
|
172
|
+
}
|
|
173
|
+
} catch { /* fall back to os.freemem() */ }
|
|
174
|
+
avail = Math.min(avail, total)
|
|
175
|
+
return { total, used: total - avail, pct: round((100 * (total - avail)) / total) }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function diskUsage(): Promise<{ total: number; free: number } | null> {
|
|
179
|
+
// fs.statfs arrived in Node 18.15; terminal mode supports any Node 18.
|
|
180
|
+
if (typeof fs.promises.statfs !== 'function') return null
|
|
181
|
+
try {
|
|
182
|
+
const s = await fs.promises.statfs(os.homedir())
|
|
183
|
+
return { total: s.blocks * s.bsize, free: s.bavail * s.bsize }
|
|
184
|
+
} catch {
|
|
185
|
+
return null
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Apple GPU load from the IOAccelerator registry entry (no sudo needed). */
|
|
190
|
+
async function appleGpu(): Promise<number | null> {
|
|
191
|
+
if (process.platform !== 'darwin') return null
|
|
192
|
+
try {
|
|
193
|
+
const out = await run('ioreg', ['-r', '-d', '1', '-w', '0', '-c', 'IOAccelerator'], { timeout: 3_000 })
|
|
194
|
+
const m = /"Device Utilization %"=(\d+)/.exec(out)
|
|
195
|
+
return m ? Number(m[1]) : null
|
|
196
|
+
} catch {
|
|
197
|
+
return null
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
interface PsRow { ppid: number; cpu: number; rss: number }
|
|
202
|
+
|
|
203
|
+
/** One `ps` for every process: pid → {ppid, %cpu, rss bytes}. */
|
|
204
|
+
async function processTable(): Promise<Map<number, PsRow>> {
|
|
205
|
+
const table = new Map<number, PsRow>()
|
|
206
|
+
if (process.platform === 'win32') return table
|
|
207
|
+
try {
|
|
208
|
+
const out = await run('ps', ['-A', '-o', 'pid=,ppid=,pcpu=,rss='])
|
|
209
|
+
for (const line of out.split('\n')) {
|
|
210
|
+
const [pid, ppid, cpu, rss] = line.trim().split(/\s+/).map(Number)
|
|
211
|
+
if (pid) table.set(pid, { ppid, cpu: cpu || 0, rss: (rss || 0) * 1024 })
|
|
212
|
+
}
|
|
213
|
+
} catch { /* no per-agent numbers this round */ }
|
|
214
|
+
return table
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function agentProcess(tab: TabProcess, table: Map<number, PsRow>): AgentProcess {
|
|
218
|
+
const base = {
|
|
219
|
+
tabId: tab.tabId,
|
|
220
|
+
name: tab.agent.name,
|
|
221
|
+
active: tab.active,
|
|
222
|
+
remote: isRemoteAgent(tab.agent),
|
|
223
|
+
service: serviceFor(tab.agent),
|
|
224
|
+
pid: tab.pid,
|
|
225
|
+
}
|
|
226
|
+
if (tab.pid == null || !table.has(tab.pid)) return { ...base, cpu: null, mem: null, procs: 0 }
|
|
227
|
+
const children = new Map<number, number[]>()
|
|
228
|
+
for (const [pid, row] of table) {
|
|
229
|
+
const list = children.get(row.ppid)
|
|
230
|
+
if (list) list.push(pid)
|
|
231
|
+
else children.set(row.ppid, [pid])
|
|
232
|
+
}
|
|
233
|
+
let cpu = 0
|
|
234
|
+
let mem = 0
|
|
235
|
+
let procs = 0
|
|
236
|
+
const queue = [tab.pid]
|
|
237
|
+
while (queue.length) {
|
|
238
|
+
const pid = queue.pop()!
|
|
239
|
+
const row = table.get(pid)
|
|
240
|
+
if (!row) continue
|
|
241
|
+
cpu += row.cpu
|
|
242
|
+
mem += row.rss
|
|
243
|
+
procs++
|
|
244
|
+
queue.push(...(children.get(pid) ?? []))
|
|
245
|
+
}
|
|
246
|
+
return { ...base, cpu: round(cpu), mem, procs }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ─── Plan usage ───────────────────────────────────
|
|
250
|
+
|
|
251
|
+
/** `claude -p /usage` text → windows (reset times as Claude Code words them). */
|
|
252
|
+
export function parseClaudeUsage(text: string): UsageWindow[] {
|
|
253
|
+
const windows: UsageWindow[] = []
|
|
254
|
+
for (const line of text.split('\n')) {
|
|
255
|
+
let m = /^\s*Current session:\s*([\d.]+)% used(?:\s*·\s*resets (.+))?/.exec(line)
|
|
256
|
+
if (m) {
|
|
257
|
+
windows.push({ label: 'session (5h)', short: '5h', pct: Number(m[1]), resets: m[2]?.trim() || null })
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
m = /^\s*Current week \((.+?)\):\s*([\d.]+)% used(?:\s*·\s*resets (.+))?/.exec(line)
|
|
261
|
+
if (m) {
|
|
262
|
+
const all = m[1].trim().toLowerCase() === 'all models'
|
|
263
|
+
windows.push({
|
|
264
|
+
label: all ? 'week, all models' : `week, ${m[1].trim()}`,
|
|
265
|
+
short: all ? 'wk' : null,
|
|
266
|
+
pct: Number(m[2]),
|
|
267
|
+
resets: m[3]?.trim() || null,
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return windows
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export async function claudeUsage(): Promise<PlanUsage> {
|
|
275
|
+
const source = 'claude /usage'
|
|
276
|
+
let text = ''
|
|
277
|
+
try {
|
|
278
|
+
const out = await run('claude', ['-p', '/usage', '--output-format', 'json', '--no-session-persistence'],
|
|
279
|
+
{ cwd: os.tmpdir(), timeout: 40_000 })
|
|
280
|
+
text = String(JSON.parse(out || '{}').result ?? '')
|
|
281
|
+
} catch (err) {
|
|
282
|
+
const msg = (err as NodeJS.ErrnoException).code === 'ENOENT' ? 'claude not found on PATH' : String(err)
|
|
283
|
+
return { ok: false, windows: [], source, ts: now(), error: msg.slice(0, 200) }
|
|
284
|
+
}
|
|
285
|
+
const windows = parseClaudeUsage(text)
|
|
286
|
+
return windows.length
|
|
287
|
+
? { ok: true, windows, source, ts: now() }
|
|
288
|
+
: { ok: false, windows: [], source, ts: now(), error: text.trim().slice(0, 200) || 'no usage reported' }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function findKey(obj: unknown, key: string): Record<string, unknown> | null {
|
|
292
|
+
if (Array.isArray(obj)) {
|
|
293
|
+
for (const v of obj) {
|
|
294
|
+
const found = findKey(v, key)
|
|
295
|
+
if (found) return found
|
|
296
|
+
}
|
|
297
|
+
} else if (obj && typeof obj === 'object') {
|
|
298
|
+
const rec = obj as Record<string, unknown>
|
|
299
|
+
if (rec[key] && typeof rec[key] === 'object') return rec[key] as Record<string, unknown>
|
|
300
|
+
for (const v of Object.values(rec)) {
|
|
301
|
+
const found = findKey(v, key)
|
|
302
|
+
if (found) return found
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return null
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function codexWindow(minutes: unknown): { label: string; short: string } {
|
|
309
|
+
const m = Number(minutes)
|
|
310
|
+
if (!Number.isFinite(m)) return { label: 'window', short: 'win' }
|
|
311
|
+
if (m === 300) return { label: '5h', short: '5h' }
|
|
312
|
+
if (m === 1440) return { label: 'day', short: 'day' }
|
|
313
|
+
if (m === 10080) return { label: 'week', short: 'wk' }
|
|
314
|
+
const s = m < 1440 ? `${Math.floor(m / 60)}h` : `${Math.floor(m / 1440)}d`
|
|
315
|
+
return { label: s, short: s }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function readTail(file: string, bytes: number): Promise<string> {
|
|
319
|
+
const fh = await fs.promises.open(file, 'r')
|
|
320
|
+
try {
|
|
321
|
+
const { size } = await fh.stat()
|
|
322
|
+
const start = Math.max(0, size - bytes)
|
|
323
|
+
const buf = Buffer.alloc(size - start)
|
|
324
|
+
await fh.read(buf, 0, buf.length, start)
|
|
325
|
+
return buf.toString('utf-8')
|
|
326
|
+
} finally {
|
|
327
|
+
await fh.close()
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Newest Codex session files (~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl),
|
|
332
|
+
* by mtime. Walks the newest day directories only. */
|
|
333
|
+
async function codexSessionFiles(root: string, limit: number): Promise<string[]> {
|
|
334
|
+
const sortedDirs = async (dir: string) =>
|
|
335
|
+
(await fs.promises.readdir(dir, { withFileTypes: true }).catch(() => []))
|
|
336
|
+
.filter(d => d.isDirectory()).map(d => path.join(dir, d.name)).sort().reverse()
|
|
337
|
+
const days: string[] = []
|
|
338
|
+
for (const y of await sortedDirs(root)) {
|
|
339
|
+
for (const m of await sortedDirs(y)) {
|
|
340
|
+
days.push(...await sortedDirs(m))
|
|
341
|
+
if (days.length >= 14) break
|
|
342
|
+
}
|
|
343
|
+
if (days.length >= 14) break
|
|
344
|
+
}
|
|
345
|
+
const files: { file: string; mtime: number }[] = []
|
|
346
|
+
for (const day of days.slice(0, 14)) {
|
|
347
|
+
for (const name of await fs.promises.readdir(day).catch(() => [] as string[])) {
|
|
348
|
+
if (!name.startsWith('rollout-') || !name.endsWith('.jsonl')) continue
|
|
349
|
+
const file = path.join(day, name)
|
|
350
|
+
try {
|
|
351
|
+
files.push({ file, mtime: (await fs.promises.stat(file)).mtimeMs })
|
|
352
|
+
} catch { /* vanished */ }
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return files.sort((a, b) => b.mtime - a.mtime).slice(0, limit).map(f => f.file)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export async function codexUsage(home: string = os.homedir()): Promise<PlanUsage> {
|
|
359
|
+
const source = 'your most recent Codex session'
|
|
360
|
+
for (const file of await codexSessionFiles(path.join(home, '.codex', 'sessions'), 5)) {
|
|
361
|
+
let lines: string[]
|
|
362
|
+
try {
|
|
363
|
+
lines = (await readTail(file, CODEX_TAIL_BYTES)).split('\n')
|
|
364
|
+
} catch {
|
|
365
|
+
continue
|
|
366
|
+
}
|
|
367
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
368
|
+
if (!lines[i].includes('"rate_limits"')) continue
|
|
369
|
+
let rl: Record<string, unknown> | null
|
|
370
|
+
try {
|
|
371
|
+
rl = findKey(JSON.parse(lines[i]), 'rate_limits')
|
|
372
|
+
} catch {
|
|
373
|
+
continue // the tail's first line is usually cut mid-record
|
|
374
|
+
}
|
|
375
|
+
if (!rl) continue
|
|
376
|
+
const windows: UsageWindow[] = []
|
|
377
|
+
for (const key of ['primary', 'secondary']) {
|
|
378
|
+
const w = rl[key] as Record<string, unknown> | null | undefined
|
|
379
|
+
if (!w || typeof w !== 'object' || w.used_percent == null) continue
|
|
380
|
+
const { label, short } = codexWindow(w.window_minutes)
|
|
381
|
+
const resets = typeof w.resets_at === 'number'
|
|
382
|
+
? new Date(w.resets_at * 1000).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
|
|
383
|
+
: null
|
|
384
|
+
windows.push({ label, short, pct: Number(w.used_percent), resets })
|
|
385
|
+
}
|
|
386
|
+
if (windows.length) {
|
|
387
|
+
const ts = new Date((await fs.promises.stat(file)).mtimeMs).toISOString()
|
|
388
|
+
return { ok: true, windows, plan: (rl.plan_type as string | null) ?? null, source, ts }
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return { ok: false, windows: [], source, ts: now(), error: 'no Codex session with usage yet' }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const FETCH_USAGE: Record<Service, () => Promise<PlanUsage>> = {
|
|
396
|
+
claude: claudeUsage,
|
|
397
|
+
codex: () => codexUsage(),
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const SERVICE_NAME: Record<Service, string> = { claude: 'Claude', codex: 'Codex' }
|
|
401
|
+
|
|
402
|
+
// ─── Monitor ──────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
export class ResourceMonitor {
|
|
405
|
+
private machine: MachineSample = { ok: false, ts: now(), error: 'sampling…' }
|
|
406
|
+
private history: HistoryPoint[] = []
|
|
407
|
+
private agents: AgentProcess[] = []
|
|
408
|
+
private usage: Partial<Record<Service, PlanUsage>> = {}
|
|
409
|
+
private usageAt: Partial<Record<Service, number>> = {}
|
|
410
|
+
private usageBusy = new Set<Service>()
|
|
411
|
+
private prevCpu = cpuTimes()
|
|
412
|
+
private lastAsk = 0
|
|
413
|
+
private fastUntil = 0
|
|
414
|
+
private timer: ReturnType<typeof setTimeout> | null = null
|
|
415
|
+
private inflight: Promise<void> | null = null
|
|
416
|
+
|
|
417
|
+
constructor(private tabs: () => TabProcess[]) {}
|
|
418
|
+
|
|
419
|
+
/** Latest sample plus 10 minutes of history. `fast` = the panel is open:
|
|
420
|
+
* sample every ~3 s and refresh quota every minute. */
|
|
421
|
+
async snapshot(fast = false): Promise<MonitorSnapshot> {
|
|
422
|
+
const t = Date.now()
|
|
423
|
+
this.lastAsk = t
|
|
424
|
+
const speedUp = fast && t >= this.fastUntil
|
|
425
|
+
if (fast) this.fastUntil = t + FAST_HOLD_MS
|
|
426
|
+
// The first answer — and the first fast one — waits for a fresh sample.
|
|
427
|
+
if ((!this.timer && !this.inflight) || speedUp) await this.tick()
|
|
428
|
+
// A tab switch can bring in a service we haven't fetched yet.
|
|
429
|
+
else this.refreshUsage(this.tabs())
|
|
430
|
+
return this.build(fast)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
stop(): void {
|
|
434
|
+
if (this.timer) clearTimeout(this.timer)
|
|
435
|
+
this.timer = null
|
|
436
|
+
this.lastAsk = 0
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private tick(): Promise<void> {
|
|
440
|
+
if (!this.inflight) {
|
|
441
|
+
if (this.timer) clearTimeout(this.timer)
|
|
442
|
+
this.timer = null
|
|
443
|
+
this.inflight = this.sample()
|
|
444
|
+
.catch(err => log(`[monitor] sample failed: ${err}`))
|
|
445
|
+
.finally(() => {
|
|
446
|
+
this.inflight = null
|
|
447
|
+
if (Date.now() - this.lastAsk >= IDLE_STOP_MS) return // nobody's looking
|
|
448
|
+
this.timer = setTimeout(() => { void this.tick() }, Date.now() < this.fastUntil ? FAST_MS : SLOW_MS)
|
|
449
|
+
this.timer.unref?.()
|
|
450
|
+
})
|
|
451
|
+
}
|
|
452
|
+
return this.inflight
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private async sample(): Promise<void> {
|
|
456
|
+
const tabs = this.tabs()
|
|
457
|
+
this.refreshUsage(tabs)
|
|
458
|
+
try {
|
|
459
|
+
const [mem, disk, gpu, table] = await Promise.all([memory(), diskUsage(), appleGpu(), processTable()])
|
|
460
|
+
const cur = cpuTimes()
|
|
461
|
+
const dTotal = cur.total - this.prevCpu.total
|
|
462
|
+
const cpuPct = dTotal > 0 ? round(100 * (1 - (cur.idle - this.prevCpu.idle) / dTotal)) : null
|
|
463
|
+
this.prevCpu = cur
|
|
464
|
+
this.machine = {
|
|
465
|
+
ok: true,
|
|
466
|
+
ts: now(),
|
|
467
|
+
cpu: { pct: cpuPct, cores: os.cpus().length, load: os.loadavg().map(x => round(x, 2)) },
|
|
468
|
+
mem,
|
|
469
|
+
disk,
|
|
470
|
+
gpu,
|
|
471
|
+
}
|
|
472
|
+
this.agents = tabs.map(tab => agentProcess(tab, table))
|
|
473
|
+
const t = Date.now()
|
|
474
|
+
this.history.push({ t, cpu: cpuPct, mem: mem.pct, gpu })
|
|
475
|
+
while (this.history.length && this.history[0].t < t - HISTORY_MS) this.history.shift()
|
|
476
|
+
} catch (err) {
|
|
477
|
+
this.machine = { ok: false, ts: now(), error: String(err).slice(0, 200) }
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private refreshUsage(tabs: TabProcess[]): void {
|
|
482
|
+
const every = Date.now() < this.fastUntil ? USAGE_FAST_MS : USAGE_SLOW_MS
|
|
483
|
+
for (const s of this.servicesInUse(tabs)) {
|
|
484
|
+
if (this.usageBusy.has(s) || Date.now() - (this.usageAt[s] ?? 0) < every) continue
|
|
485
|
+
this.usageBusy.add(s)
|
|
486
|
+
FETCH_USAGE[s]()
|
|
487
|
+
.catch((err): PlanUsage => ({ ok: false, windows: [], source: s, ts: now(), error: String(err).slice(0, 200) }))
|
|
488
|
+
.then(u => {
|
|
489
|
+
this.usage[s] = u
|
|
490
|
+
this.usageAt[s] = Date.now()
|
|
491
|
+
if (!u.ok) log(`[monitor] ${s} usage unavailable: ${u.error}`)
|
|
492
|
+
})
|
|
493
|
+
.finally(() => this.usageBusy.delete(s))
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private servicesInUse(tabs: TabProcess[]): Service[] {
|
|
498
|
+
return [...new Set(tabs.map(t => serviceFor(t.agent)).filter((s): s is Service => s !== null))]
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
private build(fast: boolean): MonitorSnapshot {
|
|
502
|
+
const tabs = this.tabs()
|
|
503
|
+
const active = tabs.find(t => t.active)
|
|
504
|
+
const usage: Partial<Record<Service, PlanUsage>> = {}
|
|
505
|
+
for (const s of this.servicesInUse(tabs)) {
|
|
506
|
+
if (this.usage[s]) usage[s] = this.usage[s]
|
|
507
|
+
}
|
|
508
|
+
return {
|
|
509
|
+
machine: {
|
|
510
|
+
...this.machine,
|
|
511
|
+
label: process.platform === 'darwin' ? 'This Mac' : os.hostname(),
|
|
512
|
+
history: [...this.history],
|
|
513
|
+
},
|
|
514
|
+
agents: this.agents,
|
|
515
|
+
activeService: active ? serviceFor(active.agent) : null,
|
|
516
|
+
usage,
|
|
517
|
+
interval: fast ? FAST_MS : SLOW_MS,
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** One-line summary for the terminal-mode tab title:
|
|
523
|
+
* "CPU 12% RAM 64% · Claude 5h 4% wk 4%". */
|
|
524
|
+
export function summarizeSnapshot(s: MonitorSnapshot): string {
|
|
525
|
+
const parts: string[] = []
|
|
526
|
+
const m = s.machine
|
|
527
|
+
if (m.ok) {
|
|
528
|
+
const res = [
|
|
529
|
+
m.cpu?.pct != null ? `CPU ${Math.round(m.cpu.pct)}%` : '',
|
|
530
|
+
m.mem ? `RAM ${Math.round(m.mem.pct)}%` : '',
|
|
531
|
+
].filter(Boolean).join(' ')
|
|
532
|
+
if (res) parts.push(res)
|
|
533
|
+
}
|
|
534
|
+
const u = s.activeService ? s.usage[s.activeService] : undefined
|
|
535
|
+
if (s.activeService && u?.ok) {
|
|
536
|
+
const wins = u.windows.filter(w => w.short).map(w => `${w.short} ${Math.round(w.pct)}%`)
|
|
537
|
+
if (wins.length) parts.push(`${SERVICE_NAME[s.activeService]} ${wins.join(' ')}`)
|
|
538
|
+
}
|
|
539
|
+
return parts.join(' · ')
|
|
540
|
+
}
|
package/src/main/orchestrator.ts
CHANGED
|
@@ -742,6 +742,16 @@ export class Orchestrator {
|
|
|
742
742
|
return [...this.tabs.keys()]
|
|
743
743
|
}
|
|
744
744
|
|
|
745
|
+
// Every open tab's agent and PTY pid, for the resource monitor (monitor.ts).
|
|
746
|
+
getTabProcesses(): { tabId: string; agent: AgentConfig; pid: number | null; active: boolean }[] {
|
|
747
|
+
return [...this.tabs].map(([tabId, tab]) => ({
|
|
748
|
+
tabId,
|
|
749
|
+
agent: tab.agent,
|
|
750
|
+
pid: tab.ptyManager.pid,
|
|
751
|
+
active: tabId === this.activeTabId,
|
|
752
|
+
}))
|
|
753
|
+
}
|
|
754
|
+
|
|
745
755
|
// ─── Tracking control (active tab) ──────────────
|
|
746
756
|
|
|
747
757
|
isActiveTabTracking(): boolean {
|
package/src/main/pty.ts
CHANGED