diff --git a/config.lua b/config.lua index 9d2567f..b766d2b 100644 --- a/config.lua +++ b/config.lua @@ -71,6 +71,11 @@ return { -- post_cmd = (os.getenv("HOME") or ".") .. "/.aish/hooks/post-cmd", -- }, + -- Issue #8: background CMD (CMD&: marker). Requires history.dir set + -- (logs land at /bg/.log + .status sidecar). The + -- feature is always-on once history.dir exists — no config flag — but + -- only fires when the model emits "CMD&: " or the user runs :bg-spawn. + -- Issue #9: permission policy DSL for AI-suggested CMD: lines. When set, -- supersedes shell.confirm_cmd. Patterns are Lua patterns (NOT regex) -- per substrate invariant §3 (no compiled extensions). Priority order: diff --git a/executor.lua b/executor.lua index 18a1530..f31aa9f 100644 --- a/executor.lua +++ b/executor.lua @@ -127,11 +127,21 @@ 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: (.*)$") - -- 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 + +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 diff --git a/repl.lua b/repl.lua index 887e530..90da735 100644 --- a/repl.lua +++ b/repl.lua @@ -142,6 +142,10 @@ Meta commands: :every schedule a recurring prompt (i: 30s | 5m | 2h) :every list show scheduled recurring prompts :every cancel remove a scheduled prompt + :bg-spawn start a background job directly (no AI needed) + :bg-list list background jobs (issued via CMD&: or :bg-spawn) + :bg-output dump the log of a background job + :bg-kill SIGTERM a background job :help this message ]] @@ -161,6 +165,12 @@ function M.run(config) -- to Norris mode (Norris has its own halt protocol). local plan_mode = false + -- Forward decl (issue #8): the bg spawn closure is defined deeper in + -- M.run alongside the meta dispatch, but ask_ai needs to call it when + -- routing CMD&: lines. Lua looks up names at call time; the closure + -- has to exist as a local in scope BEFORE ask_ai is declared. + local _bg_spawn + -- Phase 5: render the evicted turns into a compact transcript for -- the summarizer prompt. Same shape as :memory summarize uses. local function render_evicted(turns) @@ -777,6 +787,28 @@ function M.run(config) if doit then run_shell(cmd) end end end + + -- Issue #8: CMD&: extraction — spawn each as a background job. + -- No confirm gate in v1 (the model issuing CMD&: is opting into the + -- async path; permission policy is still bypassed there. Revisit + -- once #9 is generalized beyond the synchronous CMD: gate). + for _, cmd in ipairs(executor.extract_cmd_bg_lines(final_resp)) do + if plan_mode then + renderer.status(("PLAN: & %s"):format(cmd)) + ctx:append_exec_output(("[plan] would bg-run: %s"):format(cmd)) + else + local job, err = _bg_spawn(cmd) + if not job then + renderer.status(("bg spawn failed: %s"):format(tostring(err))) + ctx:append_exec_output(("[bg failed to start]: %s"):format(cmd)) + else + local note = ("[bg:%d started pid=%d]: %s") + :format(job.id, job.pid, cmd) + renderer.status(note) + ctx:append_exec_output(note) + end + end + end end local function shutdown_session() @@ -1540,9 +1572,151 @@ function M.run(config) :format(id, secs, job_model, p)) end + -- Issue #8: background CMD (CMD&: marker). Spawn via a shell wrapper + -- that captures stdout+stderr to /bg/.log and the + -- exit code to .status. We poll with kill -0; on completion read + -- the .status sidecar. No fork()/execv() FFI required — relies on POSIX + -- shell semantics. Reparented child is owned by init; we treat it as + -- "managed" via the PID and the status file only. + local bg_jobs = {} -- { {id, pid, cmd, started_at, log_path, status_path, exited} } + local next_bg_id = 1 + local bg_dir = history_dir and (history_dir .. "/bg") or nil + if bg_dir then os.execute(("mkdir -p %q 2>/dev/null"):format(bg_dir)) end + + local function _bg_shq(s) return "'" .. (s or ""):gsub("'", [['\'']]) .. "'" end + _bg_spawn = function(cmd) + if not bg_dir then + return nil, "background CMD requires history.dir to be configured" + end + local id = next_bg_id; next_bg_id = next_bg_id + 1 + local log_path = ("%s/%d.log"):format(bg_dir, id) + local status_path = ("%s/%d.status"):format(bg_dir, id) + -- Wrapper: redirect, capture exit, write status. nohup + /dev/null 2>&1 & echo $!"):format( + _bg_shq(("(%s) > %s 2>&1; echo $? > %s"):format( + cmd, _bg_shq(log_path), _bg_shq(status_path)))) + local pipe = io.popen(wrapper) + local pid_str = pipe and pipe:read("*l") + if pipe then pipe:close() end + local pid = tonumber(pid_str) + if not pid then + return nil, "failed to spawn (no PID returned)" + end + local job = { + id = id, + pid = pid, + cmd = cmd, + started_at = os.time(), + log_path = log_path, + status_path = status_path, + exited = false, + } + bg_jobs[#bg_jobs + 1] = job + return job + end + local function _bg_status_check(job) + if job.exited then return end + -- Read status file: presence means the wrapper finished writing + -- the exit code. If absent and PID is still alive, job is running. + local f = io.open(job.status_path, "rb") + if f then + local s = f:read("*l") or "" + f:close() + job.exit_code = tonumber(s) or -1 + job.exited = true + job.exited_at = os.time() + local lf = io.open(job.log_path, "rb") + job.log_bytes = 0 + if lf then + lf:seek("end"); job.log_bytes = lf:seek(); lf:close() + end + end + end + local function _fmt_bytes(n) + if n < 1024 then return ("%dB"):format(n) end + if n < 1024*1024 then return ("%.1fKB"):format(n/1024) end + return ("%.1fMB"):format(n/(1024*1024)) + end + local function check_bg_done() + for _, job in ipairs(bg_jobs) do + if not job.exited then + _bg_status_check(job) + if job.exited then + local wall = (job.exited_at or os.time()) - job.started_at + local summary = ("[bg:%d exited %d, %s, %ds wall] %s") + :format(job.id, job.exit_code, + _fmt_bytes(job.log_bytes or 0), wall, job.cmd) + renderer.status(summary) + -- Feed back into context so the model sees completion + -- on the next ai turn — same channel as foreground exec. + ctx:append_exec_output(summary) + end + end + end + end + + meta["bg-spawn"] = function(args) + local cmd = (args or ""):match("^%s*(.-)%s*$") + if cmd == "" then renderer.status("usage: :bg-spawn "); return end + local job, err = _bg_spawn(cmd) + if not job then + renderer.status("bg spawn failed: " .. tostring(err)) + else + renderer.status(("started #%d pid=%d: %s") + :format(job.id, job.pid, cmd)) + end + end + meta["bg-list"] = function() + if #bg_jobs == 0 then renderer.status("(no bg jobs)"); return end + check_bg_done() + renderer.status(("bg jobs (%d):"):format(#bg_jobs)) + for _, j in ipairs(bg_jobs) do + local state + if j.exited then + state = ("exit=%d %ds"):format(j.exit_code, + (j.exited_at - j.started_at)) + else + local age = os.time() - j.started_at + state = ("running pid=%d %ds"):format(j.pid, age) + end + io.write((" #%-3d %s %s\n"):format(j.id, state, j.cmd)) + end + end + meta["bg-output"] = function(args) + local id = tonumber(args:match("^%s*(%d+)")) + if not id then renderer.status("usage: :bg-output "); return end + local job + for _, j in ipairs(bg_jobs) do if j.id == id then job = j; break end end + if not job then renderer.status("no such bg job: #" .. id); return end + local f = io.open(job.log_path, "rb") + if not f then renderer.status("(no log file yet)"); return end + io.write(f:read("*a") or ""); f:close() + if not job.log_path:match("\n$") then io.write("\n") end + end + meta["bg-kill"] = function(args) + local id = tonumber(args:match("^%s*(%d+)")) + if not id then renderer.status("usage: :bg-kill "); return end + for _, j in ipairs(bg_jobs) do + if j.id == id then + if j.exited then + renderer.status(("#%d already exited"):format(id)) + else + os.execute(("kill %d 2>/dev/null"):format(j.pid)) + renderer.status(("sent SIGTERM to #%d (pid %d)"):format(id, j.pid)) + end + return + end + end + renderer.status("no such bg job: #" .. id) + end + -- Main loop. while true do check_every_due() + check_bg_done() local line = rl.readline(prompt()) if line == nil then -- EOF (Ctrl-D on empty line) io.write("\n")