PUBLIC MODULE · lib/agent.js

Agent

lib/agent.js — Agent: owns context, provider/model selection, the tool loop, and persistence. Headless: no stdio, no process.exit, no TUI.

PUBLIC CORE ENTRY POINT. Agent coordinates the JSONL session store, crash-safe finish hooks, the forked tool sandbox, the tool loop, tool-call execution, status readouts, session lifecycle, and thinking level. It composes Context ← Env ← IO ← Agent and publishes that core tree as Agent.Context, Agent.Env, and Agent.IO. It does not import CLI, Markdown, or application/UI layers.

Ownership: - Agent alone owns the ordered in-memory context, provider/model selection per request, the tool loop, and persistence. - IO stays stateless: every request passes the COMPLETE current context; one IO instance is reused per active provider and reconstructed after kill() leaves it permanently closed. It reports usage PER TURN (the terminal event's usage envelope, see lib/context/usage.js) — nothing above it. - Session wiring (--session/--resume) lives wholly inside Agent; bindings pass an id, nothing more. Persistence is injectable (any SessionStore-shaped object); the file store is the default. - Agent alone owns the CUMULATIVE usage total (the usage getter): it sums every terminal's envelope in memory as run() sees them. Never persisted (no usage.json) — a fresh Agent starts at zero; bindings that show it (the TUI footer, a one-shot CLI's final line) read it fresh rather than tracking their own copy.

Agent's implementation is split across helper modules in lib/agent/; this file provides the public façade: - run.js the tool loop and its runaway guards; - tool-exec.js tool-call dispatch/execution, the result contract; - readouts.js usage/context-window/plan/connection readouts; - sessions.js fork/new/resume/list, the seeded system prompt; - thinking.js the thinking level.

Core rules (see run.js/tool-exec.js for the full contracts): every tool call in the LIVE response executes — identical repeats included; calls execute ONLY in real time, never from history. Tool-call ids belong to AGENT, never to the model (generated when missing, regenerated on collision). Tool-execution lifecycle is observable: TOOL_EXECUTE fires just before a call runs and TOOL_RESULT after each result is appended.

Tool-call SANDBOX (options.toolCall): file-scanned tools execute in a FORKED child process by default (fork: true); built-ins, programmatic tools, and INTERACTIVE tools stay in-process. Options: - fork: false disables the sandbox (in-process execution) - timeout: explicit HOST override of Env.toolTimeout (duration) - async: true executes ONE message's tool calls concurrently Every call is Agent-timed (including in-process). A schema-declared timeout argument is extracted and capped at Env.toolTimeoutLimit; schema.onTimeout gets at most 60s for cleanup or a final result.

SAFE MODE (options.safe; setSafe() toggles it at runtime — /safe, the ^X Settings row — applying from the next request): publish and execute ONLY read-only tools (schemas with safe: true) through the environment's SAFE VIEW (Env.safe): the Agent reads tools through this._safe ? env.safe : env, so one Env serves any number of Agents in either mode — safety is a view, never global state. Safe mode is FORCED (construction and setSafe both honor this._forcedSafe) when no supported OS sandbox is available — there is NO opt-out: mutation tools run only under an active OS sandbox, so on a mechanism-less platform the agent is read-only.

SYSTEM PAYLOADS: a tool returning { result, system } answers briefly via its tool result while system (string or string[]) appends as System messages right after it — before any queued user messages. DISPLAY PAYLOADS: { result, display } — display rides the outcome to TOOL_RESULT subscribers and is shown to the user WITHOUT ever joining the context: a tool can display data without sending it to the model (the edit tool's git-style diff).

class Agent class

The agent: the ordered context, the provider/model selection, the tool loop, the pending queue and the session persistence, behind one headless object.

static EVENT field

The numeric event vocabulary for Agent.onEvent: START, TEXT_START, TEXT_DELTA, TEXT_END, THINKING_START, THINKING_DELTA, THINKING_END, TOOLCALL_START, TOOLCALL_DELTA, TOOLCALL_END, DONE, ERROR, MESSAGE_COMMITTED, LOG, TOOL_EXECUTE, TOOL_DATA, TOOL_RESULT, CLOSE_MARKED, CLOSED, SENT_MESSAGE — see Agent.onEvent's documentation for each value's meaning and payload.

static RESPONSE_CALLBACK_EVENTS field

The [responseCallbackName, Agent.EVENT] pairs: every EVENT value's corresponding option-callback name ("onTextDelta" for Agent.EVENT.TEXT_DELTA, …), for hosts that prefer per-event callbacks over one onEvent listener.

constructor({ env, model, url, timeout, settings, context, tools, parent, name, description, session, sessionDir, sessionSave = true, createIO, toolCall, safe, spawnPermission, question, } = {…}) constructor

Build an agent over an environment; wires the session store (a named file session, a resumed one, an injected store, or none) and takes ownership of the seed context — a NEW (non-resumed) context starts with the seeded system prompt as its FIRST message(s).

onEvent(event, callback) method

Register a synchronous listener for one numeric Agent.EVENT value. Response payloads deliberately omit IO's string type. Indexed payloads carry contentIndex plus content, the assembled block after that IO event was consumed. End-event text, when present, is the provider-normalized authoritative full block snapshot. MESSAGE_COMMITTED receives the stored message after persistence. SENT_MESSAGE receives a queued user message as it enters context. The possible event values (the Agent.EVENT constants):

assistant text block began / grew / completed; indexed payloads (contentIndex, content)

the same lifecycle for a thinking (reasoning) block

the same lifecycle for one streamed tool call

context (payload is the stored message)

offEvent(handle) method

Remove one registration; returns false when it is absent.

toolStorage(toolname) method

Return this agent's mutable, transient storage object for one tool.

toolStorageClear(toolname) method

Clear one tool's transient storage, or every tool store when omitted.

get parent() getter

The creating Agent, or undefined when none was supplied.

get children() getter

Snapshot of direct child Agents.

createChild(options = {…}) method

Construct one direct child through the environment factory. The parent relationship is authoritative for ownership and delegation denial.

childAdd(child) method

Register one direct child Agent. @param {Agent} child @returns {Agent}

childRemove(child) method

Remove one direct child Agent. @param {Agent} child @returns {boolean}

get name() getter

Human-friendly Agent name.

set name(value) setter

Set the human-friendly Agent name. @param {string} value

get description() getter

Human-friendly Agent description; an empty string is valid.

set description(value) setter

Set the human-friendly Agent description. @param {string} value

get safe() getter

safe mode: only read-only (safe) tools publish and execute

setModel(selector) method

Select an exact, configured endpoint/model pair for subsequent turns. Validation happens before either live field changes, so a failed attempt leaves the current selection intact.

setSafe(value) method

Switch safe mode at runtime (the /safe command, the ^X menu). Applies from the NEXT request: idle cached provider connections are dropped (their tool selection was fixed at construction); an in-flight request finishes with the old catalog.

get sessionSave() getter

whether the current SessionStore saves to disk

sessionSaveSet(value) method

Enable or disable saving for the current SessionStore. Anonymous agents have no store and cannot acquire one implicitly.

get spawnPermission() getter

Generic delegation permission. A child Agent may never delegate further, regardless of its stored host/user policy. Tools decide how to ask when an independent Agent's permission is unset; Agent performs no spawning.

setSpawnPermission(value) method

Set generic delegation permission; non-booleans restore tool-owned asking.

setFolder(folder) method

Narrow this agent's tool working folder to an existing folder inside its environment project. undefined restores the environment root.

get folder() getter

The agent-local root used for file tools and their OS sandbox.

setQuestion(callbacks) method

Set (or replace) the QUESTION BRIDGE at runtime — the binding's rendering engine wires it once its overlays exist (the TUI hands its questionnaire overlay to the Agent after construction; see the constructor's question option for the contract). Applies to the next tool call.

static toolContext({ question = null, env, call, agent, storage, trusted = false, resetTimeout } = {…}) method

Construct the public tool-call context. The object is ordinary in-process data: it is never serialized or published to a provider.

updateToolMessage(name, text) method

Set (or clear) a tool's sticky MESSAGE on THIS agent — a compact live text the TUI renders above the input area (collected from the VIEWED agent; lib/agent/tool-messages.js).

toolMessages() method

tools with a live sticky message

detectToolMessages() method

Re-detect tool-provided display information from the current context.

run(options = {…}) method

Run the tool loop until done/error (lib/agent/run.js).

compact() method

/context-compact: ask the model to summarize the conversation (a structured, self-contained prompt), then replace the context with the surviving SYSTEM messages plus one ASSISTANT message holding the marked summary (lib/agent/compact.js). Compact is an ordinary turn observed through Agent events; a no-op (context untouched) when the model's turn returns no usable summary text.

get usage() getter

Cumulative usage across every request THIS Agent has made — every IO terminal event's usage envelope, summed in memory. Never persisted: a fresh Agent starts at zero.

get contextUsage() getter

The context-window readout for the status surface (most exact first: the provider's own report, the last provider-reported envelope, the word-count estimate marked approximate).

get planUsage() getter

The provider-reported PLAN/QUOTA readout of the current endpoint ({label?, quotas}; in-memory, last-known). null until a request reports one.

get busy() getter

Whether an agent run is currently in progress. @returns {boolean}

get endRequested() getter

Whether this agent asked to end after its current turn settles. @returns {boolean}

get closeMarked() getter

Whether close has been requested, including while a turn finishes.

get closed() getter

Whether close cleanup has completed.

close() method

Refuse new messages now and close after the current turn, or immediately when idle. CLOSE_MARKED precedes CLOSED; repeated calls are no-ops.

requestEnd() method

Ask to END this agent (its job is done): the current turn finishes first; the run loop then closes the agent instead of idling forever. Idempotent.

get ioState() getter

The connection/work state for the TUI's status indicator: "working" (a run is in flight), "disconnected" (the last turn failed connection-class), "idle" (otherwise).

cancel() method

Cancel the in-flight request (IO kill → terminal partial). Tool children in flight are signalled on an ESCALATION LADDER: the first cancel interrupts (SIGINT — a well-behaved command exits on its own; note a BUN worker shrugs SIGINT off — the next press is what lands), the SECOND forces the child out (SIGTERM), and a still-surviving child gets SIGKILL from the third press on. The run loop stops after the current tool outcome.

fork(id) method

Fork the current session into a NEW session id: the live context continues under a fresh store (flushed immediately); the old file stays behind as a snapshot. "0"/"false"/"anon" forks into an ANONYMOUS (hidden, unpersisted) session.

newSession(id) method

Start a NEW session with an EMPTY context (re-seeded with the system prompt): the old session file is closed (its flushed content stays on disk — fork() first to keep a snapshot). With no id a random UUID is chosen (an anonymous session STAYS anonymous); "0"/"false"/"anon" always switches to an ANONYMOUS (unpersisted) session.

renameSession(name) method

Rename the current session: the session file takes the proper name (session-<name>.jsonl; the old name's file is gone) — /session-name. An anonymous session has no file to name.

enqueue(message) method

Deliver a user message: while a request is in flight it is queued for the next request; while idle it is appended and starts a request immediately. Pending messages send only after the in-flight IO turn settles (never mid-response). The flush appends them AFTER any tool results (tool calls answer first), append-merged into one user message (consecutive same-type merging). An identical user submission immediately following another user submission is ignored: it is normally an accidental second submit while a run is starting.

enqueueFile(fileName) method

Read an existing file as a binary user message and deliver it. The path is resolved inside the Agent folder; missing files, folders, and paths outside that folder fail before any message is queued.

pathInfo(path, options = {…}) method

Inspect a path using the Agent's file-security boundary.

get pending() getter

the pending queue (a copy — drainPending to remove)

drainPending() method

Remove EVERY pending message, returning them (the TUI's Option+↑ recall: the queued messages go back into the input area, merged, for editing).

resumeSession(id) method

Resume an EXISTING session: the live context is replaced with the session's stored context under its store; the old session file is closed (its flushed content stays on disk).

listSessions() method

Every session in the store's folder, latest first, each with a first-user-message preview (the ^X menu's Resume sub-menu, /resume's Tab completion).

listSessionsAsync() method

Nonblocking counterpart of listSessions().

latestSessionId() method

the latest session's id (undefined: no sessions)

get thinking() getter

the current thinking level (undefined = provider default)

setThinking(level) method

Set the thinking level for subsequent requests (THINKING_LEVELS; each provider translates it to the nearest symbol the model accepts). Applies to already-open provider connections too.

append(message) method

Append a caller-built message (e.g. AI user input), mirrored to the session store when one is wired. Library consumers use this to grow the context between turns; an active run flushes it at its durability points.

edit(i, message) method

Replace context[i] (Context edit semantics: rebuilt from recognized fields, stale provider identifiers dropped), mirrored to the session store when one is wired.

editBlock(i, j, block) method

Replace context[i].content[j] (the containing message is rebuilt).

rollback(i) method

Remove every message at index >= i (RangeError when i is not an existing index).

pop() method

Remove and return the last message (undefined on an empty context).

removeMessages(indexes) method

Remove selected context messages.

class SessionStore class

A JSONL session store: the live context array plus its atomic file rewrite.

Defined in lib/agent/session.js

constructor({ id, dir, context = [], origin, process: proc, uuid, name, save = true } = {…}) constructor

Open a store for a session id (the file is created on the first flush of a non-empty context); registers the crash-safe finish hook.

get save() getter

whether this store writes its context to disk

saveSet(value) method

Enable or disable persistence without replacing the live context. Enabling saving makes the complete current context eligible for the next flush.

append(message, options) method

Append a message, MERGING with the last one when possible (consecutive same-type messages / same-sub-type blocks fold — Context appendMessage). Returns the stored message.

prepend(messages) method

Insert messages at the FRONT of the context — Agent's seeded system prompt, which must always be the first message(s) of a fresh context.

edit(i, message) method

Replace context[i] through Context (stale identifiers dropped).

editBlock(i, j, block) method

Replace context[i].content[j] through Context.

rollback(i) method

Remove all messages at index >= i.

pop() method

Remove the last message.

removeMessages(indexes) method

Remove selected messages.

flush() method

Synchronously make the CURRENT context durable (see _planFlush for the remove/none/append/full decision). Idempotent; a no-op when nothing changed since the last flush.

close() method

Flush and detach the finish hook.

rename(newId) method

Rename the session: BOTH the stable id and the file's NAME segment become newId (the same session — the date/uuid8 prefix carries over unchanged) and the old file is gone. Refuses to clobber an EXISTING other session (a directory scan by id, the file name no longer being a direct function of it).

static originOf({ id, dir } = {…}) method

The ORIGIN FOLDER recorded in a session file's metadata line (the cwd the session ran in), or undefined (no such session). The resume-anywhere contract: an explicit --resume <id> makes this folder the process cwd.

static resume({ id, dir, process: proc, save = true } = {…}) method

Load a session file into a fresh store holding its live context. The file is found by a directory scan matching id against each file's metadata line (the file name is a presentation detail, not a direct function of id — see the module doc). Non-message records in the file are quietly ignored (tolerant reader — see loadMessages).

static list({ dir, cwd, limit = 50 } = {…}) method

Every session in the folder, LATEST FIRST, each with a small preview: a snippet of the first user message (whitespace-folded, capped). With cwd, only sessions whose metadata records THAT origin folder list (the resume contract: a project sees its own sessions; foreign metadata-less files are skipped by the FIRST-LINE scan, never parsed in full). Previews are read only for the newest limit files (stats are cheap; parsing is not). Unreadable files list without a preview, never an error.

static listAsync({ dir, cwd, limit = 50 } = {…}) method

Nonblocking counterpart of list(). It deliberately has its own async folder identity rather than calling sameFolder(), whose realpathSync would stall the interactive loop.

static latest({ dir, cwd } = {…}) method

The id of the most recently modified session in the folder (of the cwd origin when given), or undefined when the folder has none.

static deleteAll({ dir } = {…}) method

Delete EVERY session file in the folder (the /sessions-delete-all! contract — the user confirmed; foreign files stay untouched).

sessionDir() function

The default namespace session folder under settings (created when missing) — outside the project tree by design (see the header).

Defined in lib/agent/session.js

loadMessages(data) function

Parse session data (file text or an already-parsed value) into a context array. Tolerant reader: accepts a JSON array, or JSONL (one JSON value per line); quietly drops every value that isn't a core-shaped message OR a metadata RECORD (an object with a string type — isRecord, lib/context/validate.js): records (the note tool's store backup, user annotations) ride into the context, while the file's own session-metadata header record stays OUT (it maps the file to its folder — it is not context content, and re-loading it would duplicate it on the next flush).

data string|Array
raw file text or a parsed array

Returns Array<object> — the messages and records found, in order

Defined in lib/agent/session.js

sameFolder(a, b) function

Two folder paths are the same place (realpath when it exists).

Defined in lib/agent/session.js

findSessionFile(folder, id) function

Find a session file in folder by its STABLE id (a directory scan reading each file's first line — cheap even at hundreds of files; the file name does not encode id directly).

folder string
id string

Returns string|undefined — the file path, or undefined

Defined in lib/agent/session.js

resolveAgentPath(path, { folder = process.cwd(), boundary = folder } = {…}) function

Resolve a relative path from folder, bounded by boundary (the project root by default).

Defined in lib/agent/path-info.js

async pathInfo(path, { folder = process.cwd(), requireExists = true } = {…}) function

Validate and inspect a path rooted at an Agent folder.

Returns Promise<{path:string,isFolder:boolean,mimetype?:string}>

Defined in lib/agent/path-info.js

onFinish(fn, { process: proc = process } = {…}) function

Register a synchronous cleanup to run at process finish.

fn () => void
[options] Object
[options.process] NodeJS.Process
injectable for tests

Returns () => void — unregister (idempotent)

Defined in lib/agent/finish.js

runFinish() function

Run every registered cleanup. Errors never propagate.

Defined in lib/agent/finish.js

armFinishSignals({ process: proc = process, signals = ["SIGINT", "SIGTERM", "SIGHUP"], } = {…}) function

Trap termination signals: run cleanups synchronously, then re-raise the signal with our handlers removed so the process dies by the signal itself. Idempotent; returns a disarm function.

[options] Object
[options.process] NodeJS.Process
injectable for tests
[options.signals] string[]

Returns () => void — disarm

Defined in lib/agent/finish.js

_resetFinish() function

Test-only reset: forget cleanups and arming state.

Defined in lib/agent/finish.js

callToolSandboxed({ env, name, args, timeout = DEFAULT_TOOL_TIMEOUT, sandbox = false, detached = true, cwd, spawnImpl = spawn, onChild, onData, onQuestion, questionBridge = false, }) function

Run one tool call in a forked child process; never throws — every failure (spawn error, crash, timeout, bad result line) resolves as {ok: false, error}.

options Object
options.env object
the parent's registry surface (dir/settings/tool roots are handed to the child)
options.name string
flattened tool name
options.args object
the tool call's arguments
[options.timeout] number
ms before the child is killed (default 120s; explicit 0 disables — Agent's outer timeout wrapper uses 0 so it alone owns callback grace + termination)
[options.sandbox] boolean
run the worker under the OS write sandbox (lib/env/os-sandbox.js — the kernel denies writes outside Env's project folder; the tool's `sandbox: true` metadata)
[options.detached] boolean
default true; false inherits the hosts process group
[options.cwd] string
agent-local tool root (defaults env.cwd)
[options.onData] (chunk: string) => void
LIVE streaming: the worker's stderr line channel ("{"data": ...}" records — a tool's incremental output, e.g. a running bash command) rides back here DURING execution, capped on arrival; the result line keeps sole ownership of stdout.
[options.onQuestion] (questions: Array) => Promise<Array|null>|Array|null
Trusted-host bridge for a sandbox worker's typed fd-3 `ask` request. fd 3 is worker→host and fd 4 host→worker JSONL; both close at teardown.
[options.questionBridge] boolean
whether the worker receives a callable question bridge (false preserves the ordinary headless refusal).
[options.spawnImpl] typeof spawn
injectable for tests

Returns Promise<{ok: true, value: any} | {ok: false, error: string}>

Defined in lib/agent/tool-sandbox.js

DEFAULT_TOOL_TIMEOUT = ENV_DEFAULT_TOOL_TIMEOUT constant

Default Agent-enforced tool-call duration (120 seconds).

Defined in lib/agent/tool-sandbox.js

resultContent(value) function

Normalize a tool return value into result content blocks — the full tool-answer contract. A tool may return: - a string → one text block; - an array of content blocks → used as-is; - {content: [...]} → the content array; - any other JSON value → JSON.stringify'd into one text block; - {result, system?, display?}result is normalized by the rules above, while system (string or string[]) appends as System messages right after the tool result and display is retained on it for context viewers (handled by the caller, not here). Throwing fails the call: the error message becomes the tool-result error content (error: true on the result message).

value *
the tool's return value

Returns Array<object> — content blocks for the tool-result message

Defined in lib/agent/tool-exec.js

thinkValue(level) function

Map a thinking-level word to the request option value: off/false/0 → false, on/true/1 → true, level words (low/medium/high/xhigh) pass through, undefined/"default" → undefined (the model's default).

Defined in lib/agent/thinking.js

reseatAgent(agent, { id } = {…}) function

Build the FRESH Agent that replaces agent for a new session.

agent object
the agent being replaced (the TUI's viewed one)
[options] Object
[options.id] string
the new session's id; "0"/"false"/"anon" (or no id while the old session is anonymous) starts ANONYMOUS

Returns object — the fresh Agent, seated in the old one's group

Defined in lib/agent/reseat.js

isAnonymousId = (id) => id === "0" || id === "false" || id === "anon" constant

The anonymous-session id spellings: "0", "false", "anon" (sessions.js).

Defined in lib/agent/reseat.js

RESPONSE_CALLBACK_EVENTS = Object.freeze([…] constant

IO callback property paired directly with its numeric Agent event.

Defined in lib/agent/events.js