COLLECTED FROM SOURCE — NEVER COPIED PROSE

Schemas & contracts

Module docs, @typedef blocks, exported symbols, class members, object keys, and live examples — lifted from the files that own them on every build.

Jobs — activation, scheduling, execution and task files

lib/jobs.js

Portable, folder-only project Jobs. ai-jobs enables, ai-jobs-disabled disables. Operations are best effort: there are no locks, daemon identity records, or cross-process coordination. Concurrent scans/mutations may duplicate work, lose updates, or race archives. Atomic single-file replacement remains used.

lib/jobs/tasks.js

Pure Markdown/frontmatter parsing with wholesale fallback and task-local rejection.

  • parseTask(filename, source) — Parse one task without filesystem side effects; declared bad frontmatter rejects the task.
  • parseTasks(tasks) — Parse independently so duplicate ids and empty prompts remain task-local rejections.

lib/jobs/operations.js

  • async scheduleJobs(root, command, options = {…}) — Shared task command boundary.
  • async validateJobsOperational(root, options = {…}) — Operational eligibility is checked at publication and invocation from folder state.

lib/jobs/dispatcher.js

  • async dispatchJobs(projectRoot, options = {…}) — Best-effort serial scan.
  • cycleRecord(at) — Create the location-neutral public result record for one Jobs scan.

lib/jobs/agent-execution.js

In-process job Agent execution: one fresh, headless Agent per task against a wake-shared Env. An explicit cancel interrupts it; Jobs has no whole-job deadline and a wake never force-kills a running job.

bin/scripts/jobs

Thin operator CLI. Local lifecycle never constructs Env or touches host scheduling.

Context schema — messages and content blocks

lib/context/types.js

lib/types.js — context/message/content types (concrete JSDoc).

Own schema, no external dependency (Pi/OpenCode are prior art only). A context is an ordered array of messages. Every message has a numeric type and a content array; all other fields are optional metadata, tolerated by readers (unknown fields never invalidate a value).

Addressing is array/block based — messages have no local ids: message = context[i]; block = context[i].content[j] Provider response/cache/block identifiers may ride as optional metadata and are dropped by Context when an edit makes them stale.

typedef TextContent

Plain text — the universal block every provider speaks.

type "text"
text string
typedef ImageContent
type "image"
[mimetype] string
media type, when known
content string
typedef BinaryContent

