@phenx-inc/ctlsurf 0.16.1 → 0.17.1
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/bin/ctlsurf-worker.js +39 -6
- 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/scripts/extract-electron.js +105 -0
- 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
|
@@ -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
package/src/main/tui.ts
CHANGED
|
@@ -23,12 +23,19 @@ const BG_MODAL = `${CSI}48;2;31;35;53m` // #1f2335
|
|
|
23
23
|
const BG_SELECTED = `${CSI}48;2;42;43;61m` // #2a2b3d
|
|
24
24
|
const RESET = `${CSI}0m`
|
|
25
25
|
|
|
26
|
+
// Title writes share stdout with the agent's output; wait this long after the
|
|
27
|
+
// last PTY chunk so a title never lands inside one of the agent's escape
|
|
28
|
+
// sequences (node-pty can split them across chunks).
|
|
29
|
+
const TITLE_QUIET_MS = 50
|
|
30
|
+
|
|
26
31
|
export interface TuiState {
|
|
27
32
|
agentName: string
|
|
28
33
|
cwd: string
|
|
29
34
|
wsStatus: string
|
|
30
35
|
workerId: string | null
|
|
31
36
|
mode: string
|
|
37
|
+
// Resource + plan-quota summary from monitor.ts, appended to the title.
|
|
38
|
+
resources: string
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
export class Tui {
|
|
@@ -40,7 +47,12 @@ export class Tui {
|
|
|
40
47
|
wsStatus: 'disconnected',
|
|
41
48
|
workerId: null,
|
|
42
49
|
mode: 'terminal',
|
|
50
|
+
resources: '',
|
|
43
51
|
}
|
|
52
|
+
private lastPtyWrite = 0
|
|
53
|
+
private pendingTitle: string | null = null
|
|
54
|
+
private lastTitle = ''
|
|
55
|
+
private titleTimer: ReturnType<typeof setTimeout> | null = null
|
|
44
56
|
|
|
45
57
|
constructor() {
|
|
46
58
|
this.rows = process.stdout.rows || 24
|
|
@@ -60,6 +72,8 @@ export class Tui {
|
|
|
60
72
|
* Restore terminal to normal state
|
|
61
73
|
*/
|
|
62
74
|
destroy(): void {
|
|
75
|
+
if (this.titleTimer) clearTimeout(this.titleTimer)
|
|
76
|
+
this.titleTimer = null
|
|
63
77
|
// Show cursor
|
|
64
78
|
this.write(`${CSI}?25h`)
|
|
65
79
|
}
|
|
@@ -94,6 +108,7 @@ export class Tui {
|
|
|
94
108
|
* Write PTY output — full passthrough.
|
|
95
109
|
*/
|
|
96
110
|
writePtyData(data: string): void {
|
|
111
|
+
this.lastPtyWrite = Date.now()
|
|
97
112
|
this.write(data)
|
|
98
113
|
}
|
|
99
114
|
|
|
@@ -101,14 +116,30 @@ export class Tui {
|
|
|
101
116
|
* Update the terminal window/tab title via OSC escape sequence.
|
|
102
117
|
*/
|
|
103
118
|
setTerminalTitle(title: string): void {
|
|
104
|
-
this.
|
|
119
|
+
this.pendingTitle = title
|
|
120
|
+
this.flushTitle()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private flushTitle(): void {
|
|
124
|
+
if (this.pendingTitle === null || this.titleTimer) return
|
|
125
|
+
const wait = TITLE_QUIET_MS - (Date.now() - this.lastPtyWrite)
|
|
126
|
+
if (wait > 0) {
|
|
127
|
+
this.titleTimer = setTimeout(() => {
|
|
128
|
+
this.titleTimer = null
|
|
129
|
+
this.flushTitle()
|
|
130
|
+
}, wait)
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
if (this.pendingTitle !== this.lastTitle) this.write(`${ESC}]0;${this.pendingTitle}\x07`)
|
|
134
|
+
this.lastTitle = this.pendingTitle
|
|
135
|
+
this.pendingTitle = null
|
|
105
136
|
}
|
|
106
137
|
|
|
107
138
|
/**
|
|
108
139
|
* Build a title string from current state for the terminal tab.
|
|
109
140
|
*/
|
|
110
141
|
updateTerminalTitle(): void {
|
|
111
|
-
const { agentName, wsStatus, cwd } = this.state
|
|
142
|
+
const { agentName, wsStatus, cwd, resources } = this.state
|
|
112
143
|
const displayCwd = this.shortenPath(cwd)
|
|
113
144
|
const statusIcon = {
|
|
114
145
|
connected: '\u25CF',
|
|
@@ -125,7 +156,10 @@ export class Tui {
|
|
|
125
156
|
no_project: 'No Project',
|
|
126
157
|
}[wsStatus] || wsStatus
|
|
127
158
|
|
|
128
|
-
this.setTerminalTitle(
|
|
159
|
+
this.setTerminalTitle(
|
|
160
|
+
`ctlsurf \u00B7 ${agentName} \u00B7 ${statusIcon} ${statusLabel} \u00B7 ${displayCwd}` +
|
|
161
|
+
(resources ? ` \u00B7 ${resources}` : '')
|
|
162
|
+
)
|
|
129
163
|
}
|
|
130
164
|
|
|
131
165
|
/**
|
package/src/preload/index.ts
CHANGED
|
@@ -108,6 +108,10 @@ const api = {
|
|
|
108
108
|
ipcRenderer.on('app:updateInfo', listener)
|
|
109
109
|
return () => ipcRenderer.removeListener('app:updateInfo', listener)
|
|
110
110
|
},
|
|
111
|
+
// Resources + plan quota for the status bar (shape: MonitorSnapshot in
|
|
112
|
+
// src/main/monitor.ts). `fast` = the details panel is open.
|
|
113
|
+
getResourceSnapshot: (fast: boolean): Promise<unknown> =>
|
|
114
|
+
ipcRenderer.invoke('monitor:snapshot', fast),
|
|
111
115
|
|
|
112
116
|
// Settings (legacy)
|
|
113
117
|
getSetting: (key: string): Promise<string | null> =>
|