CONCERNs from the Phase 1 review pass:
ffi/curl.lua:
- SSE write_cb body is now pcall-wrapped. A Lua error in on_event (or
in the parse loop itself) is captured into cb_error and surfaced
after curl_easy_perform rather than propagating across the FFI
callback boundary (which LuaJIT documents as process-fatal). The
EOS flush path gets the same shield. Errors return
(nil, "callback: <msg>") from post_sse.
history.lua:
- sh_singlequote() escapes shell metacharacters; the mkdir -p and
ls -1 shell-outs no longer double-quote (where $(...) and $VAR
still expand) — single-quote with embedded-' escaping is the
safe form.
- M.load now returns (turns, meta) instead of (meta, turns). turns
is ALWAYS a table on success, never nil-when-no-header; failure
path is the unambiguous (nil, err). Callers can `if not turns
then` without the previous ambiguity. repl.lua :resume updated
to the new shape.
repl.lua :resume:
- Refuse to resume into a non-empty ctx — silent overwrite was the
Q15 default, but the review surfaced the no-undo / no-warning
failure mode. User must :reset (or :save then re-launch) to
express intent. The current session's on-disk log is unaffected
either way.
NITs:
- ffi/libc.lua READ_BUF: comment noting it's module-shared and
Phase 1 has no reentrant readers; revisit when that changes.
- PHASE1.md §7: \C-x\C-c reservation pinned to Phase 3 ("deferred
from Phase 1 — no consumer here") rather than the previous
dangling "(or here)".
Regression suite verifies:
- history.load new signature on success + failure paths
- shell-quoted history.dir with $ doesn't trip
- aish scripted run: ctx with 2 turns refuses :resume anchor with
a clear status; user must :reset first
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
13 KiB
aish — Phase 1 Manifest
Project: aish — AI-augmented conversational shell Document: Phase 1 Requirements, Architecture & Design Decisions Status: Formulate (pre-analysis) Date: 2026-05-10
PHASE0.md is the locked substrate. This manifest specifies what Phase 1 adds on top. Section numbers reference back to PHASE0.md when relevant.
1. Scope of Phase 1
Four pillars per PHASE0.md §11:
- SSE streaming — assistant text arrives incrementally instead of as
a complete block at end of
curl_easy_perform. Reuses the Phase 0 WRITEFUNCTION hook inffi/curl.lua. - PTY-backed exec via
forkpty(libc FFI). Replaces Phase 0'sio.popenso interactive commands (vim,less,htop) work and so the §7 sentinel-echo exit-code workaround can be retired in favor ofwaitpid. - Session persistence — each session writes an append-only JSONL log
under
<config.history.dir>/sessions/<utc>.jsonl. Optional:resumeloads a prior session's turns into context. - Readline custom bindings — wire the rebinding API on
ffi/readline.luaso subsequent phases can attach actions to key sequences. Phase 1 itself binds nothing user-visible; Norris (Phase 3) is the first consumer.
Phase 1 is done when:
- Assistant responses arrive token-by-token (visible streaming)
vim/less/htopwork end-to-end via$cmdor:exec cmd- A session is written to
sessions/*.jsonland resumable acrossluajit main.luainvocations - The Phase 0
executor.luasentinel hack is gone; PHASE0.md §7's sketch becomes accurate (waitpid surfaces the exit code) rl_bind_keyseqis callable from Lua and known not to crash with a no-op handler bound to a reserved sequence
2. Technology Decisions (delta from Phase 0)
| Decision | Choice | Rationale |
|---|---|---|
| Streaming transport | SSE over the existing libcurl easy interface | OpenAI-compat servers (llama.cpp, hossenfelder) emit text/event-stream when the request body has stream: true. The Phase 0 WRITEFUNCTION callback already receives incremental chunks; the only change is the parsing strategy. |
| Streaming concurrency | Single blocking curl_easy_perform; the WRITEFUNCTION calls a Lua on_delta callback synchronously |
LuaJIT FFI callbacks run on the libcurl thread but Phase 0's WRITEFUNCTION already ran fine that way. No coroutines / no threads in Phase 1. |
| PTY library | forkpty(3) from libutil (linked separately on glibc) |
Standard, single-call setup of master/slave pair + fork + dup2. Avoids hand-rolling the openpty/grantpt/unlockpt/ptsname dance. |
| Exec uniformity | All shell exec goes through PTY (no io.popen fallback) |
One code path. Non-interactive cmds (ls) work fine on a PTY too. Avoids the per-cmd "is this interactive?" classifier. |
| Exit code recovery | waitpid(WEXITSTATUS) from the PTY parent |
The §7 sentinel-echo hack is retired. Same commit that lands PTY exec also amends PHASE0.md §7 to drop the LuaJIT-2.1 popen caveat. |
| Session log format | Append-only JSONL (one turn per line) | Streaming-friendly; grep-able; robust to truncation; no parser dependency beyond the vendored dkjson. |
| Session location | <config.history.dir>/sessions/<UTC-iso8601>.jsonl |
Default ~/.local/share/aish/sessions/ per Phase 0 config. Per-session file → concurrent aish processes don't collide. |
| Session save trigger | Auto-write on :quit AND explicit :save for mid-session checkpoint |
Closes Q3 from PHASE0.md §13 with both. The auto path means kept-by-default; explicit path exists for users who want a checkpoint name. |
| Readline bindings API | Bind via rl_bind_keyseq (GNU readline) — M.bind(seq, lua_fn) wrapper |
Phase 1 ships the wiring; bound sequences with no consuming phase yet are reserved with a logged-status no-op. Phase 3+ replace handlers. |
3. Module Changes
No new module file names beyond the §4 stubs already present (ffi/pty.lua,
history.lua). All changes are growth of existing files.
| File | Phase 0 | Phase 1 |
|---|---|---|
ffi/curl.lua |
Blocking POST; response captured into a Lua string | Add M.post_sse(url, body, headers, on_event). on_event(delta) is called per parsed SSE data: line. The Phase 0 M.post stays for non-streaming consumers. |
ffi/pty.lua |
Stub | Implement: M.spawn(argv) -> handle; handle exposes :read(), :write(data), :close(), :wait() -> exit_code. Uses forkpty + waitpid. |
ffi/libc.lua |
chdir, errno, strerror |
Add waitpid, WEXITSTATUS (macro materialized in Lua), read, write, close, kill, tcgetattr/tcsetattr + cfmakeraw for raw-mode toggle on the controlling tty (required for single-key UIs to work — done-criteria #2), poll for stdin↔master multiplex in executor. |
ffi/readline.lua |
readline, add_history |
Add rl_bind_keyseq binding; expose M.bind(seq, fn). |
broker.lua |
M.chat(cfg, msgs) blocking |
Add M.chat_stream(cfg, msgs, on_delta). M.chat becomes a thin wrapper that buffers deltas. |
executor.lua |
popen + sentinel exit-code recovery + cd interception + CMD: extract |
Replace popen path with pty.spawn. The sentinel hack is deleted. cd interception unchanged (still routes through libc.chdir). CMD: extract unchanged. |
repl.lua |
Blocking ask_ai → renderer.assistant | chat_stream with renderer.assistant_delta per chunk; closing flush highlights any completed CMD: lines. New meta: :save, :resume <name>, :sessions. |
renderer.lua |
assistant(text) whole block |
Add assistant_delta(chunk) and assistant_flush(). Streaming path emits raw chunks; flush re-highlights completed CMD: lines if needed. |
history.lua |
Stub | Implement: M.open(path) -> session; session:append(turn); M.load(path) -> turns; M.list_sessions(dir) -> [{name, mtime, turns}]. |
config.lua |
history.dir set | Optional new fields: session.autosave (default true), session.resume_on_start (default false). |
4. SSE Streaming
Request shape (delta from PHASE0 §6)
POST /v1/chat/completions
Content-Type: application/json
{
"model": "...",
"messages": [...],
"stream": true,
"temperature": 0.2
}
Event format (per OpenAI / llama.cpp)
data: {"choices":[{"delta":{"content":"Hel"}}]}
data: {"choices":[{"delta":{"content":"lo"}}]}
data: [DONE]
Events are \n\n-terminated. data: prefix carries either JSON or the
literal [DONE] sentinel. SSE comments (lines starting with :) are
ignored.
Parser (in ffi/curl.lua post_sse)
- WRITEFUNCTION accumulates into a buffer.
- After each callback delivery, scan for
\n\nevent terminators. - For each complete event:
- Skip
:comment lines. - Strip the
data:prefix. - If body is
[DONE], signal end. - Else
dkjson.decode(body), extractchoices[1].delta.content, callon_event(content).
- Skip
- Carry incomplete tail of buffer into next callback.
UTF-8 codepoint splits at chunk boundaries are tolerated because we hold delivery in the buffer until a full event is assembled before decoding.
Renderer streaming
renderer.assistant_delta(chunk) writes raw characters to stdout (no
ANSI markup yet — the CMD: highlight depends on seeing a complete
line). renderer.assistant_flush() is called after the SSE stream ends:
it scans the accumulated stdout buffer (kept in renderer-local state) for
completed CMD: lines and emits ANSI sequences after-the-fact via cursor
manipulation. Open question Q12 below.
5. PTY Execution Model
parent (aish) child (cmd)
───────────── ───────────
forkpty() │
│ │
├─ master fd ───────┐ │
│ └────────┴── slave PTY (becomes child stdin/stdout/stderr)
│
├─ select / read master fd → renderer.exec_output_delta(chunk)
├─ write master fd ← user keystrokes (when interactive)
│
└─ waitpid() → exit_code = WEXITSTATUS(status)
For Phase 1's interactive cmds (vim/less/htop), aish flips its own
controlling tty to raw mode (tcgetattr + tcsetattr ICANON/ECHO off)
while the child is running, and restores on exit. Ctrl-C sends SIGINT
to the child via kill(pid, SIGINT) rather than the aish parent.
Non-interactive cmds (ls, git status) run on the same path; the
output is read from the master fd and rendered exactly as Phase 0's
exec_output frame did. The fact that the tty is a PTY rather than a pipe
does not change the visible UX for these.
Exit code: waitpid(pid, &status, 0); WEXITSTATUS(status). The §7
sentinel-echo hack is gone. PHASE0.md §7's amendment ("LuaJIT 2.1
popen-close caveat") becomes obsolete — same commit that lands the PTY
work amends §7 again to drop the caveat.
6. Session Persistence
Format
Each session is one JSONL file. One turn per line:
{"ts":"2026-05-10T19:00:01Z","role":"user","content":"list files"}
{"ts":"2026-05-10T19:00:04Z","role":"assistant","content":"CMD: ls"}
{"ts":"2026-05-10T19:00:05Z","role":"user","content":"[exec output]\n..."}
The first line is special: {"meta":{"started":"...","model":"fast","aish_version":"phase1"}}.
Lifecycle
- On startup,
history.luaopens<config.history.dir>/sessions/<utc-iso8601>.jsonlfor append. - Every
ctx:append_user(...)and assistant turn triggers asession:append(turn). :quitcloses the file and flushes (auto-save default).:save [<name>]renames the current session file to<name>.jsonl(or copies if user wants both auto + named).:resume <name>reads a JSONL file, recreates a Context, swaps it in. Q15 below covers the warn/refuse semantics on a non-empty current context.:sessionslists files in the dir with mtime + turn count.
Recovery semantics
Append-only JSONL means a partial last line (process killed mid-write)
is recoverable: history.load skips lines that fail to JSON-parse and
emits a warning. No fsync after every line in Phase 1 (overhead); a
crash may lose the most recent turn. Q? deferred.
7. Readline Custom Bindings
Wire rl_bind_keyseq from libreadline:
int rl_bind_keyseq(const char *keyseq, rl_command_func_t function);
Lua wrapper:
function M.bind(seq, fn)
-- ffi.cast a closure that calls fn() and returns 0
rl.rl_bind_keyseq(seq, fn_cast)
end
Phase 1 binds nothing user-visible. The reserved-key list is documented here so subsequent phases don't collide:
| Sequence | Reserved for | Phase |
|---|---|---|
\C-n |
Norris autonomous mode toggle | 3 |
\C-x\C-c |
Cancel running CMD: confirm prompt | 3 (deferred from Phase 1 — no consumer here) |
Phase 1 binds \C-n to a no-op handler that emits a [aish] Norris mode not yet implemented (Phase 3) status, just to verify the wiring works.
8. Migration from Phase 0
User-visible changes:
- Assistant responses stream instead of arriving in a block.
- All exec routes through PTY;
vim/less/htopwork. - A session log is written by default;
:resetno longer loses the conversation forever (it's in the JSONL).
Substrate (PHASE0.md §3) invariants are unchanged. The §6 broker
contract grows (request body adds stream: true; response handling adds
SSE) but the Phase 0 blocking shape stays callable. The §7 amendment
about LuaJIT 2.1 popen-close gets retired in the same commit that lands
PTY exec.
9. Out of Scope (Phase 1)
Per PHASE0.md §11, these belong elsewhere:
- Tool-calling / MCP (Phase 2)
- Norris autonomous mode (Phase 3)
memory.jsonlsummarization (Phase 4)- Multi-model routing / cloud fallback (Phase 5)
- Tree-sitter syntax highlighting (Phase 6)
Specifically out of Phase 1 scope despite proximity:
- Any binding consumer beyond the no-op
\C-nreserved key. - Streaming partial-tool-call deltas (Phase 2).
- Session search / pruning beyond
:sessionslisting (Phase 4).
10. Open Questions
| # | Question | Impact | Resolve by |
|---|---|---|---|
| Q11 | Hossenfelder-via-OpenRouter SSE: do all routed cloud models emit identical event shape, or do some flatten / re-frame? | broker.lua streaming parser robustness | Phase 7 (verify) |
| Q12 | CMD: highlight on streaming output: highlight as the line completes (delayed render), or live-highlight starting at the CMD: prefix detection? Cursor-positioning re-render trade-off. |
renderer.lua | Phase 4 (plan) |
| Q13 | TTY raw-mode restore on uncaught Lua error during PTY exec: SIGWINCH handler + on-exit hook, or accept that a crashed aish leaves a wrecked terminal? | executor + signal handling | Phase 4 (plan) |
| Q14 | \C-n reserved binding: bind a no-op now (verifies wiring) or defer the entire binding API to Phase 3 (where Norris is the first real consumer)? |
ffi/readline + repl scope | Phase 4 (plan) |
| Q15 | :resume <name> into a non-empty current context: refuse with a warning, prompt-overwrite, or merge? |
repl + history | Phase 4 (plan) |
| Q16 | Session log fsync: per-line (safe, slow) or close-only (fast, lossy on crash)? Default Phase 1 = close-only; revisit if crash recovery becomes a real concern. | history.lua | Phase 1 default; tracked for Phase 4 if it bites |
End of Phase 1 Manifest — aish