Engine & Turn Loop
The engine is the brain of Nehanda CLI. It runs entirely in-process as a single Node.js application — no separate server daemon, no background HTTP relay. Every user interaction flows through runUserTurn, the central function in lib/orchestrate.mjs.
The Turn Lifecycle
runUserTurn — The Main Entry Point
runUserTurn(db, rt, userText, io) is the single function that drives every interaction. It receives:
| Parameter | Type | Description |
|---|---|---|
db |
better-sqlite3 |
The SQLite database connection |
rt |
object | Runtime context: sessionId, conversationId, cwd, settings, _model |
userText |
string | The user’s input message |
io |
object | I/O adapter: write(), println(), ask(), spinner, onToolStart(), onToolResult() |
Steps
-
Hook:
UserPromptSubmit— Fires before anything else. A hook can block the prompt entirely (e.g., content policy). -
SDLC routing — If the conversation is in
idlephase and the I/O layer supports asking, the user is prompted: “Use SDLC workflow (plan → implement → test)?” If yes, the phase transitions toexploreand the user’s message is wrapped in an exploration prompt template. If no, the message is passed through as-is for a free-form turn. -
Jev-Mem hot path — The latest transcript tail is ingested as an observation (write path: type scoring → relation edges). The System-1 state is refreshed via
extractCanonicalState(Laya). Evidence is retrieved viaretrieveEvidence(read path: FTS + recency → sufficiency loop). Pinned context blocks are fetched frompinned_context. These are assembled into the compact System-2 message list: pinned blocks → canonical header → retrieved evidence → last 2 turns + last failed tool result. -
Provider resolution — The active provider is read from settings, and the wire model name is resolved. This determines whether the Anthropic SDK path or the OpenAI-compatible path is taken.
-
Provider dispatch — Control flows to either
runSdlcAnthropic()orrunSdlcOpenAICompat(). Both implement the same phase-driven loop but use different SDKs.
The Phase Loop
Inside the provider-specific functions, a while loop drives the SDLC phases:
while (['explore', 'planning', 'implement', 'test'].includes(phase)) {
const text = await runPhase(db, rt, io, hookRt, ...tools, phase)
if (text === null) return // model error
let advanced = false
if (phase === 'explore') advanced = await exploreGate(...)
if (phase === 'planning') advanced = await planGate(...)
if (phase === 'implement') advanced = await implementGate(...)
if (phase === 'test') advanced = await testGate(...)
if (!advanced) break
phase = getPhase(db, conversationId)
}
Each phase in the loop:
- Builds a phase-specific system prompt (see SDLC Workflow for prompt templates)
- Filters tools by phase (explore → read-only; planning → none; implement/test → all)
- Sends the transcript to the API with streaming enabled
- Collects the response (text + any tool calls)
- If tool calls exist, executes each one through the tool orchestrator
- Repeats until the model stops calling tools
- Passes the final text to the appropriate human gate
Tool Execution Pipeline
For each tool call the model produces:
Phase-based Tool Filtering
Before sending tools to the model, the orchestrator filters them:
| Phase | Available Tools | Rationale |
|---|---|---|
explore |
Read, Glob, Grep + explore_only dynamic tools |
Only discovery — no mutations |
planning |
None | Model writes plan as text, no tool calls |
implement |
All tools | Plan approved, execute freely |
test |
All tools | Run tests + mandatory safety checkers |
idle |
All tools (with permission gate) | Free-form interaction |
Local-only Tool Subset
When connected to local models (LM Studio, or Ollama without native tool support), the tool set is reduced to six core tools to conserve context window: Read, Write, Edit, Bash, Glob, Grep. Web tools, notebook tools, worktree tools, and agent tools are excluded.
Subagent Spawning
The Agent tool creates a child turn within the same process. The subagent:
- Gets its own
session_id(UUID) but shares theconversation_id - Runs
runUserTurnrecursively with the subagent’s prompt - Collects all output into a string
- Fires
SubagentStartandSubagentStophooks - Returns the collected output as the tool result
This enables the model to delegate subtasks without any external process management.
Error Handling
- Provider errors are classified by HTTP status (401 →
authentication_failed, 429 →rate_limit, 404 →model_not_found, 500/503 →server_error) and reported via theStopFailurehook. - Tool execution errors return
is_error: truein the tool result, which the model sees and can respond to. - Permission denials return an error tool result explaining the decision.
- Phase violations (calling a blocked tool in the wrong phase) return an error immediately without executing.
- Ollama fallback — if an Ollama model returns a “does not support tools” error, the engine automatically falls back to the
[TOOL_CALL]rescue path for all subsequent turns.
The io Adapter
The I/O layer abstracts away whether the engine is running in an interactive TUI or headless pipe mode:
| Method | Interactive (Ink TUI) | Pipe Mode |
|---|---|---|
io.write(text) |
Renders in the terminal component | Writes to stdout |
io.println(text) |
Renders with newline | Writes to stdout |
io.ask(question) |
Shows inline prompt, waits for input | Returns 'y' (auto-approve) |
io.confirmBlock(payload) |
Raises BlockConfirmMenu ([A]/[S]/[C]) in Ink event loop; resolves with 'approve', 'sandbox', or 'cancel' |
Absent — toolBash auto-denies |
io.spinner.start(msg) |
Shows spinning indicator | No-op |
io.spinner.stop() |
Hides spinner | No-op |
io.onToolStart(name) |
Updates tool indicator UI | No-op |
io.onToolResult(name, content, isError) |
Updates tool result display | No-op |
io.confirmBlock is the mechanism that ties the bashguard and safety-checker structured rejection payloads directly into the Ink event loop. When toolBash receives a blocked result and io.confirmBlock is present, it awaits the user’s decision before either proceeding, sandboxing, or cancelling — without blocking the Node.js event loop. When io.confirmBlock is absent (pipe/headless mode), the command is auto-denied immediately.