Raw file bytes for models that accept binary input (vision models fail on base64 *text* but consume binary blocks). Providers map image/* blocks to their binary/image channels and tolerate the rest.

type "binary"
[mimetype] string
media type (application/octet-stream fallback)
[filename] string
safe display/upload basename; never a source path
content string
base64-encoded bytes
typedef ThinkingContent

May carry provider metadata (e.g. replay signatures) as extra fields; preserved through Context edits.

type "thinking"
text string
typedef ToolCallContent

May carry provider metadata as extra fields; preserved through Context edits.

type "toolCall"
callId string
provider call id
name string
flattened tool name
arguments *
tool arguments (string while streaming, parsed value after)
typedef Content

Small discriminated objects keyed by `type`. Unknown block types are tolerated (tolerant reader).

typedef Message

System messages stay visible in ordered position. Assistant content is an ordered mix of text/thinking/toolCall blocks — thinking is a block, never a separate message.

type number
one of MessageType (1..4)
content Content[]
typedef ToolResultMessage

A tool's answer, linked to its call; built by resultContent() (see the Tool schema contract).

type 4
content Content[]
result content
callId string
links to the ToolCallContent.callId
[name] string
tool name, when the provider requires it
[error] boolean
error status
typedef Context

An ordered array of messages — the schema everyone talks.

Provider contract

lib/env/provider.js

Provider class contract normalization and stable error taxonomy.

A plugin default-exports one class and may use public Env/Context helpers, but never imports the private OpenAI defaults. The harness completes missing methods from lib/env/openai.js. Construction replaces the old connect() hook; each instance owns one endpoint's transport state.

  • defineProvider(Protocol, { name } = {…}) — Complete a standalone provider class with OpenAI-compatible defaults.
  • class ProviderError extends Error — Provider/transport error with a stable failure class.
  • classifyError(err, providerName) — Classify a raw error into the stable provider taxonomy.

methods completed with OpenAI defaults when the class lacks them: context2msg msg2events send read close models login reportPlanUsage testConnection

providers/ollama.js reference implementation

lib/providers/ollama.js — Ollama connector (first target; proves the provider contract and the metadata/models()/login() shape together).

Direct HTTP to the Ollama local server (never the ollama CLI): POST {url}/api/chat — NDJSON streaming chat GET {url}/api/tags — local model listing

Uses the default HTTP backend wholesale (connect/send/read/close) — Ollama's NDJSON stream is line-delimited JSON, which the default blocking-await read already speaks. Only the two translators plus the pi-pattern metadata surface live here.

Wire mapping (context format -> /api/chat): system/user -> {role, content} (text blocks joined) assistant -> {role:"assistant", content, tool_calls?} — thinking blocks are local-only and never hit the wire tool result -> {role:"tool", name, content} — name resolved from the message's name, else the matching toolCall block image and generic binary blocks ride Ollama's sole documented images channel (base64) on user and tool-result messages. The selected local model decides which binary formats it can consume; Ollama exposes no separate generic-file field in /api/chat. Tools: Env catalog entries -> {type:"function", function:{name, description, parameters}}; omitted when the catalog is empty.

Wire mapping (/api/chat NDJSON -> response events): content strings stream as text_start/text_delta/text_end at contentIndex 0; tool_calls become toolcall_start/toolcall_end pairs (Ollama supplies no call ids — local ollama-N ids are generated for context linkage); the done:true frame maps prompt_eval_count/eval_count into the usage envelope. Native frames ride along as event metadata where useful.

Errors: HTTP statuses and stream-level {error} frames surface through IO's auth/network/provider/malformed taxonomy; login() is a trivial no-auth procedure; models() refreshes the cached list in the provider namespace on every access and falls back to the cache offline.

Tool contract — PUBLISHES / REQUIRES / RETURNS (add a tool by reading this)

lib/env/tools.js

PUBLISHES — the module shape a tool file must export

lib/env/tools.js — tool-folder scanning (private to Env).

The scan is NOT recursive: only a root's TOP-LEVEL .js files are tool modules. Sub-folders are private to the tools — a well-designed tool is a thin wrapper (tools/read.js) whose helpers/libraries live beside it (tools/read/grep.js, tools/read/mime-map.js, ...) and are never imported or published by the scan.

Module contract: a module's toolDescription(env) — or describe(env) when toolDescription is undefined (the fallback name) — returns an object keyed by exported function name, each value an MCP-like {description, inputSchema} (plus optional harness metadata: safe/trusted/ sandbox/onTimeout/secret/fn — never published). onTimeout is an Agent-facing callback, not a model-facing schema member. secret: true hides the tool from the PUBLISHED catalog (never sent to the model) while it stays callable directly (bin/scripts/tool, the TUI's /<tool> and Tools menu). A described function is normally published only when the module exports a callable of that name; an entry may instead carry its own fn (a closure built at scan time) for names that have no static export — the env-dependent shortcut tools tools/mcp.js builds per configured server (mcp-<name>). The scan-time env argument lets a module read live settings (e.g. settings.mcp) to decide which such entries to contribute; it is REBUILT on every refresh, so settings edits take effect on the next tool-refresh. Modules without any of the three functions are omitted from the catalog (their import side effects still run). Duplicate names are diagnosed (throw), never resolved arbitrarily.

Every scan imports modules with a cache-busting ?v=<revision> (bumped on every refresh), so wrappers re-run and re-import their helpers (stamped with Env.toolTimestamp()). Bun IGNORES query strings on file:// URL imports — plain absolute paths only.

A module may also export settingsSchema() (zero-arg, sync, same scan cadence as toolDescription): { [settingKey]: {default, description} }, merged into the DEFAULTS SCHEMA (see lib/env/settings-schema.js, env.defaultsSchema()) — a tool's own self-documentation for the setting(s) it reads (tools/mcp.js contributes mcp this way). Purely discovery metadata: an unknown settings key is never rejected either way.

  • async scanToolRoots(roots, env, { trustedRoots = [] } = {…}) — Scan tool roots into a flattened name -> { fn, schema, file, safe?

lib/env/tool-registry.js

  • registerTool(env, name, fn, schema, { builtin = false, file, allowTrusted = builtin } = {…}) — Register a tool into the flattened callable lookup (the scan-load calls this; programmatic tools may too).
  • toolSchemas(env, names, { includeSecret = false } = {…}) — The publishable tool catalog.

tools/read.js

PUBLISHES — a worked example: the minimal wrapper shape (read-only, no ctx)

/**
 * tools/read.js — the `read` tool: an independent, read-only,
 * cwd-rooted file access tool. Thin WRAPPER publishing the callable
 * implemented under tools/read/. The tool scan is NOT recursive:
 * sub-folders are never scanned, so the modules in read/ (paths,
 * grep, mime-map, mime-detection, read, util) are this tool's PRIVATE
 * helpers — internal libraries it does not export as tools.
 *
 * One exception BY DESIGN: tools/guard/ is the shared guard layer
 * EVERY tool imports (read, write, edit, bash) — the deterministic
 * path resolver (guard/resolve.js) and the fast-path content
 * trip-wire (guard/paths.js); path traversal policy is global,
 * never per-tool (see those files).
 *
 * Helper imports are stamped with the shared refresh revision
 * (toolRevision() — lib/env/mcp.js, the tiny runtime the tool scan
 * publishes it through): every refreshTools() bumps it, the wrapper
 * re-runs, and the helpers re-import fresh — editing any read/*
 * module applies on refresh without touching this wrapper.
 */

