-- executor.lua — command execution. -- Phase 1: forkpty via ffi/pty. Replaces Phase 0's io.popen + sentinel-echo -- exit-code workaround; vim/less/htop now work because the child runs on a -- real PTY. `cd` interception is unchanged (still libc.chdir per §3, §7). -- See docs/PHASE0.md §7 and docs/PHASE1.md §5. local libc = require("ffi.libc") local pty = require("ffi.pty") local M = {} -- Execute a shell command. -- Returns: (output_string, exit_code). -- 0 success -- 1..255 child exited with that status -- 128+N child killed by signal N (bash convention) -- -1 forkpty / spawn / wait failure function M.exec(cmd) if not cmd or cmd:match("^%s*$") then return "(empty command)", -1 end local sess, err = pty.spawn(cmd) if not sess then return "(pty.spawn failed: " .. tostring(err) .. ")", -1 end -- Drain until the child closes its end. PTY combines stdout+stderr -- on the master fd (no 2>&1 needed); CR LF gets normalized below. local chunks = {} while true do local data, n = sess:read() if not data then break end if n == 0 then break end chunks[#chunks + 1] = data end local kind, code = sess:wait() sess:close() -- PTY line discipline emits \r\n for every \n the child writes; collapse -- back to \n so the Phase 0 caller contract ("output uses \n separators") -- still holds. local output = table.concat(chunks):gsub("\r\n", "\n") if kind == "exit" then return output, code end if kind == "signal" then return output, 128 + code 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