-- htop: the reference tool surface (docs/surfaces.md, TB-792). -- -- A saved Lua tool with a live panel. Install it like any saved tool: copy -- this file to ~/.girl/tools/htop.lua (every project; found with tool_search) -- or to the project's tools folder (loaded at once), or hand its text to -- `save_tool`. Then ask the model to "open htop" and the panel opens in the -- girl app's side dock. -- -- * `htop` (the tool the model calls) only OPENS the panel: it returns the -- girl/surface display, and the registry stamps the surface, its digest, -- title and placement. -- * `list` (a handler, no permission: classified surface.read, so it runs at -- once, even mid-turn) reads the process list. -- * `kill` (a handler, permission process.kill) stops one process. It -- prompts like any gated tool, waits for a safe point, and is shown to the -- model as a "[Surface activity, by the user]" row once it settles. -- -- The Windows list command is NOT the one the design record first named. -- docs/design/surfaces.md asked for `tasklist /fo csv /v`; measured on the -- machine this was built on (TB-792), that command takes 29.9 s per call, -- against 0.28 s for the PowerShell Get-Process form below, so a panel that -- refreshes every 2 s could never refresh with it. Do not "restore" it: -- `tasklist /fo csv /nh` is as fast but has no CPU column. Get-Process -- reports CUMULATIVE CPU seconds; the page shows them as "CPU s" and derives -- "CPU % (last 2 s)" from the change between two refreshes - never an -- instantaneous sample, and a dash when there is no earlier sample. -- -- The page talks to girl only through `window.girl` (docs/surfaces.md, "SDK -- reference"): girl.call for the two handlers, girl.state for the selection -- the model can read with `surface {action="state", surface="htop"}`, -- girl.attach + girl.compose for "Ask about this", and girl.on("event") for a -- model-driven `highlight {pid}`. -- -- The page builds its whole UI from its one script with a small DOM subset -- (createElement, appendChild/removeChild/firstChild, textContent, className, -- setAttribute, addEventListener, value, disabled, document.body, -- document.visibilityState and the visibilitychange event), so -- tests/app_servers_harness.js can execute it in node against a stub of -- exactly that subset. Keep to it, or extend that stub with it. local fs = require("platform.fs") local WINDOWS = package.config:sub(1, 1) == "\\" -- The two list commands and the two kill commands, exactly (a suite test reads -- them from this file, so neither platform can drift silently). local LIST_WINDOWS = "Get-Process | Select-Object Id,ProcessName,CPU,WorkingSet64 | ConvertTo-Csv -NoTypeInformation" local LIST_POSIX = "ps -eo pid,comm,%cpu,rss" local KILL_WINDOWS = "taskkill /PID %d /F" local KILL_POSIX = "kill -TERM %d" local ROWS_MAX = 500 local RESULT_BUDGET = 48 * 1024 -- the dispatcher refuses a result over 64 KiB local PID_MAX = 2147483647 local NAME_MAX = 260 -- "exit code: N\n" (the platform.fs house shape) -> output, N. local function output_of(text, code) text = tostring(text or "") local body = text:gsub("^exit code: %-?%d+\r?\n?", "", 1) return body, tonumber(code) or tonumber(text:match("^exit code: (%-?%d+)")) or 1 end -- One CSV line, RFC 4180 quoting ("" inside quotes is a quote). local function csv_fields(line) local fields, i, n = {}, 1, #line while i <= n + 1 do local c = line:sub(i, i) if c == '"' then local value, j = {}, i + 1 while j <= n do local d = line:sub(j, j) if d == '"' then if line:sub(j + 1, j + 1) == '"' then value[#value + 1] = '"'; j = j + 2 else j = j + 1; break end else value[#value + 1] = d; j = j + 1 end end fields[#fields + 1] = table.concat(value) i = j + 1 -- skip the comma else local comma = line:find(",", i, true) or (n + 1) fields[#fields + 1] = line:sub(i, comma - 1) i = comma + 1 end if i > n + 1 then break end end return fields end -- A number that may use a decimal comma; nil for an empty field. local function number_of(text) text = tostring(text or ""):gsub("^%s+", ""):gsub("%s+$", "") if text == "" then return nil end return tonumber((text:gsub(",", "."))) end local function parse_windows(body) local rows, header = {}, nil body = body:gsub("^\239\187\191", "") -- a UTF-8 byte order mark, if the shell wrote one for line in body:gmatch("[^\r\n]+") do local f = csv_fields(line) if not header then header = f else local pid = tonumber(f[1]) if pid and pid > 0 and f[2] and f[2] ~= "" then local mem = number_of(f[4]) rows[#rows + 1] = { pid = pid, name = f[2], cpu = number_of(f[3]), mem_bytes = mem and math.floor(mem) or nil } end end end if not header or header[1] ~= "Id" then error("htop: Get-Process answered no process table", 0) end return rows end local function parse_posix(body) local rows, first = {}, true for line in body:gmatch("[^\r\n]+") do if first then first = false else -- PID COMMAND %CPU RSS; a command may hold spaces. local pid, rest = line:match("^%s*(%d+)%s+(.+)$") if pid then local name, cpu, rss = rest:match("^(.-)%s+(%S+)%s+(%S+)%s*$") local kib = number_of(rss) if name and name ~= "" then rows[#rows + 1] = { pid = tonumber(pid), name = name, cpu = number_of(cpu), mem_bytes = kib and math.floor(kib * 1024) or nil } end end end end return rows end local function read_processes(context) local text, code if WINDOWS then text, code = fs.run_shell("powershell", context.workspace, LIST_WINDOWS, { max_chars = 2000000 }) else text, code = fs.run(context.workspace, LIST_POSIX, { max_chars = 2000000 }) end local body, exit = output_of(text, code) if exit ~= 0 then error("htop: the process list failed (exit " .. tostring(exit) .. "): " .. body:sub(1, 200), 0) end return WINDOWS and parse_windows(body) or parse_posix(body) end local function by_cpu(a, b) local x, y = a.cpu or -1, b.cpu or -1 if x ~= y then return x > y end return a.pid < b.pid end local function list(_, context) local rows = read_processes(context) local total = #rows table.sort(rows, by_cpu) while #rows > ROWS_MAX do rows[#rows] = nil end local json = require("platform.json") -- Trim from the tail (the idlest processes) until the answer fits. while #rows > 0 and #json.encode(rows) > RESULT_BUDGET do for _ = 1, math.max(1, math.floor(#rows / 10)) do rows[#rows] = nil end end return { platform = WINDOWS and "windows" or "posix", cpu = WINDOWS and "seconds" or "percent", at = os.time(), total = total, rows = rows } end -- The kill's arguments, checked BEFORE anything runs: a pid is an integer in -- 1..2^31-1 and a name a non-empty single line of at most 260 bytes. local function checked(args) args = type(args) == "table" and args or {} local pid, name = args.pid, args.name if type(pid) ~= "number" or pid ~= math.floor(pid) or pid < 1 or pid > PID_MAX then error("htop: pid must be a whole number between 1 and " .. PID_MAX, 0) end if type(name) ~= "string" or name == "" or #name > NAME_MAX or name:find("%c") then error("htop: name must be the process name, one line of at most " .. NAME_MAX .. " bytes", 0) end return pid, name end local function shown(value) local text = tostring(value == nil and "?" or value):gsub("%c", "?") if #text > 80 then text = text:sub(1, 79) .. "~" end return text end local function kill(args, context) local pid, name = checked(args) -- The pid must still be that process: a pid the OS handed to another -- process since the list was read (or one that is gone) is never stopped. local found for _, row in ipairs(read_processes(context)) do if row.pid == pid then found = row; break end end if not found then error("htop: pid " .. pid .. " is not running (it may have exited); nothing was stopped", 0) end if found.name ~= name then error("htop: pid " .. pid .. " is now " .. shown(found.name) .. ", not " .. shown(name) .. "; nothing was stopped", 0) end -- Only the integer reaches the shell, never the name. local text, code if WINDOWS then text, code = fs.run(context.workspace, string.format(KILL_WINDOWS, pid)) else text, code = fs.run(context.workspace, string.format(KILL_POSIX, pid)) end local body, exit = output_of(text, code) if exit ~= 0 then error("htop: stopping pid " .. pid .. " failed (exit " .. exit .. "): " .. body:sub(1, 200), 0) end -- A zero exit only says the request was delivered: SIGTERM can be ignored, -- and a process can linger for a moment. So look once more and say which. local exited = true for _, row in ipairs(read_processes(context)) do if row.pid == pid and row.name == found.name then exited = false; break end end return { stopped = pid, name = found.name, exited = exited } end local HTML = [==[ htop ]==] return function(registry) registry:register { name = "htop", description = "Open a live process list (htop) in the girl app's side panel. The user can sort it, select a " .. "process, attach it to the chat with \"Ask about this\", and stop it (Kill asks for permission). " .. "To see which process the user selected, call surface {action=\"state\", surface=\"htop\"}; " .. "to point at one, call surface {action=\"event\", surface=\"htop\", name=\"highlight\", payload={pid=N}}.", input_schema = { type = "object", properties = { sort = { type = "string", enum = { "cpu", "mem", "pid", "name" }, description = "The column to sort by first (cpu unless asked)." }, }, additionalProperties = false, }, -- Opening the panel has no effect of its own (it returns a display): -- surface.read is the read-only class every posture allows. permission = "surface.read", resource = "process list panel", surface = { title = "htop", placement = "dock", html = HTML, handlers = { list = { description = "The running processes: pid, name, CPU and memory.", run = list, }, kill = { description = "Stop one process, by pid and the name it must still have.", permission = "process.kill", resource = function(args) args = type(args) == "table" and args or {} return "pid " .. shown(args.pid) .. " (" .. shown(args.name) .. ")" end, run = kill, }, }, }, run = function(args) local sort = type(args) == "table" and args.sort or nil if sort ~= "cpu" and sort ~= "mem" and sort ~= "pid" and sort ~= "name" then sort = "cpu" end local dir = (sort == "pid" or sort == "name") and 1 or -1 return "Opened htop in the girl app. Read the user's selection with surface {action=\"state\", surface=\"htop\"}.", { renderer = "girl/surface", data = { state = { sort = sort, dir = dir, selected = nil } } } end, } end