import { toolRevision } from "../lib/tool-runtime.js"; // the tool-runtime leaf: one instance across cache-busted imports — no whole-library load for a timestamp

const timestamp = toolRevision(); // shared tool-registry revision
const { read, readDescription } = await import(`./read/read.js?now=${timestamp}`);

export { read };

export function toolDescription() {
  return { read: readDescription() };
}

tools/write.js

PUBLISHES — a worked example: sandboxed (`sandbox: true`, forked, write-jailed)

import { mkdir, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { toolRevision } from "../lib/tool-runtime.js"; // the tool-runtime leaf: one instance across cache-busted imports — no whole-library load for a timestamp

const timestamp = toolRevision();
const { rejectSymlinkPath, resolveCwdPath } = await import(`./guard/resolve.js?now=${timestamp}`);
const { enforceContentPolicy } = await import(`./guard/paths.js?now=${timestamp}`);

export async function write({ path, content, ask = false } = {}, context) {
  if (typeof content !== "string") throw new TypeError("write: content must be a string");
  // The agent folder is the working directory, while Env.cwd bounds the
  // project. Relative ../ paths may reach project siblings but never leave
  // that project; the OS sandbox has the same project-wide write scope.
  const writeCwd = context?.agent?.folder ?? context?.env?.cwd ?? context?.agent?.env?.cwd ?? process.cwd();
  const projectCwd = context?.env?.cwd ?? context?.agent?.env?.cwd ?? writeCwd;
  const contentCwd = projectCwd;
  const resolved = await rejectSymlinkPath(resolveCwdPath(path, { cwd: writeCwd, boundary: projectCwd }), { cwd: projectCwd });
  // A live question bridge makes the write interactive already; ask by
  // default there. Direct/non-agent callers still get a deterministic
  // refusal unless they explicitly request permission.
  const requestPermission = ask === true || typeof context?.question?.ask === "function";
  await enforceContentPolicy({ path, content, ask: requestPermission, context, askable: true, cwd: contentCwd, lax: true });
  await mkdir(dirname(resolved), { recursive: true });
  await writeFile(resolved, content, "utf8");
  return `Successfully wrote to ${path}`;
}

export function toolDescription() {
  return { write: {
    trusted: true,
    description: "Create or overwrite a working-folder file; create parent folders as needed.",
    inputSchema: { type: "object", properties: {
      path: { type: "string", description: "Path relative to the working folder." },
      content: { type: "string", description: "Content to write." },
      ask: { type: "boolean", description: "Ask for permission with surrounding context when the content names an existing path outside the working folder." },
    }, required: ["path", "content"] },
  } };
}

lib/agent.js

REQUIRES — every in-process call's (args, ctx) signature: ctx = {question, env, call, agent}

  • _toolContext(call, { trusted = false } = {…}) — The TOOL CONTEXT handed to interactive tools (in-process only): the question bridge, the environment VIEW the call runs under (the safe view in safe mode — an interactive tool reading settings or reporting status sees exactly what the call may), the CALL's own linkage ({callId, name}) so a tool can key per-call state (the edit tool's rollback record), and the calling Agent itself.
  • _toolEnv() — The environment view for TOOL operations: safe mode reads Env's SAFE VIEW (env.safe — a facade whose catalog is read-only tools only and whose callTool refuses unsafe ones), normal mode the environment itself.

