@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.
Files changed (37) hide show
  1. package/bin/ctlsurf-worker.js +39 -6
  2. package/out/headless/index.mjs +549 -116
  3. package/out/headless/index.mjs.map +4 -4
  4. package/out/main/index.js +416 -38
  5. package/out/preload/index.js +3 -0
  6. package/out/renderer/assets/{cssMode-DwHD6JSn.js → cssMode-BtQxI2Eb.js} +3 -3
  7. package/out/renderer/assets/{freemarker2-Na9-bXYw.js → freemarker2-DRW5dmY5.js} +1 -1
  8. package/out/renderer/assets/{handlebars-D7gzKLS8.js → handlebars-CFLF3cP7.js} +1 -1
  9. package/out/renderer/assets/{html-DDK7xN27.js → html-DtnBOkwp.js} +1 -1
  10. package/out/renderer/assets/{htmlMode-BfbLv-NB.js → htmlMode-DMVysDCg.js} +3 -3
  11. package/out/renderer/assets/{index-DID1LlMU.css → index-CEjwKjtd.css} +45 -0
  12. package/out/renderer/assets/{index-HoDaBul3.js → index-Dm70hk5s.js} +252 -26
  13. package/out/renderer/assets/{javascript-DJjWOxID.js → javascript-ChlyA1sf.js} +2 -2
  14. package/out/renderer/assets/{jsonMode-BileWbig.js → jsonMode-D1_oHzeN.js} +3 -3
  15. package/out/renderer/assets/{liquid-DvNU_dI9.js → liquid-D4ftdy1G.js} +1 -1
  16. package/out/renderer/assets/{lspLanguageFeatures-Bb27EY8c.js → lspLanguageFeatures-Ckqu5ubG.js} +1 -1
  17. package/out/renderer/assets/{mdx-CLkNPVgD.js → mdx-B9wsVYSC.js} +1 -1
  18. package/out/renderer/assets/{python-DOJdL4kC.js → python-e23tD6IS.js} +1 -1
  19. package/out/renderer/assets/{razor-CSr8UlJc.js → razor-CrDyXj3d.js} +1 -1
  20. package/out/renderer/assets/{tsMode-sbsAvk21.js → tsMode-BwGC5o4O.js} +1 -1
  21. package/out/renderer/assets/{typescript-r5QB4yRW.js → typescript-Do-98d_O.js} +1 -1
  22. package/out/renderer/assets/{xml-CYhKh3Pi.js → xml-Dpdu5R3m.js} +1 -1
  23. package/out/renderer/assets/{yaml-B8DsoaYW.js → yaml-B0Zt7LCJ.js} +1 -1
  24. package/out/renderer/index.html +2 -2
  25. package/package.json +1 -1
  26. package/scripts/extract-electron.js +105 -0
  27. package/src/main/headless.ts +12 -0
  28. package/src/main/index.ts +8 -0
  29. package/src/main/monitor.ts +540 -0
  30. package/src/main/orchestrator.ts +10 -0
  31. package/src/main/pty.ts +5 -0
  32. package/src/main/tui.ts +37 -3
  33. package/src/preload/index.ts +4 -0
  34. package/src/renderer/App.tsx +9 -1
  35. package/src/renderer/components/Resources.tsx +217 -0
  36. package/src/renderer/components/StatusBar.tsx +6 -1
  37. 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
+ }
@@ -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
@@ -39,6 +39,11 @@ export class PtyManager {
39
39
  })
40
40
  }
41
41
 
42
+ /** OS pid of the spawned agent (or ssh), null once it has exited. */
43
+ get pid(): number | null {
44
+ return this.process?.pid ?? null
45
+ }
46
+
42
47
  write(data: string): void {
43
48
  this.process?.write(data)
44
49
  }
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.write(`${ESC}]0;${title}\x07`)
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(`ctlsurf \u00B7 ${agentName} \u00B7 ${statusIcon} ${statusLabel} \u00B7 ${displayCwd}`)
159
+ this.setTerminalTitle(
160
+ `ctlsurf \u00B7 ${agentName} \u00B7 ${statusIcon} ${statusLabel} \u00B7 ${displayCwd}` +
161
+ (resources ? ` \u00B7 ${resources}` : '')
162
+ )
129
163
  }
130
164
 
131
165
  /**
@@ -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> =>