-- executor.lua — command execution. -- Phase 0: io.popen with stderr merged. PTY (forkpty) lands in Phase 1. -- `cd` is intercepted before popen and routed through libc.chdir so the -- working directory persists across calls (popen forks; cd inside it would -- otherwise be discarded). See docs/PHASE0.md §6, §7. local libc = require("ffi.libc") local M = {} -- LuaJIT 2.1's io.popen():close() returns only `true` (Lua 5.1 ABI) — it -- does not surface the child exit status. Recover it via a sentinel echo -- appended after the command. Phase 1's PTY work will wire waitpid via FFI -- and replace this hack. local EXIT_SENTINEL = "__AISH_EXIT_4F8E91__" -- Execute a shell command. -- Returns: (output_string, exit_code). -- exit_code == 0 on success; non-zero on failure; -1 on no-output / sentinel- -- parse failure (popen failed, empty cmd, shell parse error, sentinel collision). function M.exec(cmd) if not cmd or cmd:match("^%s*$") then return "(empty command)", -1 end local wrapped = string.format("(%s) 2>&1; echo %s$?", cmd, EXIT_SENTINEL) local handle, err = io.popen(wrapped, "r") if not handle then return ("popen failed: " .. tostring(err)), -1 end local output = handle:read("*a") or "" handle:close() local body, code = output:match("^(.-)" .. EXIT_SENTINEL .. "(%-?%d+)%s*$") if code then return body, tonumber(code) end if output == "" then return "(no output — possible shell parse error)", -1 end return output, -1 end -- Intercept and apply `cd ` (or bare `cd` -> $HOME) without forking. -- Returns: -- nil : the command is not a `cd` (caller falls through to exec) -- true : it was a cd, libc.chdir succeeded -- false, err : it was a cd, libc.chdir failed with errmsg function M.maybe_chdir(cmd) local rest = cmd:match("^%s*cd%s*$") and "" or cmd:match("^%s*cd%s+(.+)$") if not rest then return nil end local target = rest:match("^%s*(.-)%s*$") or "" -- Phase 0: no $OLDPWD support, so `cd -` is not handled. if target == "" then target = os.getenv("HOME") or "/" end if target == "~" then target = os.getenv("HOME") or "/" end if target:sub(1, 2) == "~/" then target = (os.getenv("HOME") or "") .. target:sub(2) end return libc.chdir(target) end -- Extract `CMD: ` lines from an assistant response per the §6 broker contract. -- The "CMD: " prefix is a §3 substrate invariant: exact prefix, single space, -- start-of-line only. Leading whitespace before CMD: does NOT match. function M.extract_cmd_lines(text) local cmds = {} for line in (text or ""):gmatch("[^\n]+") do local cmd = line:match("^CMD: (.*)$") -- Skip whitespace-only / empty bodies; "CMD: " alone is degenerate. if cmd and cmd:match("%S") then cmds[#cmds + 1] = cmd end end return cmds end return M