lib/agent/tool-exec.js

REQUIRES — dispatch: which tools fork (sandboxed worker, REDUCED ctx) vs stay in-process (full ctx)

  • async callToolFor(agent, name, args, call) — Invoke one tool: SANDBOXED (forked child, lib/agent/tool-sandbox.js) when the tool is file-scanned (a child can rebuild it from the tool roots).

tools/question.js

REQUIRES — a worked example: an interactive tool (`interactive: true`) using ctx.question.ask

tools/question.js — the question tool: ask the user one or more structured questions mid-task (pi question-tool semantics): 1–4 questions, each with a ≤16-character header chip, 2–16 options (label ≤60 characters + description, optional preview), and an optional multiSelect flag. The engine appends its own free-text affordance (pi's "Type something." row) — it is NOT an option here.

DISENTANGLED from the rendering engine: the tool owns argument validation and answer normalization; the harness's QUESTION BRIDGE (context.question.ask — provided by the Agent, which received it from the TUI/HTML/WebSocket binding) owns rendering the questions and previews and collecting the answers. The bridge is the INTERNAL second argument, never part of the published schema. Bridge answer shape, one per question: { labels: string[] } — the chosen option label(s) { text: string } — a custom typed answer { abandoned: true } — the user pressed Esc Without a bridge the tool REFUSES (throws): a session with no one to ask cannot ask — CLI bindings wire the bridge ONLY into the ACTIVE agent (the one being displayed), so a session running headless execution has no question handler and every question is refused. The refusal text tells the model to proceed with its best judgment instead.

Read-only (safe: true): asking the user mutates nothing — safe-mode Agents may ask questions. The forked, OS-sandboxed worker uses the Agent's typed fd-3/fd-4 JSONL ask/answer bridge; no host callback crosses the sandbox.

lib/agent/tool-worker.js

REQUIRES — the forked worker's REDUCED ctx: {question: null, env} only — no `call`, no `agent`

lib/tool-worker.js — the child-process side of lib/tool-sandbox.js.

Spawned as <runtime> lib/tool-worker.js: reads ONE JSON line from stdin — {dir, settings, roots, name, args, file?} — rebuilds an Env from it, loads the ONE tool module the parent's registry named (file; never a root rescan), invokes the tool, and writes ONE JSON result line to stdout: {ok: true, value} or {ok: false, error}. All ordinary failures are reported on stdout with exit 0; a nonzero exit (or silence) means the tool destroyed the process (e.g. process.exit) and the parent reports that instead — which is the entire point of the sandbox.

THE RESULT LINE IS THE LAST THING WRITTEN — and it must survive everything a tool printed at IMPORT (a script without an import.meta.main guard can dump megabytes onto stdout while the tool scan loads it): the process exits only after the writable side FLUSHED. process.exit() right after write() discards user-space pipe buffers — the truncated-result race the parent reports as "worker exited without a result (code 0)".

This file is executed, not imported, and must stay runtime-agnostic (no Bun-only APIs).

lib/agent/tool-exec.js

RETURNS — every shape a tool's return value may take

  • resultContent(value) — Normalize a tool return value into result content blocks — the full tool-answer contract.
  • async executeToolCall(agent, call)

Agent identity and delegation metadata

lib/agent.js

  • get parent() — The creating Agent, or undefined when none was supplied.
  • get children() — Snapshot of direct child Agents.
  • createChild(options = {…}) — Construct one direct child through the environment factory.
  • childAdd(child) — Register one direct child Agent.
  • childRemove(child) — Remove one direct child Agent.
  • get name() — Human-friendly Agent name.
  • get description() — Human-friendly Agent description; an empty string is valid.
  • get spawnPermission() — Generic delegation permission.
  • setSpawnPermission(value) — Set generic delegation permission; non-booleans restore tool-owned asking.

lib/agent.js

Env.createAgent installation at the Agent/Env composition boundary

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).

