e4780483ad
Pure function parallel to extract_cmd_lines, but more permissive to accommodate cloud-model output variation: tolerates leading whitespace (cloud often indents), tolerates extra whitespace after the colon, strips trailing whitespace. Strict on the literal "TASK:" prefix. Returns an array of trimmed strings; empty TASKs and non-TASK lines dropped silently. Callers cap the list size per cfg.norris.tasks_max. 10 inline unit cases verified: empty/nil, single TASK, mixed CMD+TASK (only TASKs returned), leading whitespace tolerated, empty-body TASKs dropped, trailing whitespace stripped, extra-spaces-after-colon AND no-space-after-colon both tolerated, prose interleaving (3 TASKs extracted from a realistic cloud response with intro+outro prose), TASK content with embedded quotes/punctuation preserved. Nothing in the tree calls this yet (Phase 10 C1 is the foundation commit; C4 lights it up). No regression. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
191 lines
7.0 KiB
Lua
191 lines
7.0 KiB
Lua
-- 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 <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.
|
|
-- "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: <preset> "<prompt>"` 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: <imperative>` 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
|