-- executor.lua — command execution. -- Phase 1: forkpty via ffi/pty + bidirectional multiplex. Replaces Phase 0's -- io.popen + sentinel-echo workaround. The multiplex loop forwards stdin -- keystrokes to the child master fd while streaming master output to stdout, -- so vim / less / htop / nano are usable end-to-end. Parent's tty (fd 0) is -- flipped to raw mode for the duration so single-key UIs work. -- `cd` interception is unchanged (still libc.chdir per §3, §7). -- See docs/PHASE0.md §7 and docs/PHASE1.md §5. local ffi = require("ffi") local bit = require("bit") local libc = require("ffi.libc") local pty = require("ffi.pty") local M = {} local pollfd_arr2 = ffi.typeof("struct pollfd[2]") -- Multiplex stdin (fd 0) <-> sess.master_fd until the child writes EOF. -- Output is streamed live to stdout AND collected for the (output, code) -- return so context.append_exec_output still has the body to inject into -- the next user turn. local function multiplex(sess) local saved_termios = libc.set_raw(0) -- nil if stdin isn't a tty local stdin_is_tty = (saved_termios ~= nil) local fds = pollfd_arr2() -- Only poll stdin when it's a tty. With piped stdin (scripted runs / -- tests), aish's stdin holds the *next* aish commands queued for the -- repl loop — draining it into the child would swallow those. fds[0].fd = stdin_is_tty and 0 or -1 fds[0].events = libc.POLLIN fds[1].fd = sess.master_fd fds[1].events = libc.POLLIN local chunks = {} while true do fds[0].revents = 0 fds[1].revents = 0 local rc = libc.poll(fds, 2, -1) if rc < 0 then if libc.errno() == libc.EINTR then -- signal during poll; loop and retry else break end else -- Drain master first (output priority). Read on *any* revents — -- POLLHUP fires (and POLLIN doesn't) when the child closes its -- slave PTY end on exit; reading then returns 0 = EOF. if fds[1].revents ~= 0 then local data, n = sess:read() if not data or n == 0 then break end chunks[#chunks + 1] = data io.write(data); io.flush() end -- Forward stdin keystrokes (or piped-in bytes) to the child. if fds[0].revents ~= 0 then local input, n = libc.read(0, 4096) if input and n > 0 then sess:write(input) elseif input == "" then -- aish's own stdin closed; stop forwarding but keep -- draining master until child exits fds[0].fd = -1 end end end end if saved_termios then libc.restore_termios(0, saved_termios) end return chunks end -- 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 local chunks = multiplex(sess) 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 for context-injection purposes. 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. -- "CMD&: " lines are issue #8 background variants — extracted separately so -- repl.lua can route them to the bg spawner instead of the synchronous gate. function M.extract_cmd_lines(text) local cmds = {} for line in (text or ""):gmatch("[^\n]+") do local cmd = line:match("^CMD: (.*)$") if cmd and cmd:match("%S") then cmds[#cmds + 1] = cmd end end return cmds end function M.extract_cmd_bg_lines(text) local cmds = {} for line in (text or ""):gmatch("[^\n]+") do local cmd = line:match("^CMD&: (.*)$") if cmd and cmd:match("%S") then cmds[#cmds + 1] = cmd end end return cmds end -- Issue #6: `DELEGATE: ""` lines. Parses each into -- (preset, prompt) — quotes around the prompt are required so the -- parser can find the boundary unambiguously (the prompt may contain -- arbitrary punctuation otherwise). Lines that don't match the -- quoted shape are silently dropped (rendered as text to the user). function M.extract_delegate_lines(text) local out = {} for line in (text or ""):gmatch("[^\n]+") do local preset, prompt = line:match([[^DELEGATE: (%S+)%s+"(.+)"%s*$]]) if not preset then preset, prompt = line:match([[^DELEGATE: (%S+)%s+'(.+)'%s*$]]) end if preset and prompt and prompt:match("%S") then out[#out + 1] = { preset = preset, prompt = prompt } end end return out end -- Phase 10 / #89: extract `TASK: ` lines from a cloud -- preplanner's response. Wire contract for the planning/executor -- split: cloud emits a list of imperative TASKs once per :norris -- launch, local model executes each. -- -- More permissive than extract_cmd_lines: tolerates leading -- whitespace (cloud models often indent) AND leading whitespace -- after the colon, AND strips trailing whitespace. Strict only on -- the literal "TASK:" prefix. -- -- Returns an array of strings (already trimmed); empty TASKs and -- non-TASK lines are dropped silently. function M.extract_task_lines(text) local out = {} for line in (text or ""):gmatch("[^\n]+") do local task = line:match("^%s*TASK:%s*(.-)%s*$") if task and task:match("%S") then out[#out + 1] = task end end return out end return M