lib/env.js

  • onEvent(event, callback) — Subscribe to a generic Env lifecycle event (distinct from Agent.onEvent's numeric turn events — these are Symbols).
  • offEvent(handle) — Remove a generic Env lifecycle listener by its opaque handle.

lib/env/events.js

  • ENV_EVENT = Object.freeze({…}) — Generic Env lifecycle events.

Headless usage — running the Agent from your own script, no TUI

lib/agent.js

the headless core entry point: callbacks replace stdio/process.exit/terminal APIs

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).

bin/scripts/agent

the reference consumer: a one-shot stdin-to-stdout tool loop, as a CLI

agent — headless tool-loop CLI (thin binding over lib/agent.js).

Usage: <prefix>-agent [--model <endpoint>/<model>] [--url <url>] [--token <key>] [--timeout <ms>] [--tools <list>] [--session <id> | --resume <id>] [--max-turns <n>] [--max-tool-calls <n>] [--login] [--logout <endpoint>] [--help]

Contract: - Reads stdin to EOF (shared grammar: whole JSON context first, otherwise JSON-per-line with non-JSON lines as user messages). - Runs the Agent tool loop: provider request → tool calls executed in-process → results appended → next request, until done/error. - stdout: normalized response events, one JSON object per line (every request's events stream in order; the last done carries the final assembled message). stderr: diagnostics only. - --session <id> persists the context to a JSONL session log; --resume <id> replays it first. Session wiring lives in Agent. - SIGINT/SIGTERM cancels the in-flight request: the terminal partial is persisted, exit 130.

Exit codes: 0 success · 1 usage/other · 2 auth · 3 network · 4 provider error · 5 malformed input · 130 cancelled

Agent-owned tool timeout policy

lib/env/tool-timeout.js

lib/env/tool-timeout.js — Env's tool-timeout policy (private): one default per call and one hard ceiling for a tool schema's model-facing timeout argument. Durations use the shared parser, so settings accept millisecond numbers or unit strings ("30s", "5m").

The Agent owns enforcement. Tools may DECLARE timeout, but the Agent extracts it before invocation; tools never race a second timer against the harness. A tool's optional Agent-facing onTimeout callback gets one bounded cleanup/final-response grace period.

  • DEFAULT_TOOL_TIMEOUT = 120_000 — Default Agent-enforced cap for one tool call: two minutes.
  • DEFAULT_TOOL_TIMEOUT_LIMIT = 20 * 60_000 — Maximum model-requested tool duration: twenty minutes.
  • TOOL_ON_TIMEOUT_LIMIT = 60_000 — Maximum Agent-facing onTimeout cleanup/final-response grace.

lib/agent/tool-timeout.js

lib/agent/tool-timeout.js — the Agent-owned timeout boundary around every tool invocation (private to Agent). A tool may declare a top-level model-facing timeout property; the Agent extracts it, parses it, caps it at Env.toolTimeoutLimit, and never passes it to the tool. With no request the default is the explicit host Agent.toolCall.timeout override, else Env.toolTimeout.

A registry entry may carry schema.onTimeout (stripped from the provider catalog). When the main duration expires, the callback gets at most TOOL_ON_TIMEOUT_LIMIT to clean up or return a FINAL result; afterward the Agent kills the forked worker (or abandons an in-process promise) unconditionally. Undefined means cleanup-only and the ordinary timeout error is returned to the model.

  • prepareToolTimeout(agent, entry, args) — Resolve a call's effective timeout and strip a declared timeout from the arguments handed to the tool.
  • async runWithToolTimeout({ agent, entry, name, args, call, timeout, requested, invoke, terminate = () => {}, onResetTimeout, onInterrupt, // Private test seam. Every deadline must be cleared when execution wins; // unref only permits process exit, it does not release a timer's closure. setTimer = setTimeout, clearTimer = clearTimeout, }) — Run an invocation under the Agent timeout.

lib/env.js

  • get toolTimeout() — Default Agent-enforced duration of one tool call.
  • get toolTimeoutLimit() — Hard ceiling for a tool's schema-declared, model-requested `timeout` argument.

Agent-owned runaway guard — context-usage caps, settable

lib/env/context-guard.js

lib/env/context-guard.js — Env's runaway-guard policy (private): the agent tool loop is capped by CONTEXT USAGE (a fraction of the model's context window), not by counting requests/tool calls — a model that quotes one huge file spins the context out just as "runaway" as one that loops forever on trivial calls, and a request count can't tell the two apart.

Two independent caps, both settable in settings.json: - contextGuardCap the OVERALL ceiling (default 0.9 — always leaves room for /compact itself to run); crossing it refuses to continue — user oversight and /compact are required first. - contextGuardTurnCap the growth ceiling for ONE agent turn alone (default 0.4): even starting from a near-empty context, a single turn cannot consume more than this fraction of the window before it's stopped.

A settings value > 1 is read as a PERCENTAGE (90 == 0.9); either spelling works. The Agent owns enforcement (lib/agent/run.js) — this module only resolves the two numbers.

  • DEFAULT_CONTEXT_GUARD_CAP = 0.9 — The overall context-usage ceiling: 90% (always leaves /compact room).
  • DEFAULT_CONTEXT_GUARD_TURN_CAP = 0.4 — The per-turn context-growth ceiling: 40%.
  • configuredContextGuardCap(settings = {…}) — @returns {number} the overall context-usage cap, a (0,1] fraction
  • configuredContextGuardTurnCap(settings = {…}) — @returns {number} the per-turn context-growth cap, a (0,1] fraction

lib/agent/run.js

  • contextGuardTrip(agent, turnStartUsed) — The CONTEXT-USAGE runaway guard (settings.contextGuardCap / contextGuardTurnCap — lib/env/context-guard.js): checked at the top of every loop iteration, so it gates EVERY continuation alike — the very first request of a turn that inherited an already-critical context, another tool-call round, or the next queued message alike.

lib/env.js

  • get contextGuardCap() — The Agent tool loop's runaway guard: the OVERALL context-usage ceiling, a (0,1] fraction of the model's context window (settings value > 1 reads as a percentage).
  • get contextGuardTurnCap() — The Agent tool loop's runaway guard: the PER-TURN context-growth ceiling, a (0,1] fraction of the model's context window — even starting near-empty, one agent turn alone cannot consume more than this before it's stopped.

IO-failure retries and endpoint token-depletion — settable

lib/env/reliability.js

lib/env/reliability.js — the IO RETRY policy (private to Env): how the Agent answers a failed provider request. Three settings, each accepting a millisecond numeral or a unit string for the durations (lib/env/duration.js), each with a default — no magic numbers at the call sites:

settings.maxAttempts request attempts per IO turn (default 3): the first write plus its retries settings.retryBase the FIRST retry's delay (default 2s) — each next attempt waits twice as long settings.retryMax the delay ceiling (default 30s)

Only failure classes that can plausibly succeed on a later attempt retry (RETRYABLE_KINDS): transport failures and provider-side statuses — a depleted token budget surfaces as one of those and its refill IS time. A malformed context never heals by waiting; a cancellation is the user's own word.

  • DEFAULT_MAX_ATTEMPTS = 3
  • DEFAULT_RETRY_BASE = 2_000
  • DEFAULT_RETRY_MAX = 30_000
  • RETRYABLE_KINDS = Object.freeze(["network", "provider", "auth"]) — The classified error kinds an Agent retries after a growing interval (lib/agent/run.js).
  • configuredMaxAttempts(settings) — The configured attempt count (>= 1 — one write always happens).
  • retryDelay(settings, attempt) — One attempt's delay: retryBase doubling per retry (attempt 0 is the first RETRY — the write before it already happened), bounded by retryMax, spread by up to a quarter of the base so simultaneous retries do not land in lockstep.
  • awaitTimeout(ms, wait) — awaitTimeout — race a completion event against a deadline (the Promise.race pattern), resolving true for completion and false for the deadline.

lib/env/provider.js

  • depletionError(classified) — The shared TOKEN-DEPLETION predicate: exact signals only.

lib/env.js

  • get maxAttempts() — Provider-request attempts per IO turn (settings.maxAttempts, default 3): the first write plus its retries — only failure classes that can heal with time retry (Env.RETRYABLE_KINDS); the interval grows from retryBase, doubling per attempt, capped at retryMax (lib/env/reliability.js).
  • retryDelay(attempt) — One retry's delay (lib/env/reliability.js): retryBase doubling per attempt, capped at retryMax, jittered against lockstep.

TUI input, cursor, mouse-selection, and questionnaire editing

lib/tui-app/input-controller.js

lib/tui-app/input-controller.js — the draft input box's MODEL-OWNED state: value/caret/selection (mirrored from GTUI's input control — see lib/gtui/controls.js's controlled-input contract: the control computes edits from the LAST rendered value/caret/selection, so the app must hold and re-supply them) plus completions, which are pure tui-app policy (lib/tui-app/completion.js's computeCompletions) that GTUI's input node only ever displays.

Tab/Shift+Tab/Down/Up/Enter double as completion-list navigation ONLY while completions are showing — the app declares them via GTUI's bindings(model) so those keys bypass the input control entirely (matching the capability matrix: "Enter; Shift+Enter; Tab/Down — submit/soft break/completion — tui-app policy").

lib/gtui/controls.js

  • createControls(emit, options = {…}) — Host-private controlled input/menu/scroll/overlay mechanics.

lib/gtui/terminal-input.js

  • createTerminalInput(input, emit, { beginBurst = () => {}, endBurst = () => {} } = {…}) — Decode raw terminal bytes into GTUI semantic key/paste/pointer events.

lib/tui-app/questionnaire-view.js

lib/tui-app/questionnaire-view.js — the open question (agent-adapter.js's question bridge + questionnaire.js's pure state) into GTUI nodes: the options render through the SAME controlled menu used everywhere else (arrow/enter/escape are native), plus a real input for a custom typed answer — parity with test/cli-question.test.js's "the custom-answer line is a real text input".

Enter and Space both select/toggle the focused row. On a single-select answer Enter also moves the highlight to the Submit row for confirmation (the input's Enter does the same jump). Only selecting the final submit row commits the answer. The question text, option labels, and option DESCRIPTIONS all SOFT-WRAP (labels through the menu's own word-wrap; descriptions as their own wrapped text nodes), so nothing clips at the overlay edge. A description/preview belongs to the option: it shows ONLY while that option is the HIGHLIGHTED menu row (tui-app tracks menu.change into question.highlight), so extra information never floods the overlay. A code preview renders through the shared markdown pipeline (fenced block) — the same highlighting the transcript uses. Crossing the input's top row with ↑ (or its bottom with ↓) returns focus to the menu (tui-app's key policy on GTUI's bubbled boundary move). GTUI still owns only generic menu navigation/selection and never sees questionnaire state.

Endpoint/auth entity

lib/env.js

  • endpointSettings(endpoint) — Live endpoint configuration plus endpoint-keyed auth/model cache (lib/env/endpoints.js).
  • authSet(endpoint, data, { scope } = {…}) — Persist endpoint-keyed auth/model data: creates/updates `auth-<endpoint>.json` holding `{ [endpoint]: data }` (tokens + cached model list) in the endpoint's scope — the project folder ("local") or the effective user-settings folder ("package", the default; the package folder itself is used only when settingsDir is null).
  • saveEndpoint(name, endpoint, { scope = "package" } = {…}) — Add/update one configured endpoint and persist it to the scope's settings file (the user settings folder or namespaced project file).

settings.json

{
  "env-refuse": [
    "OPENAI_API_KEY",
    "AZURE_OPENAI_API_KEY",
    "ANTHROPIC_API_KEY",
    "XAI_API_KEY",
    "GEMINI_API_KEY",
    "GOOGLE_API_KEY",
    "MOONSHOT_API_KEY",
    "KIMI_API_KEY",
    "DEEPSEEK_API_KEY",
    "MISTRAL_API_KEY",
    "GROQ_API_KEY",
    "OPENROUTER_API_KEY",
    "TOGETHER_API_KEY",
    "FIREWORKS_API_KEY",
    "COHERE_API_KEY",
    "PERPLEXITY_API_KEY",
    "NVIDIA_API_KEY",
    "CEREBRAS_API_KEY",
    "SAMBANOVA_API_KEY",
    "AI21_API_KEY",
    "VOYAGE_API_KEY",
    "DASHSCOPE_API_KEY",
    "HUGGINGFACE_API_KEY",
    "HF_TOKEN",
    "REPLICATE_API_TOKEN"
  ],
  "providers": {
    "test": {
      "provider": "test",
      "url": "test://script",
      "secret": true
    }
  }
}

Settings schema — the merged settings tree

lib/env/settings.js

lib/env/settings.js — settings scan-and-merge helpers (private to Env).

Every top-level JSON file of the package folder merges into one settings tree: objects deep-merge, arrays concatenate, scalar collisions follow incidental read order (file ordering is NOT a precedence mechanism). A layer may additionally scan its themes/ subfolder (scanThemeFiles): theme files are JSON settings like any other, but a DEDICATED subfolder keeps palettes out of the top-level scan — and the scan is restricted to exactly that one name, so a folder's tools/sessions/scratch subfolders never leak into settings.

README.md

keymeaning
`providers`Endpoint URLs, protocol names, model metadata, endpoint limits
`tools`Additional trusted tool roots (package or user settings only)
`skills` / `prompts`Additional instruction and prompt roots
`mcp`MCP servers and launch settings
`tui`Interface mode, theme, theme definitions
`think`Default reasoning effort
`safe`Start read-only
`timeout` / `toolTimeout`Provider and tool execution limits