ee4d7f86d6
Replaces the Phase 0 io.popen + sentinel-echo exit-code recovery with
forkpty + waitpid via ffi/pty. The §7 amendment paragraph on PHASE0.md
is rewritten to point at PHASE1.md §5 — the workaround is gone, not
just renamed.
User-visible behavioral changes:
- Interactive commands (vim, less, htop, top) now work via $cmd /
:exec / known-command shell paths because the child has a real
PTY for line discipline.
- Exit codes are accurate: `false` -> 1, `exit 7` -> 7, signal kill
-> 128+N (bash convention), shell parse error -> sh's 2.
- Broken-shell-syntax cmd now shows the actual sh diagnostic
(e.g. "Syntax error: end of file unexpected") instead of Phase 0's
"(no output — possible shell parse error)" guess.
- Output normalization: PTY emits CR LF; executor collapses \r\n
-> \n to keep the Phase 0 contract ("output uses \n separators").
Code path:
pty.spawn(cmd) -> drain master_fd until EOF
-> wait() returns ("exit", N) | ("signal", N) | ...
-> exit_code mapped: exit -> N, signal -> 128+N, else -1
Phase 0 invariants intact: `cd` interception unchanged (still libc.chdir
per §3 + §7), `CMD: ` extraction unchanged.
PHASE0.md §7: the "LuaJIT 2.1 popen-close caveat" paragraph is rewritten
to "Superseded by Phase 1" — points at PHASE1.md §5 for the live model.
The illustrative sketch is left in place as historical context.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
87 lines
3.0 KiB
Lua
87 lines
3.0 KiB
Lua
-- 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 <path>` (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
|