mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-06 21:02:28 +00:00
fleet update
This commit is contained in:
1 parent
75bc92f3a4
commit
d917053daa
15 files changed
+1090
-37
No files matched your search
@@ -0,0 +1,748 @@
|
||||
// One snapshot of the machine and of each area's process tree.
|
||||
// Missing sensors are "unavailable". Nothing here changes the machine.
|
||||
// The health block in fleet.config.json is guidance for the prompts.
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
export const HEALTH_DEFAULTS = {
|
||||
diskFreePct: 10,
|
||||
diskFreeGb: 5,
|
||||
memoryAvailablePct: 15,
|
||||
swapUsedPct: 50,
|
||||
cpuTempC: 90,
|
||||
batteryPct: 20,
|
||||
batteryMinutes: 30,
|
||||
loadPerCore: 1.5,
|
||||
};
|
||||
|
||||
const GB = 1024 ** 3;
|
||||
|
||||
export function healthThresholds(raw) {
|
||||
const src = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
||||
const out = { ...HEALTH_DEFAULTS };
|
||||
for (const key of Object.keys(HEALTH_DEFAULTS)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(src, key)) continue;
|
||||
const n = Number(src[key]);
|
||||
if (Number.isFinite(n) && n >= 0) out[key] = n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseLoadavg(text) {
|
||||
const m = /^(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)/.exec(String(text || '').trim());
|
||||
if (!m) return null;
|
||||
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
||||
}
|
||||
|
||||
export function parseMeminfo(text) {
|
||||
const map = {};
|
||||
for (const line of String(text || '').split('\n')) {
|
||||
const m = /^(\w+):\s+(\d+)\s*kB/i.exec(line);
|
||||
if (m) map[m[1]] = Number(m[2]) * 1024;
|
||||
}
|
||||
if (map.MemTotal == null) return null;
|
||||
const total = map.MemTotal;
|
||||
const available = map.MemAvailable ?? map.MemFree ?? null;
|
||||
const free = map.MemFree ?? null;
|
||||
const swapTotal = map.SwapTotal ?? 0;
|
||||
const swapFree = map.SwapFree ?? (swapTotal ? 0 : null);
|
||||
return {
|
||||
totalBytes: total,
|
||||
availableBytes: available,
|
||||
freeBytes: free,
|
||||
usedBytes: available != null ? Math.max(0, total - available) : null,
|
||||
swapTotalBytes: map.SwapTotal == null ? null : swapTotal,
|
||||
swapFreeBytes: map.SwapTotal == null ? null : swapFree,
|
||||
swapUsedBytes: map.SwapTotal == null ? null : Math.max(0, swapTotal - (swapFree ?? 0)),
|
||||
};
|
||||
}
|
||||
|
||||
/** /proc/stat "cpu" line, or a sum of os.cpus().times. idle includes iowait. */
|
||||
export function parseCpuStat(text) {
|
||||
const line = String(text || '').split('\n').find((l) => /^cpu\s+/.test(l));
|
||||
if (!line) return null;
|
||||
const n = line.trim().split(/\s+/).slice(1).map(Number);
|
||||
if (n.length < 4 || n.some((x) => !Number.isFinite(x))) return null;
|
||||
const idle = n[3] + (n[4] || 0);
|
||||
const total = n.reduce((a, b) => a + b, 0);
|
||||
return { total, idle };
|
||||
}
|
||||
|
||||
export function cpuTimes(cpus = os.cpus()) {
|
||||
let idle = 0;
|
||||
let total = 0;
|
||||
for (const cpu of cpus || []) {
|
||||
for (const [key, value] of Object.entries(cpu.times || {})) {
|
||||
total += value;
|
||||
if (key === 'idle') idle += value;
|
||||
}
|
||||
}
|
||||
if (total <= 0) return null;
|
||||
return { total, idle };
|
||||
}
|
||||
|
||||
export function cpuUsagePct(before, after) {
|
||||
if (!before || !after) return null;
|
||||
const total = after.total - before.total;
|
||||
const idle = after.idle - before.idle;
|
||||
if (total <= 0) return null;
|
||||
return Math.round((1000 * (total - idle)) / total) / 10;
|
||||
}
|
||||
|
||||
/** One line of /proc/<pid>/stat. rss is pages times pageSize (default 4096). */
|
||||
export function parseProcStat(text, pageSize = 4096) {
|
||||
const s = String(text || '');
|
||||
const end = s.lastIndexOf(')');
|
||||
const space = s.indexOf(' ');
|
||||
if (end < 0 || space < 0) return null;
|
||||
const pid = Number(s.slice(0, space));
|
||||
const rest = s.slice(end + 2).trim().split(/\s+/);
|
||||
if (rest.length < 22) return null;
|
||||
const ppid = Number(rest[1]);
|
||||
const utime = Number(rest[11]);
|
||||
const stime = Number(rest[12]);
|
||||
const rssPages = Number(rest[21]);
|
||||
if (![pid, ppid, utime, stime, rssPages].every(Number.isFinite)) return null;
|
||||
return { pid, ppid, ticks: utime + stime, rssBytes: rssPages * pageSize };
|
||||
}
|
||||
|
||||
export function readProcSnapshot(sysRoot, pageSize = 4096) {
|
||||
const dir = path.join(sysRoot, 'proc');
|
||||
const rows = [];
|
||||
for (const name of safeReaddir(dir)) {
|
||||
if (!/^\d+$/.test(name)) continue;
|
||||
const text = readText(path.join(dir, name, 'stat'));
|
||||
if (!text) continue;
|
||||
const row = parseProcStat(text, pageSize);
|
||||
if (row) rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* CPU% and RSS for rootPid and every descendant.
|
||||
* ticks are USER_HZ (default 100). A process that appears only in `after`
|
||||
* counts its whole tick count. cpuPct may exceed 100 when several cores are busy.
|
||||
*/
|
||||
export function treeUse(before, after, rootPid, elapsedMs, hz = 100) {
|
||||
const rows = [];
|
||||
const prev = new Map((before || []).map((p) => [p.pid, p]));
|
||||
const elapsedTicks = (Number(elapsedMs) / 1000) * hz;
|
||||
for (const now of after || []) {
|
||||
const was = prev.get(now.pid);
|
||||
const delta = was ? Math.max(0, now.ticks - was.ticks) : now.ticks;
|
||||
const cpuPct = elapsedTicks > 0 ? (100 * delta) / elapsedTicks : null;
|
||||
rows.push({ pid: now.pid, ppid: now.ppid, rssBytes: now.rssBytes, cpuPct });
|
||||
}
|
||||
const sum = sumTree(rows, rootPid);
|
||||
if (!sum.found) return sum;
|
||||
return { ...sum, cpuPct: sum.cpuPct == null ? null : Math.round(sum.cpuPct * 10) / 10 };
|
||||
}
|
||||
|
||||
/** rows: { pid, ppid, cpuPct, rssBytes }. cpuPct null stays null if every row is null. */
|
||||
export function sumTree(rows, rootPid) {
|
||||
const byPid = new Map();
|
||||
const children = new Map();
|
||||
for (const row of rows || []) {
|
||||
byPid.set(row.pid, row);
|
||||
if (!children.has(row.ppid)) children.set(row.ppid, []);
|
||||
children.get(row.ppid).push(row.pid);
|
||||
}
|
||||
const seen = new Set();
|
||||
const stack = [rootPid];
|
||||
let rss = 0;
|
||||
let cpu = 0;
|
||||
let cpuKnown = false;
|
||||
let count = 0;
|
||||
while (stack.length) {
|
||||
const pid = stack.pop();
|
||||
if (seen.has(pid)) continue;
|
||||
seen.add(pid);
|
||||
const row = byPid.get(pid);
|
||||
if (!row) continue;
|
||||
count += 1;
|
||||
rss += row.rssBytes || 0;
|
||||
if (row.cpuPct != null) { cpu += row.cpuPct; cpuKnown = true; }
|
||||
for (const kid of children.get(pid) || []) stack.push(kid);
|
||||
}
|
||||
if (!count) return { found: false, cpuPct: null, rssBytes: null, pids: 0 };
|
||||
return { found: true, cpuPct: cpuKnown ? Math.round(cpu * 10) / 10 : null, rssBytes: rss, pids: count };
|
||||
}
|
||||
|
||||
export function parseDf(text) {
|
||||
const lines = String(text || '').split('\n').map((l) => l.trim()).filter(Boolean);
|
||||
if (!lines.length) return null;
|
||||
const line = lines[lines.length - 1];
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length < 6) return null;
|
||||
const total = Number(parts[1]);
|
||||
const used = Number(parts[2]);
|
||||
const free = Number(parts[3]);
|
||||
if (![total, used, free].every((n) => Number.isFinite(n) && n >= 0)) return null;
|
||||
const freePct = total > 0 ? Math.round((1000 * free) / total) / 10 : null;
|
||||
return { mount: parts.slice(5).join(' '), totalBytes: total, usedBytes: used, freeBytes: free, freePct };
|
||||
}
|
||||
|
||||
export function parseVmStat(text) {
|
||||
const page = /page size of (\d+) bytes/i.exec(text || '');
|
||||
const pages = {};
|
||||
for (const line of String(text || '').split('\n')) {
|
||||
const m = /^Pages (.+?):\s+(\d+)/.exec(line.trim());
|
||||
if (m) pages[m[1].toLowerCase()] = Number(m[2]);
|
||||
}
|
||||
return { pageSize: page ? Number(page[1]) : 4096, pages };
|
||||
}
|
||||
|
||||
export function parseSwapUsage(text) {
|
||||
const unit = { K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 };
|
||||
const grab = (name) => {
|
||||
const m = new RegExp(`${name}\\s*=\\s*([\\d.]+)\\s*([KMGT])`, 'i').exec(text || '');
|
||||
if (!m) return null;
|
||||
return Math.round(Number(m[1]) * (unit[m[2].toUpperCase()] || 1));
|
||||
};
|
||||
const total = grab('total');
|
||||
if (total == null) return { totalBytes: null, usedBytes: null, freeBytes: null };
|
||||
return { totalBytes: total, usedBytes: grab('used'), freeBytes: grab('free') };
|
||||
}
|
||||
|
||||
export function parsePmset(text) {
|
||||
const body = String(text || '');
|
||||
if (!body.trim() || /no batteries/i.test(body) && !/\d+\s*%/.test(body)) return emptyBattery();
|
||||
const line = body.split('\n').find((l) => /\d+\s*%/.test(l));
|
||||
if (!line) return emptyBattery();
|
||||
const pct = /(\d+)\s*%/.exec(line);
|
||||
let state = 'unavailable';
|
||||
if (/discharg/i.test(line)) state = 'discharging';
|
||||
else if (/charg(ing|ed)/i.test(line) && !/not charging/i.test(line) && /charging/i.test(line)) state = 'charging';
|
||||
else if (/charged|full/i.test(line)) state = 'full';
|
||||
else if (/not charging/i.test(line)) state = 'not-charging';
|
||||
else if (/AC Power/i.test(body)) state = 'not-charging';
|
||||
else if (/Battery Power/i.test(body)) state = 'discharging';
|
||||
let minutes = null;
|
||||
const tm = /(\d+):(\d+)\s+remaining/.exec(line);
|
||||
if (tm && !/no estimate/i.test(line)) minutes = Number(tm[1]) * 60 + Number(tm[2]);
|
||||
return { present: true, percent: pct ? Number(pct[1]) : null, state, minutesRemaining: minutes };
|
||||
}
|
||||
|
||||
export function parsePs(text) {
|
||||
const rows = [];
|
||||
for (const line of String(text || '').split('\n')) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length < 4) continue;
|
||||
const pid = Number(parts[0]);
|
||||
const ppid = Number(parts[1]);
|
||||
const rssKb = Number(parts[2]);
|
||||
const cpu = Number(parts[3]);
|
||||
if (![pid, ppid, rssKb, cpu].every(Number.isFinite)) continue;
|
||||
rows.push({ pid, ppid, rssBytes: rssKb * 1024, cpuPct: cpu });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function emptyBattery() {
|
||||
return { present: false, percent: null, state: 'unavailable', minutesRemaining: null };
|
||||
}
|
||||
|
||||
function emptyMemory() {
|
||||
return {
|
||||
status: 'unavailable', totalBytes: null, usedBytes: null, freeBytes: null,
|
||||
availableBytes: null, availablePct: null, swapTotalBytes: null, swapUsedBytes: null, swapFreeBytes: null,
|
||||
};
|
||||
}
|
||||
|
||||
function withMemoryPct(mem) {
|
||||
if (!mem || mem.totalBytes == null) return emptyMemory();
|
||||
const availablePct = mem.availableBytes != null && mem.totalBytes > 0
|
||||
? Math.round((1000 * mem.availableBytes) / mem.totalBytes) / 10
|
||||
: null;
|
||||
return { status: 'ok', ...mem, availablePct };
|
||||
}
|
||||
|
||||
function readText(file) {
|
||||
try { return fs.readFileSync(file, 'utf8'); } catch { return null; }
|
||||
}
|
||||
|
||||
function readNum(file) {
|
||||
const text = readText(file);
|
||||
if (text == null) return null;
|
||||
const n = Number(String(text).trim());
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function safeReaddir(dir) {
|
||||
try { return fs.readdirSync(dir); } catch { return []; }
|
||||
}
|
||||
|
||||
function toC(raw) {
|
||||
if (raw == null || !Number.isFinite(raw)) return null;
|
||||
const c = Math.abs(raw) >= 1000 ? raw / 1000 : raw;
|
||||
if (c <= 0 || c > 150) return null;
|
||||
return Math.round(c * 10) / 10;
|
||||
}
|
||||
|
||||
function isCpuName(name) {
|
||||
return /cpu|package|pkg|core|soc|k10temp|zenpower|tdie|tctl/i.test(name || '');
|
||||
}
|
||||
|
||||
export function readLinuxTemps(sysRoot) {
|
||||
const readings = [];
|
||||
const thermal = path.join(sysRoot, 'sys/class/thermal');
|
||||
for (const name of safeReaddir(thermal)) {
|
||||
if (!name.startsWith('thermal_zone')) continue;
|
||||
const dir = path.join(thermal, name);
|
||||
const celsius = toC(readNum(path.join(dir, 'temp')));
|
||||
if (celsius == null) continue;
|
||||
readings.push({ source: 'thermal', name: readText(path.join(dir, 'type'))?.trim() || name, celsius });
|
||||
}
|
||||
const hwmon = path.join(sysRoot, 'sys/class/hwmon');
|
||||
for (const name of safeReaddir(hwmon)) {
|
||||
const dir = path.join(hwmon, name);
|
||||
const chip = readText(path.join(dir, 'name'))?.trim() || name;
|
||||
for (const file of safeReaddir(dir)) {
|
||||
const m = /^temp(\d+)_input$/.exec(file);
|
||||
if (!m) continue;
|
||||
const celsius = toC(readNum(path.join(dir, file)));
|
||||
if (celsius == null) continue;
|
||||
const label = readText(path.join(dir, `temp${m[1]}_label`))?.trim();
|
||||
readings.push({ source: 'hwmon', name: label ? `${chip} ${label}` : chip, celsius });
|
||||
}
|
||||
}
|
||||
const cpuish = readings.filter((r) => isCpuName(r.name));
|
||||
const rest = readings.filter((r) => !isCpuName(r.name));
|
||||
const kept = [...cpuish, ...rest].slice(0, 12);
|
||||
return kept.length ? { status: 'ok', readings: kept } : { status: 'unavailable', readings: [] };
|
||||
}
|
||||
|
||||
function minutesBetween(now, full, rate) {
|
||||
if (now == null || rate == null || rate <= 0) return null;
|
||||
return Math.max(0, Math.round((Math.abs(full == null ? now : full - now) / rate) * 60));
|
||||
}
|
||||
|
||||
export function readLinuxBattery(sysRoot) {
|
||||
const root = path.join(sysRoot, 'sys/class/power_supply');
|
||||
const names = safeReaddir(root).filter((n) => /^(BAT|battery)/i.test(n)).sort();
|
||||
const parsed = [];
|
||||
for (const name of names) {
|
||||
const dir = path.join(root, name);
|
||||
const status = (readText(path.join(dir, 'status')) || '').trim().toLowerCase();
|
||||
const state = status === 'charging' ? 'charging'
|
||||
: status === 'discharging' ? 'discharging'
|
||||
: status === 'full' ? 'full'
|
||||
: status === 'not charging' ? 'not-charging'
|
||||
: 'unavailable';
|
||||
let percent = readNum(path.join(dir, 'capacity'));
|
||||
const energy = readNum(path.join(dir, 'energy_now'));
|
||||
const energyFull = readNum(path.join(dir, 'energy_full'));
|
||||
const power = readNum(path.join(dir, 'power_now'));
|
||||
const charge = readNum(path.join(dir, 'charge_now'));
|
||||
const chargeFull = readNum(path.join(dir, 'charge_full'));
|
||||
const current = readNum(path.join(dir, 'current_now'));
|
||||
if (percent == null && energy != null && energyFull > 0) percent = Math.round((100 * energy) / energyFull);
|
||||
if (percent == null && charge != null && chargeFull > 0) percent = Math.round((100 * charge) / chargeFull);
|
||||
if (percent == null && state === 'unavailable') continue;
|
||||
let minutes = null;
|
||||
if (state === 'discharging') minutes = energy != null ? minutesBetween(energy, null, power) : minutesBetween(charge, null, current);
|
||||
else if (state === 'charging') minutes = energy != null ? minutesBetween(energy, energyFull, power) : minutesBetween(charge, chargeFull, current);
|
||||
parsed.push({ percent, state, minutesRemaining: minutes });
|
||||
}
|
||||
if (!parsed.length) return emptyBattery();
|
||||
const pick = parsed.find((b) => b.state === 'discharging') || parsed[0];
|
||||
return { present: true, count: parsed.length, ...pick };
|
||||
}
|
||||
|
||||
function sh(cmd, args, timeout = 2500) {
|
||||
try {
|
||||
const r = spawnSync(cmd, args, { encoding: 'utf8', timeout });
|
||||
if (r.status !== 0 || r.error) return null;
|
||||
return r.stdout || '';
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function pause(ms) {
|
||||
const n = Number(ms);
|
||||
if (!Number.isFinite(n) || n <= 0) return;
|
||||
const r = spawnSync('sleep', [(n / 1000).toFixed(3)], { timeout: n + 2000 });
|
||||
if (r.error || r.status !== 0) {
|
||||
const end = Date.now() + n;
|
||||
while (Date.now() < end) { /* sleep(1) missing */ }
|
||||
}
|
||||
}
|
||||
|
||||
function pageSize() {
|
||||
const text = sh('getconf', ['PAGE_SIZE'], 1000);
|
||||
const n = Number(String(text || '').trim());
|
||||
return Number.isFinite(n) && n > 0 ? n : 4096;
|
||||
}
|
||||
|
||||
function userHz() {
|
||||
const text = sh('getconf', ['CLK_TCK'], 1000);
|
||||
const n = Number(String(text || '').trim());
|
||||
return Number.isFinite(n) && n > 0 ? n : 100;
|
||||
}
|
||||
|
||||
function existingPath(target) {
|
||||
let dir = target;
|
||||
while (dir) {
|
||||
try { if (fs.existsSync(dir)) return dir; } catch { return null; }
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function dfLive(target, platform) {
|
||||
const gnu = sh('df', ['-B1', '-P', target], 3000);
|
||||
if (gnu && /1B-blocks|1-blocks/.test(gnu)) return parseDf(gnu);
|
||||
const bsd = sh('df', ['-k', '-P', target], 3000);
|
||||
if (bsd) {
|
||||
const parsed = parseDf(bsd);
|
||||
if (parsed) {
|
||||
const scale = /512-blocks/.test(bsd) ? 512 : 1024;
|
||||
return {
|
||||
mount: parsed.mount,
|
||||
totalBytes: parsed.totalBytes * scale,
|
||||
usedBytes: parsed.usedBytes * scale,
|
||||
freeBytes: parsed.freeBytes * scale,
|
||||
freePct: parsed.freePct,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (gnu) return parseDf(gnu);
|
||||
if (platform === 'win32') return winDisk(target);
|
||||
return null;
|
||||
}
|
||||
|
||||
function winDisk(target) {
|
||||
const script = `$p=${JSON.stringify(target)}; $q=Split-Path -Qualifier $p; if (-not $q) { $q='C:' }; $d=New-Object System.IO.DriveInfo($q); if (-not $d.IsReady) { exit 1 }; Write-Output ($d.TotalSize.ToString()+' '+($d.TotalSize-$d.AvailableFreeSpace).ToString()+' '+$d.AvailableFreeSpace.ToString()+' '+$d.Name)`;
|
||||
const text = sh('powershell', ['-NoProfile', '-Command', script], 4000);
|
||||
if (!text) return null;
|
||||
const parts = text.trim().split(/\s+/);
|
||||
if (parts.length < 4) return null;
|
||||
const total = Number(parts[0]);
|
||||
const used = Number(parts[1]);
|
||||
const free = Number(parts[2]);
|
||||
if (![total, used, free].every(Number.isFinite)) return null;
|
||||
return { mount: parts.slice(3).join(' '), totalBytes: total, usedBytes: used, freeBytes: free, freePct: total ? Math.round((1000 * free) / total) / 10 : null };
|
||||
}
|
||||
|
||||
function detectWsl(sysRoot, opt) {
|
||||
if (opt.wsl != null) return Boolean(opt.wsl);
|
||||
const version = readText(path.join(sysRoot, 'proc/version'));
|
||||
if (version) return /microsoft|wsl/i.test(version);
|
||||
return Boolean(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP);
|
||||
}
|
||||
|
||||
function diskTargets(c) {
|
||||
return [
|
||||
['repo', c?.repo || '/'],
|
||||
['worktree', c?.worktreeRoot || c?.repo || '/'],
|
||||
['state', c?.stateDir || '/'],
|
||||
['tmp', os.tmpdir()],
|
||||
];
|
||||
}
|
||||
|
||||
function readDisks(c, platform, opt) {
|
||||
return diskTargets(c).map(([label, requested]) => {
|
||||
const parsed = opt.df
|
||||
? opt.df(requested)
|
||||
: dfLive(existingPath(requested) || requested, platform);
|
||||
if (!parsed || parsed.freeBytes == null) {
|
||||
return { label, path: requested, mount: null, totalBytes: null, usedBytes: null, freeBytes: null, freePct: null, status: 'unavailable' };
|
||||
}
|
||||
return { label, path: requested, status: 'ok', mount: parsed.mount ?? null, totalBytes: parsed.totalBytes, usedBytes: parsed.usedBytes, freeBytes: parsed.freeBytes, freePct: parsed.freePct };
|
||||
});
|
||||
}
|
||||
|
||||
function readLinuxMemory(sysRoot) {
|
||||
return withMemoryPct(parseMeminfo(readText(path.join(sysRoot, 'proc/meminfo')) || ''));
|
||||
}
|
||||
|
||||
function readGenericMemory() {
|
||||
const total = os.totalmem();
|
||||
const free = os.freemem();
|
||||
if (!total) return emptyMemory();
|
||||
return withMemoryPct({
|
||||
totalBytes: total,
|
||||
availableBytes: free,
|
||||
freeBytes: free,
|
||||
usedBytes: Math.max(0, total - free),
|
||||
swapTotalBytes: null,
|
||||
swapFreeBytes: null,
|
||||
swapUsedBytes: null,
|
||||
});
|
||||
}
|
||||
|
||||
function readDarwinMemory() {
|
||||
const total = Number(String(sh('sysctl', ['-n', 'hw.memsize']) || '').trim());
|
||||
const vm = parseVmStat(sh('vm_stat', []) || '');
|
||||
const swap = parseSwapUsage(sh('sysctl', ['-n', 'vm.swapusage']) || '');
|
||||
if (!Number.isFinite(total) || total <= 0) return readGenericMemory();
|
||||
const page = vm.pageSize || 4096;
|
||||
const availablePages = (vm.pages.free || 0) + (vm.pages.inactive || 0) + (vm.pages.speculative || 0);
|
||||
const available = Math.min(total, availablePages * page);
|
||||
return withMemoryPct({
|
||||
totalBytes: total,
|
||||
availableBytes: available,
|
||||
freeBytes: (vm.pages.free || 0) * page,
|
||||
usedBytes: Math.max(0, total - available),
|
||||
swapTotalBytes: swap.totalBytes,
|
||||
swapFreeBytes: swap.freeBytes,
|
||||
swapUsedBytes: swap.usedBytes,
|
||||
});
|
||||
}
|
||||
|
||||
function readDarwinBattery() {
|
||||
const text = sh('pmset', ['-g', 'batt'], 3000);
|
||||
if (!text) return emptyBattery();
|
||||
return parsePmset(text);
|
||||
}
|
||||
|
||||
function readWindowsBattery() {
|
||||
const text = sh('powershell', ['-NoProfile', '-Command', '(Get-CimInstance Win32_Battery -ErrorAction SilentlyContinue | Select-Object -First 1 EstimatedChargeRemaining,BatteryStatus | ConvertTo-Json -Compress)'], 4000);
|
||||
if (!text || !text.trim() || text.trim() === 'null') return emptyBattery();
|
||||
try {
|
||||
const row = JSON.parse(text);
|
||||
if (!row || row.EstimatedChargeRemaining == null) return emptyBattery();
|
||||
const code = Number(row.BatteryStatus);
|
||||
const state = [6, 7, 8, 9].includes(code) ? 'charging'
|
||||
: code === 1 || code === 4 || code === 5 ? 'discharging'
|
||||
: code === 2 || code === 3 ? 'full'
|
||||
: 'unavailable';
|
||||
return { present: true, percent: Number(row.EstimatedChargeRemaining), state, minutesRemaining: null };
|
||||
} catch { return emptyBattery(); }
|
||||
}
|
||||
|
||||
function linuxSample(sysRoot, opt) {
|
||||
if (opt.statTexts || opt.procSamples) {
|
||||
return {
|
||||
usagePct: opt.statTexts ? cpuUsagePct(parseCpuStat(opt.statTexts[0]), parseCpuStat(opt.statTexts[1])) : null,
|
||||
before: opt.procSamples ? opt.procSamples[0] : [],
|
||||
after: opt.procSamples ? opt.procSamples[1] : [],
|
||||
elapsedMs: opt.elapsedMs ?? 1000,
|
||||
};
|
||||
}
|
||||
if (opt.sampleMs === 0) return { usagePct: null, before: [], after: [], elapsedMs: 0 };
|
||||
const stat1 = parseCpuStat(readText(path.join(sysRoot, 'proc/stat')) || '');
|
||||
const pages = pageSize();
|
||||
const before = readProcSnapshot(sysRoot, pages);
|
||||
const t0 = Date.now();
|
||||
pause(opt.sampleMs ?? 200);
|
||||
const stat2 = parseCpuStat(readText(path.join(sysRoot, 'proc/stat')) || '');
|
||||
const after = readProcSnapshot(sysRoot, pages);
|
||||
let usagePct = cpuUsagePct(stat1, stat2);
|
||||
if (usagePct == null) {
|
||||
const a = cpuTimes();
|
||||
pause(opt.sampleMs ?? 200);
|
||||
usagePct = cpuUsagePct(a, cpuTimes());
|
||||
}
|
||||
return { usagePct, before, after, elapsedMs: Math.max(1, Date.now() - t0) };
|
||||
}
|
||||
|
||||
function readLoad(platform, sysRoot) {
|
||||
if (platform === 'linux') {
|
||||
const parsed = parseLoadavg(readText(path.join(sysRoot, 'proc/loadavg')) || '');
|
||||
if (parsed) return parsed;
|
||||
const live = os.loadavg();
|
||||
return live.some((n) => n > 0) ? live : [null, null, null];
|
||||
}
|
||||
if (platform === 'win32') return [null, null, null];
|
||||
return os.loadavg();
|
||||
}
|
||||
|
||||
function listPanes(c, opt) {
|
||||
if (opt.panes) return opt.panes;
|
||||
const session = `fleet-${c?.project || 'fleet'}`.replace(/[.:]/g, '-');
|
||||
return (c?.areas || []).map((area) => {
|
||||
const r = sh('tmux', ['list-panes', '-t', `${session}:${area.id}`, '-F', '#{pane_pid}'], 2000);
|
||||
const pid = Number(String(r || '').trim().split(/\s+/)[0]);
|
||||
return { id: area.id, pid: Number.isInteger(pid) && pid > 0 ? pid : null };
|
||||
});
|
||||
}
|
||||
|
||||
function areasFromTree(panes, before, after, elapsedMs, hz) {
|
||||
return (panes || []).map((pane) => {
|
||||
if (!pane.pid) return { id: pane.id, found: false, cpuPct: null, rssBytes: null, pids: 0 };
|
||||
return { id: pane.id, ...treeUse(before, after, pane.pid, elapsedMs, hz) };
|
||||
});
|
||||
}
|
||||
|
||||
function areasFromPs(panes, rows) {
|
||||
return (panes || []).map((pane) => {
|
||||
if (!pane.pid) return { id: pane.id, found: false, cpuPct: null, rssBytes: null, pids: 0 };
|
||||
return { id: pane.id, ...sumTree(rows, pane.pid) };
|
||||
});
|
||||
}
|
||||
|
||||
function readPsAreas(panes, opt) {
|
||||
const text = opt.psText != null ? opt.psText : sh('ps', ['-ax', '-o', 'pid=,ppid=,rss=,%cpu='], 3000);
|
||||
if (!text) return (panes || []).map((pane) => ({ id: pane.id, found: Boolean(pane.pid), cpuPct: null, rssBytes: null, pids: pane.pid ? null : 0 }));
|
||||
return areasFromPs(panes, parsePs(text));
|
||||
}
|
||||
|
||||
function fmtBytes(bytes) {
|
||||
if (bytes == null) return 'unavailable';
|
||||
const gb = bytes / GB;
|
||||
if (gb >= 1) return `${Math.round(gb * 10) / 10} GB`;
|
||||
return `${Math.round(bytes / (1024 ** 2))} MB`;
|
||||
}
|
||||
|
||||
function judge(snap, limits) {
|
||||
const crossed = [];
|
||||
const mem = snap.memory;
|
||||
if (mem?.status === 'ok' && mem.availableBytes != null && mem.totalBytes > 0) {
|
||||
const pct = (100 * mem.availableBytes) / mem.totalBytes;
|
||||
if (pct < limits.memoryAvailablePct) {
|
||||
crossed.push({ id: 'memory', words: `memory available ${Math.round(pct)}% (${fmtBytes(mem.availableBytes)} of ${fmtBytes(mem.totalBytes)}), under ${limits.memoryAvailablePct}%` });
|
||||
}
|
||||
}
|
||||
if (mem?.swapTotalBytes > 0 && mem.swapUsedBytes != null) {
|
||||
const pct = (100 * mem.swapUsedBytes) / mem.swapTotalBytes;
|
||||
if (pct >= limits.swapUsedPct) {
|
||||
crossed.push({ id: 'swap', words: `swap ${Math.round(pct)}% used, at or over ${limits.swapUsedPct}%` });
|
||||
}
|
||||
}
|
||||
const seen = new Set();
|
||||
for (const disk of snap.disks || []) {
|
||||
if (disk.status !== 'ok' || disk.freeBytes == null) continue;
|
||||
const key = disk.mount || disk.path;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const freeGb = disk.freeBytes / GB;
|
||||
const lowPct = disk.freePct != null && disk.freePct < limits.diskFreePct;
|
||||
const lowGb = freeGb < limits.diskFreeGb;
|
||||
if (!lowPct && !lowGb) continue;
|
||||
const labels = snap.disks.filter((row) => row.status === 'ok' && (row.mount || row.path) === key).map((row) => row.label).join(', ');
|
||||
const pct = disk.freePct == null ? '' : ` (${Math.round(disk.freePct)}%)`;
|
||||
crossed.push({ id: `disk:${key}`, words: `${labels} on ${key}: ${fmtBytes(disk.freeBytes)} free${pct}, under ${limits.diskFreeGb} GB or ${limits.diskFreePct}%` });
|
||||
}
|
||||
const readings = snap.temps?.readings || [];
|
||||
const cpuish = readings.filter((r) => isCpuName(r.name));
|
||||
const pool = cpuish.length ? cpuish : readings;
|
||||
if (pool.length) {
|
||||
const hot = pool.reduce((a, b) => (b.celsius > a.celsius ? b : a));
|
||||
if (hot.celsius >= limits.cpuTempC) {
|
||||
crossed.push({ id: 'temp', words: `${hot.name} ${hot.celsius}°C, at or over ${limits.cpuTempC}°C` });
|
||||
}
|
||||
}
|
||||
const battery = snap.battery;
|
||||
if (battery?.present && battery.percent != null && battery.percent <= limits.batteryPct && battery.state !== 'charging' && battery.state !== 'full') {
|
||||
const how = battery.state === 'discharging' ? ' and discharging' : '';
|
||||
crossed.push({ id: 'battery', words: `battery ${battery.percent}%${how}, at or under ${limits.batteryPct}%` });
|
||||
} else if (battery?.present && battery.state === 'discharging' && battery.minutesRemaining != null && battery.minutesRemaining <= limits.batteryMinutes) {
|
||||
crossed.push({ id: 'battery', words: `battery discharging with about ${battery.minutesRemaining} min left, at or under ${limits.batteryMinutes} min` });
|
||||
}
|
||||
if (snap.cpu?.load1 != null && snap.cpu.cores) {
|
||||
const per = snap.cpu.load1 / snap.cpu.cores;
|
||||
if (per >= limits.loadPerCore) {
|
||||
crossed.push({ id: 'load', words: `load ${snap.cpu.load1} on ${snap.cpu.cores} cores (${Math.round(per * 10) / 10} per core), at or over ${limits.loadPerCore}` });
|
||||
}
|
||||
}
|
||||
return crossed;
|
||||
}
|
||||
|
||||
function verdictOf(crossed, battery) {
|
||||
const onBattery = battery?.present && battery.state === 'discharging';
|
||||
if (!crossed.length && onBattery) {
|
||||
const left = battery.minutesRemaining == null ? 'time left unavailable' : `about ${battery.minutesRemaining} min left`;
|
||||
const pct = battery.percent == null ? 'unknown %' : `${battery.percent}%`;
|
||||
return `ok: on battery at ${pct}, ${left}; nothing crossed the suggested limits`;
|
||||
}
|
||||
if (!crossed.length) return 'ok: nothing crossed the suggested limits';
|
||||
return `tight: ${crossed.map((row) => row.words).join('; ')}`;
|
||||
}
|
||||
|
||||
export function formatHealth(snap) {
|
||||
const lines = [snap.verdict];
|
||||
const cpu = snap.cpu || {};
|
||||
const load = cpu.load1 == null ? 'load unavailable' : `load ${cpu.load1} ${cpu.load5} ${cpu.load15}`;
|
||||
const usage = cpu.usagePct == null ? 'usage unavailable' : `usage ${cpu.usagePct}%`;
|
||||
lines.push(`cpu ${load} ${cpu.cores ?? '?'} cores ${usage}${snap.wsl ? ' wsl' : ''}`);
|
||||
const mem = snap.memory;
|
||||
if (!mem || mem.status !== 'ok') lines.push('mem unavailable');
|
||||
else {
|
||||
const swap = mem.swapTotalBytes == null ? 'swap unavailable' : mem.swapTotalBytes === 0 ? 'swap none' : `swap ${fmtBytes(mem.swapUsedBytes)} / ${fmtBytes(mem.swapTotalBytes)}`;
|
||||
lines.push(`mem ${fmtBytes(mem.usedBytes)} used / ${fmtBytes(mem.totalBytes)} available ${fmtBytes(mem.availableBytes)} (${mem.availablePct ?? '?'}%) ${swap}`);
|
||||
}
|
||||
for (const disk of snap.disks || []) {
|
||||
if (disk.status !== 'ok') lines.push(`disk ${disk.label} unavailable ${disk.path}`);
|
||||
else lines.push(`disk ${disk.label} ${disk.mount} ${fmtBytes(disk.freeBytes)} free (${disk.freePct}%) ${disk.path}`);
|
||||
}
|
||||
if (!snap.temps || snap.temps.status !== 'ok') lines.push('temp unavailable');
|
||||
else lines.push(`temp ${snap.temps.readings.map((r) => `${r.name} ${r.celsius}°C`).join(' ')}`);
|
||||
const battery = snap.battery || emptyBattery();
|
||||
if (!battery.present) lines.push('battery none');
|
||||
else {
|
||||
const left = battery.minutesRemaining == null ? 'time unavailable' : `${battery.minutesRemaining} min left`;
|
||||
lines.push(`battery ${battery.percent ?? '?'}% ${battery.state} ${left}`);
|
||||
}
|
||||
if (!snap.areas?.length) lines.push('areas none');
|
||||
for (const area of snap.areas || []) {
|
||||
if (!area.found) lines.push(`area ${area.id} window not found`);
|
||||
else if (area.cpuPct == null && area.rssBytes == null) lines.push(`area ${area.id} unavailable`);
|
||||
else lines.push(`area ${area.id} cpu ${area.cpuPct ?? '?'}% rss ${fmtBytes(area.rssBytes)} ${area.pids ?? '?'} procs`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function fleetHealth(c = {}, opt = {}) {
|
||||
const platform = opt.platform || process.platform;
|
||||
const sysRoot = opt.sysRoot || '/';
|
||||
const limits = healthThresholds(c.health);
|
||||
const wsl = platform === 'linux' ? detectWsl(sysRoot, opt) : false;
|
||||
const cores = opt.cores ?? (os.cpus()?.length || null);
|
||||
const needsLinuxSample = platform === 'linux' && (opt.cpu == null || opt.areas == null);
|
||||
const sampled = needsLinuxSample ? linuxSample(sysRoot, opt) : null;
|
||||
const load = readLoad(platform, sysRoot);
|
||||
const cpu = opt.cpu ?? {
|
||||
load1: load[0],
|
||||
load5: load[1],
|
||||
load15: load[2],
|
||||
cores,
|
||||
usagePct: platform === 'linux'
|
||||
? sampled?.usagePct ?? null
|
||||
: (opt.sampleMs === 0 ? null : cpuUsagePct(cpuTimes(), (pause(opt.sampleMs ?? 200), cpuTimes()))),
|
||||
};
|
||||
const memory = opt.memory
|
||||
? withMemoryPct(opt.memory.status === 'unavailable' ? null : opt.memory)
|
||||
: platform === 'linux' ? readLinuxMemory(sysRoot)
|
||||
: platform === 'darwin' ? readDarwinMemory()
|
||||
: readGenericMemory();
|
||||
const disks = opt.disks ?? readDisks(c, platform, opt);
|
||||
const temps = opt.temps ?? (platform === 'linux' ? readLinuxTemps(sysRoot) : { status: 'unavailable', readings: [] });
|
||||
const battery = opt.battery ?? (
|
||||
platform === 'linux' ? readLinuxBattery(sysRoot)
|
||||
: platform === 'darwin' ? readDarwinBattery()
|
||||
: platform === 'win32' ? readWindowsBattery()
|
||||
: emptyBattery()
|
||||
);
|
||||
let areas = opt.areas;
|
||||
if (areas == null) {
|
||||
const panes = listPanes(c, opt);
|
||||
if (platform === 'linux' && sampled && (sampled.before.length || sampled.after.length || opt.procSamples || opt.sampleMs === 0)) {
|
||||
areas = areasFromTree(panes, sampled.before, sampled.after, sampled.elapsedMs, opt.hz ?? userHz());
|
||||
} else if (platform === 'linux' && opt.sampleMs !== 0) {
|
||||
areas = areasFromTree(panes, sampled?.before || [], sampled?.after || [], sampled?.elapsedMs || 0, opt.hz ?? userHz());
|
||||
if (areas.every((a) => !a.found) && panes.some((p) => p.pid) && !opt.procSamples) areas = readPsAreas(panes, opt);
|
||||
} else {
|
||||
areas = readPsAreas(panes, opt);
|
||||
}
|
||||
}
|
||||
const snap = {
|
||||
at: opt.at || new Date().toISOString(),
|
||||
platform,
|
||||
wsl,
|
||||
cpu,
|
||||
memory,
|
||||
disks,
|
||||
temps,
|
||||
battery,
|
||||
areas,
|
||||
thresholds: limits,
|
||||
};
|
||||
snap.crossed = judge(snap, limits);
|
||||
snap.verdict = verdictOf(snap.crossed, snap.battery);
|
||||
return snap;
|
||||
}
|
||||
+183
-25
@@ -2,8 +2,8 @@
|
||||
// both call these. Do not copy this logic into a plugin or a second server.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { area as getArea, die, pidAlive, tryGit, git, mainRef, writeAtomic, today, landerIsUp, matchesAny, startLander, runTimedSync } from '../lib.mjs';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { KIT, area as getArea, die, pidAlive, tryGit, git, mainRef, writeAtomic, today, landerIsUp, matchesAny, startLander, runTimedSync } from '../lib.mjs';
|
||||
import { send as mailboxSendFile, read as mailboxReadDir, archive as mailboxArchiveDir } from '../../adapters/mailbox.mjs';
|
||||
import { appendWakeLog, priorWakes } from './wake-log.mjs';
|
||||
|
||||
@@ -210,11 +210,14 @@ export function hearInterpreter(c) {
|
||||
return readMailbox(c, 'interpreter');
|
||||
}
|
||||
|
||||
const NUDGE_PAUSE_MS = 200;
|
||||
const NUDGE_SETTLE_MS = 150;
|
||||
const NUDGE_RETRY_MS = 400;
|
||||
// Backoff before each Enter. Codex's paste window outlasts a single short pause.
|
||||
// 250 + 800 + 2000, plus a short settle after each Enter, stays under 4s.
|
||||
const ENTER_DELAYS_MS = [250, 800, 2000];
|
||||
const ENTER_SETTLE_MS = 150;
|
||||
const IDLE_AFTER_MS = 3000;
|
||||
const RECOVER_AFTER_MS = 120000;
|
||||
const WAKE_WAIT_POLL_MS = 3000;
|
||||
const WAKE_WAIT_MAX_MS = 600000;
|
||||
const BUSY_RE = /esc to interrupt|esc to cancel|ctrl\+c to (?:interrupt|cancel)/i;
|
||||
|
||||
export function nudgeLine(from) {
|
||||
@@ -269,12 +272,118 @@ function paneTail(pane) {
|
||||
return rows.map((s) => s.slice(0, 120)).join(' | ').slice(0, 400);
|
||||
}
|
||||
|
||||
function snapBefore(before) {
|
||||
return { found: Boolean(before?.found), dead: Boolean(before?.dead), interrupt: Boolean(before?.interrupt), idleSec: before?.idleSec ?? null };
|
||||
}
|
||||
|
||||
/** Window names in the fleet session, so a miss says what was there. */
|
||||
function sessionWindows(c) {
|
||||
const session = `fleet-${c.project}`.replace(/[.:]/g, '-');
|
||||
const has = tmuxText(['has-session', '-t', session]);
|
||||
const listed = tmuxText(['list-windows', '-t', session, '-F', '#{window_index} #{window_name}']);
|
||||
return {
|
||||
session,
|
||||
sessionUp: has.status === 0,
|
||||
windows: listed.status === 0 ? listed.stdout.split('\n').map((s) => s.trim()).filter(Boolean) : [],
|
||||
listError: listed.status === 0 ? '' : String(listed.stderr || has.stderr || '').trim().slice(0, 200),
|
||||
};
|
||||
}
|
||||
|
||||
function wakeLockPath(c, areaId) {
|
||||
return path.join(c.stateDir, 'wake-wait', `${areaId}.lock`);
|
||||
}
|
||||
|
||||
function claimWakeLock(lock) {
|
||||
fs.mkdirSync(path.dirname(lock), { recursive: true });
|
||||
const body = `${JSON.stringify({ pid: process.pid, starting: true, t: new Date().toISOString() })}\n`;
|
||||
const create = () => {
|
||||
const fd = fs.openSync(lock, 'wx');
|
||||
fs.writeSync(fd, body);
|
||||
fs.closeSync(fd);
|
||||
};
|
||||
try {
|
||||
create();
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e.code !== 'EEXIST') return false;
|
||||
let cur = {};
|
||||
try { cur = JSON.parse(fs.readFileSync(lock, 'utf8')); } catch { /* empty or torn */ }
|
||||
if (cur.pid && pidAlive(cur.pid)) return false;
|
||||
try { fs.unlinkSync(lock); } catch { return false; }
|
||||
try { create(); return true; } catch { return false; }
|
||||
}
|
||||
}
|
||||
|
||||
function handDeferred(c, areaId, from, file) {
|
||||
const lock = wakeLockPath(c, areaId);
|
||||
if (!claimWakeLock(lock)) return { started: false };
|
||||
const poll = Number(c.wakeWait?.pollMs) > 0 ? Number(c.wakeWait.pollMs) : WAKE_WAIT_POLL_MS;
|
||||
const max = Number(c.wakeWait?.maxMs) > 0 ? Number(c.wakeWait.maxMs) : WAKE_WAIT_MAX_MS;
|
||||
const child = spawn(process.execPath, [
|
||||
path.join(KIT, 'bin/core/wake-wait.mjs'),
|
||||
'--config', c.file,
|
||||
'--area', areaId,
|
||||
'--from', from,
|
||||
'--file', file || '',
|
||||
'--poll-ms', String(poll),
|
||||
'--max-ms', String(max),
|
||||
], { detached: true, stdio: 'ignore' });
|
||||
if (!child.pid) {
|
||||
try { fs.unlinkSync(lock); } catch { /* already gone */ }
|
||||
return { started: false };
|
||||
}
|
||||
fs.writeFileSync(lock, `${JSON.stringify({ pid: child.pid, area: areaId, file: file || null, from, t: new Date().toISOString() })}\n`);
|
||||
child.unref();
|
||||
return { started: true, pid: child.pid };
|
||||
}
|
||||
|
||||
function releaseWakeLock(c, areaId) {
|
||||
try { fs.unlinkSync(wakeLockPath(c, areaId)); } catch { /* already gone */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Type the wake line and press Enter until the cursor row no longer holds it.
|
||||
* One literal send-keys -l, then Enter on its own. The fake pane does not
|
||||
* treat chunked keys differently, so the backoff is what survives a long paste.
|
||||
*/
|
||||
function submitWake(c, areaId, from, meta, before) {
|
||||
const target = tmuxTarget(c, areaId);
|
||||
const line = nudgeLine(from);
|
||||
const record = {
|
||||
t: new Date().toISOString(),
|
||||
area: areaId,
|
||||
window: target,
|
||||
file: meta.file || null,
|
||||
trigger: meta.trigger || 'send',
|
||||
before: snapBefore(before),
|
||||
};
|
||||
const finish = (fields) => {
|
||||
const { line: _line, reason, ...logged } = fields;
|
||||
appendWakeLog(c, { ...record, ...logged, tail: paneTail(capturePane(target)) });
|
||||
return { submitted: logged.outcome === 'submitted', reason, line };
|
||||
};
|
||||
if (tmuxText(['send-keys', '-t', target, '-l', line]).status !== 0) {
|
||||
return finish({ action: 'send-failed', enterAttempts: 0, enterDelays: [], outcome: 'send-failed', reason: 'send-failed', line });
|
||||
}
|
||||
const enterDelays = [];
|
||||
let enterAttempts = 0;
|
||||
for (const delay of ENTER_DELAYS_MS) {
|
||||
sleepMs(delay);
|
||||
enterDelays.push(delay);
|
||||
tmuxText(['send-keys', '-t', target, 'Enter']);
|
||||
enterAttempts += 1;
|
||||
sleepMs(ENTER_SETTLE_MS);
|
||||
if (!stillInInput(target, line)) {
|
||||
return finish({ action: 'typed', enterAttempts, enterDelays, outcome: 'submitted', reason: 'submitted', line });
|
||||
}
|
||||
}
|
||||
return finish({ action: 'typed', enterAttempts, enterDelays, outcome: 'swallowed', reason: 'not-submitted', line });
|
||||
}
|
||||
|
||||
/**
|
||||
* Wake an open tmux window with a short line, not the mail body.
|
||||
* Codex and Claude treat a fast burst as a paste and swallow Enter, so the
|
||||
* line is typed, then Enter is sent after a pause, outside that burst.
|
||||
* If the cursor row still holds the line, Enter is sent once more.
|
||||
* A pane that shows an interrupt hint is left alone; a later status check can nudge it.
|
||||
* Enter is retried with backoff until the line leaves the composer.
|
||||
* A pane showing an interrupt hint is handed to one detached waiter.
|
||||
*/
|
||||
export function nudgeMail(c, areaId, from, meta = {}) {
|
||||
const target = tmuxTarget(c, areaId);
|
||||
@@ -285,29 +394,78 @@ export function nudgeMail(c, areaId, from, meta = {}) {
|
||||
window: target,
|
||||
file: meta.file || null,
|
||||
trigger: meta.trigger || 'send',
|
||||
before: { found: before.found, dead: before.dead, interrupt: before.interrupt, idleSec: before.idleSec },
|
||||
before: snapBefore(before),
|
||||
};
|
||||
const finish = (fields) => {
|
||||
const { line, reason, ...logged } = fields;
|
||||
const pane = logged.action === 'no-window' ? '' : capturePane(target);
|
||||
appendWakeLog(c, { ...record, ...logged, tail: paneTail(pane) });
|
||||
const where = logged.action === 'no-window' ? { lookup: target, ...sessionWindows(c) } : {};
|
||||
appendWakeLog(c, { ...record, ...logged, ...where, tail: paneTail(pane) });
|
||||
return { submitted: logged.outcome === 'submitted', reason, ...(line ? { line } : {}) };
|
||||
};
|
||||
if (!before.found) return finish({ action: 'no-window', enterAttempts: 0, outcome: 'no-window', reason: 'no-window' });
|
||||
if (!before.found) return finish({ action: 'no-window', enterAttempts: 0, enterDelays: [], outcome: 'no-window', reason: 'no-window' });
|
||||
const line = nudgeLine(from);
|
||||
if (before.interrupt) return finish({ action: 'skipped-busy', enterAttempts: 0, outcome: 'skipped-busy', reason: 'working', line });
|
||||
if (tmuxText(['send-keys', '-t', target, '-l', line]).status !== 0) return finish({ action: 'send-failed', enterAttempts: 0, outcome: 'send-failed', reason: 'send-failed', line });
|
||||
let enterAttempts = 0;
|
||||
const hitEnter = () => { tmuxText(['send-keys', '-t', target, 'Enter']); enterAttempts += 1; };
|
||||
sleepMs(NUDGE_PAUSE_MS);
|
||||
hitEnter();
|
||||
sleepMs(NUDGE_SETTLE_MS);
|
||||
if (!stillInInput(target, line)) return finish({ action: 'typed', enterAttempts, outcome: 'submitted', reason: 'submitted', line });
|
||||
sleepMs(NUDGE_RETRY_MS);
|
||||
hitEnter();
|
||||
sleepMs(NUDGE_SETTLE_MS);
|
||||
const submitted = !stillInInput(target, line);
|
||||
return finish({ action: 'typed', enterAttempts, outcome: submitted ? 'submitted' : 'swallowed', reason: submitted ? 'submitted' : 'not-submitted', line });
|
||||
if (before.interrupt) {
|
||||
const hand = handDeferred(c, areaId, from, meta.file || '');
|
||||
return finish({
|
||||
action: 'deferred',
|
||||
outcome: hand.started ? 'deferred' : 'waiter-running',
|
||||
enterAttempts: 0,
|
||||
enterDelays: [],
|
||||
waiterPid: hand.pid || null,
|
||||
reason: 'working',
|
||||
line,
|
||||
});
|
||||
}
|
||||
return submitWake(c, areaId, from, meta, before);
|
||||
}
|
||||
|
||||
/** Poll until the pane is idle, the mail is read, or the window closes. One process per area. */
|
||||
export function runWakeWait(c, { area, from, file, pollMs, maxMs } = {}) {
|
||||
if (!area) die('wake-wait needs --area');
|
||||
const poll = Number(pollMs) > 0 ? Number(pollMs) : WAKE_WAIT_POLL_MS;
|
||||
const max = Number(maxMs) > 0 ? Number(maxMs) : WAKE_WAIT_MAX_MS;
|
||||
const target = tmuxTarget(c, area);
|
||||
const started = Date.now();
|
||||
const release = () => releaseWakeLock(c, area);
|
||||
const log = (fields) => {
|
||||
const where = fields.outcome === 'no-window' ? { lookup: target, ...sessionWindows(c) } : {};
|
||||
appendWakeLog(c, {
|
||||
t: new Date().toISOString(),
|
||||
area,
|
||||
window: target,
|
||||
file: file || null,
|
||||
trigger: 'deferred',
|
||||
...fields,
|
||||
...where,
|
||||
tail: fields.outcome === 'no-window' ? '' : paneTail(capturePane(target)),
|
||||
});
|
||||
release();
|
||||
};
|
||||
const unread = () => file && fs.existsSync(path.join(c.mailbox.dir, area, file));
|
||||
while (Date.now() - started < max) {
|
||||
const left = max - (Date.now() - started);
|
||||
if (left <= 0) break;
|
||||
sleepMs(Math.min(poll, left));
|
||||
if (file && !unread()) {
|
||||
return log({ action: 'already-read', outcome: 'already-read', enterAttempts: 0, enterDelays: [], before: { found: true, dead: false, interrupt: false, idleSec: null } });
|
||||
}
|
||||
const before = inspectPane(target);
|
||||
if (!before.found) {
|
||||
return log({ action: 'no-window', outcome: 'no-window', enterAttempts: 0, enterDelays: [], before: snapBefore(before) });
|
||||
}
|
||||
if (before.dead) {
|
||||
return log({ action: 'no-window', outcome: 'dead', enterAttempts: 0, enterDelays: [], before: snapBefore(before) });
|
||||
}
|
||||
if (before.interrupt) continue;
|
||||
try {
|
||||
return submitWake(c, area, from, { trigger: 'deferred', file: file || null }, before);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
const before = inspectPane(target);
|
||||
return log({ action: 'gave-up', outcome: 'timeout', enterAttempts: 0, enterDelays: [], before: snapBefore(before) });
|
||||
}
|
||||
|
||||
function unreadFiles(c, id) {
|
||||
|
||||
@@ -41,15 +41,27 @@ export function wakeFlag(entry) {
|
||||
const age = entry.ageSec ?? '?';
|
||||
if (!last) return `still unread after ${age}s; no earlier wake`;
|
||||
if (last.outcome === 'swallowed') return `woke at ${last.t} (${last.trigger}), wake line still in the composer, still unread after ${age}s`;
|
||||
if (last.outcome === 'timeout') return `woke at ${last.t} (${last.trigger}), pane stayed busy until the waiter stopped, still unread after ${age}s`;
|
||||
if (last.outcome === 'deferred' || last.action === 'deferred') return `woke at ${last.t} (${last.trigger}), handed to a waiter because the pane was busy, still unread after ${age}s`;
|
||||
if (last.action === 'skipped-busy') return `woke at ${last.t} (${last.trigger}), skipped because an interrupt hint was showing, still unread after ${age}s`;
|
||||
if (last.action === 'no-window' || last.outcome === 'no-window') return `woke at ${last.t} (${last.trigger}), pane not found, still unread after ${age}s`;
|
||||
if (last.outcome === 'submitted') return `woke at ${last.t} (${last.trigger}), line left the composer, still unread after ${age}s`;
|
||||
return `woke at ${last.t} (${last.trigger}), outcome ${last.outcome}, still unread after ${age}s`;
|
||||
}
|
||||
if (entry.action === 'no-window' || entry.outcome === 'no-window') return 'pane not found';
|
||||
if (entry.action === 'no-window' || entry.outcome === 'no-window') {
|
||||
const names = Array.isArray(entry.windows) && entry.windows.length ? entry.windows.join(', ') : 'none';
|
||||
const looked = entry.lookup || entry.window || 'unknown window';
|
||||
return `pane not found (looked up ${looked}; session windows: ${names})`;
|
||||
}
|
||||
if (entry.outcome === 'waiter-running') return 'a waiter is already polling this pane';
|
||||
if (entry.outcome === 'already-read') return 'mail was read before the pane went idle, so nothing was typed';
|
||||
if (entry.outcome === 'timeout') return 'pane stayed busy until the waiter stopped';
|
||||
if (entry.outcome === 'deferred') return 'handed to a waiter because an interrupt hint was showing';
|
||||
if (entry.action === 'skipped-busy') return 'skipped: interrupt hint was showing, so the pane looked mid-turn';
|
||||
if (entry.action === 'send-failed') return 'send-keys failed before Enter';
|
||||
if (entry.trigger === 'deferred' && entry.outcome === 'swallowed') return `waiter typed it; wake line still on the cursor row after ${entry.enterAttempts} Enter`;
|
||||
if (entry.outcome === 'swallowed') return `wake line still on the cursor row after ${entry.enterAttempts} Enter`;
|
||||
if (entry.trigger === 'deferred' && entry.outcome === 'submitted') return 'waiter typed the line and it left the composer';
|
||||
if (entry.outcome === 'submitted') {
|
||||
if (entry.before && entry.before.idleSec != null && entry.before.idleSec < 3 && !entry.before.interrupt) {
|
||||
return 'typed and the line left the composer while the pane was recently active';
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
// One detached waiter for a busy pane. Started by nudgeMail, not by hand.
|
||||
import { flag, loadConfig, runningAsMain } from '../lib.mjs';
|
||||
import { runWakeWait } from './ops.mjs';
|
||||
|
||||
if (runningAsMain(import.meta.url)) {
|
||||
const c = loadConfig();
|
||||
runWakeWait(c, {
|
||||
area: flag(process.argv, '--area'),
|
||||
from: flag(process.argv, '--from') || 'fleet',
|
||||
file: flag(process.argv, '--file') || '',
|
||||
pollMs: Number(flag(process.argv, '--poll-ms')) || 0,
|
||||
maxMs: Number(flag(process.argv, '--max-ms')) || 0,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user