a404b2a152
Phase 3 commit #5 per docs/PHASE3.md §12. Wires safety.norris_step (commit #4) into the REPL with the user-facing surface. ffi/readline.lua extensions (A1 + R-C4): - rl_insert_text + rl_redisplay added to ffi.cdef block; M.insert_text and M.redisplay wrappers exposed. - M.bind: removed `:free()` on previous callback. Now keeps every bound callback pinned for process lifetime in `_pinned` list (alongside `_bound[seq]` for current lookup). Avoids the use-after-free window between unbind and rebind that R-C4 flagged. Memory cost is bounded — one closure per key sequence binding. context.lua Norris suffix (R-C3 / §8): - to_messages() composes a dynamic NORRIS MODE block onto the system prompt when ctx.norris_active is set. The block carries ctx.norris_goal so eviction of the user's "[norris] goal:" turn doesn't lose the anchor. Returns to plain system prompt when Norris exits. repl.lua Norris driver: - prompt() now shows ⚡ marker when ctx.norris_active per PHASE0.md §9. - \C-n bound to a real handler — inserts ":norris " at the cursor (replaces Phase 1 status placeholder). - run_norris(goal) function: sets norris_active + norris_goal, appends a "[norris] <goal>" user turn, renders the banner, then loops calling safety.norris_step with an injected helpers table until a terminal status returns. Renders the closing banner. - norris_halt(): the [N] proceed/skip/abort prompt called by safety.norris_step via helpers.halt. Empty input → abort (safe). - dispatch_tool(): factored from the Phase 2 ask_ai code so safety.norris_step can call it. - norris_exec(): factored exec path for autonomous mode (skips the interactive run_shell cd-status renderer). - :norris <goal> meta — launches autonomous mode - :norris off meta — drops Norris flag (rare; usually 'abort') - :safety patterns meta — lists active is_destructive rules - :safety check <cmd> meta — probes a hypothetical command End-to-end mock-driven test: Submitted ":norris find files in /tmp" → banner → step 1 emits tool_call (auto_approved per policy) → dispatched → frame rendered → step 2 emits "GOAL: complete" → sub-loop exits → DONE banner. 2 broker invocations, no stalls. config.lua safety example block lands in commit #6. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
105 lines
3.6 KiB
Lua
105 lines
3.6 KiB
Lua
-- ffi/readline.lua — GNU readline binding.
|
|
-- Phase 0: readline + add_history + EOF handling.
|
|
-- Phase 1: custom key bindings via rl_bind_keyseq.
|
|
-- Phase 3: rl_insert_text + rl_redisplay so bound key handlers can
|
|
-- stuff text into the in-progress line buffer (used by \C-n
|
|
-- to insert ":norris " in repl.lua).
|
|
-- See docs/PHASE0.md §9 and docs/PHASE1.md §7 and docs/PHASE3.md §3.
|
|
|
|
local ffi = require("ffi")
|
|
|
|
ffi.cdef[[
|
|
char *readline(const char *prompt);
|
|
void add_history(const char *line);
|
|
void free(void *ptr);
|
|
|
|
typedef int (*rl_command_func_t)(int, int);
|
|
int rl_bind_keyseq(const char *keyseq, rl_command_func_t function);
|
|
int rl_insert_text(const char *text);
|
|
int rl_redisplay(void);
|
|
]]
|
|
|
|
-- libreadline-dev (which ships the unversioned `libreadline.so` symlink) is
|
|
-- not assumed to be installed on the runtime host; fall back to versioned
|
|
-- sonames so a base Debian/Arch with just libreadline runtime works.
|
|
local function load_readline()
|
|
local errs = {}
|
|
for _, name in ipairs({"readline", "readline.so.8", "readline.so.7"}) do
|
|
local ok, lib = pcall(ffi.load, name)
|
|
if ok then return lib end
|
|
errs[#errs+1] = name .. ": " .. tostring(lib)
|
|
end
|
|
error("libreadline not loadable: " .. table.concat(errs, "; "))
|
|
end
|
|
|
|
local rl = load_readline()
|
|
local C = ffi.C
|
|
|
|
local M = {}
|
|
|
|
-- Read one line of input.
|
|
-- Returns:
|
|
-- string : the line (no trailing newline)
|
|
-- nil : EOF (Ctrl-D on empty line)
|
|
function M.readline(prompt)
|
|
local cstr = rl.readline(prompt)
|
|
if cstr == nil then return nil end
|
|
local s = ffi.string(cstr)
|
|
C.free(cstr)
|
|
return s
|
|
end
|
|
|
|
-- Append a non-empty line to readline's in-memory history.
|
|
function M.add_history(line)
|
|
if line and #line > 0 then
|
|
rl.add_history(line)
|
|
end
|
|
end
|
|
|
|
-- Bind `seq` (e.g. "\\C-n") to a Lua function that runs when the user types
|
|
-- that key sequence at the readline prompt. The Lua fn takes no arguments
|
|
-- (readline passes count + key, but consumers don't need them).
|
|
-- Callback trampolines are pinned in module-local state for process
|
|
-- lifetime. We do NOT free the previous binding on rebind: readline
|
|
-- retains the function pointer in its keymap, and the window between
|
|
-- :free() and the new rl_bind_keyseq is a potential use-after-free.
|
|
-- Memory cost is bounded — one closure per bound key sequence.
|
|
-- (Phase 3 R-C4 fold-in.)
|
|
-- `_pinned` keeps every callback ever cast alive for process lifetime
|
|
-- (so readline's keymap pointers never dangle even after a re-bind).
|
|
-- `_bound` indexes by seq for "what's currently bound here" lookup but
|
|
-- both old and new closures stay reachable via _pinned.
|
|
local _bound = {}
|
|
local _pinned = {}
|
|
|
|
function M.bind(seq, fn)
|
|
local cb = ffi.cast("rl_command_func_t", function(_count, _key)
|
|
local ok, err = pcall(fn)
|
|
if not ok then
|
|
io.stderr:write("ffi/readline bind handler error: " .. tostring(err) .. "\n")
|
|
end
|
|
return 0
|
|
end)
|
|
_pinned[#_pinned + 1] = cb -- never freed; bounded by N rebinds
|
|
local rc = rl.rl_bind_keyseq(seq, cb)
|
|
_bound[seq] = cb
|
|
return rc == 0
|
|
end
|
|
|
|
-- Insert `text` at the cursor in the in-progress readline buffer.
|
|
-- Used by bound key handlers to stuff e.g. ":norris " into the line.
|
|
-- Caller typically follows with M.redisplay() to refresh the display.
|
|
function M.insert_text(text)
|
|
if text and text ~= "" then
|
|
rl.rl_insert_text(text)
|
|
end
|
|
end
|
|
|
|
-- Force readline to redraw the current line. Call after insert_text or
|
|
-- any other buffer mutation from inside a bound handler.
|
|
function M.redisplay()
|
|
rl.rl_redisplay()
|
|
end
|
|
|
|
return M
|