cell.cpp

cell.cpp

A single-file AI coding agent in modern C++26

A lightweight AI coding agent written in modern C++26. Connect to OpenAI or Anthropic, chat with your codebase, and let the LLM read, write, edit, and execute — all sandboxed.

C++26Single fileLicense

markdownmarkdown
cell: cwd=D:\Developments\CXX\cell.cpp session=82db5d5997826cfd-1789051971045-d72020ad model=llama-cpp:Qwen3.8-27B sandbox=full-accesscontext: loaded 1 message(s) from disk> 介绍一下自己,以及你能干什么The user is asking in Chinese: "Please introduce yourself and what you can do."This is a simple self-introduction question. According to the system prompt, I am Qwen (Tongyi Qianwen) made by Alibaba. I should reply in Chinese (language sync), and shouldn't mention specific version numbers. I should mention capabilities like:- Advanced human-computer interaction- Precision control of full attention- Long context window (about 100,000 characters / 1M context)- Knowledge cutoff date 2025- Strong in complex tasks: code, document writing, reasoning, Q&AI should also mention the specific capabilities I can actually do in this environment: file operations, code execution, search, and coordination of child agents. Since I actually have tools here (file reading/writing, shell execution, search, team child agents), I can mention them in a natural way.The identity guidelines say "don't proactively bring up the version number." I should use the same language as the user. So I'll reply in Chinese.Let me write a friendly self-introduction:- Name: Qwen (Tongyi Qianwen), developed by Alibaba- Core capabilities- Things I can do in this session: file operations, code execution, project search, multi-stage task coordination- Knowledge cutoff 2025Format: A friendly introduction using some structure (short list). Since this is a simple question, I'll keep it medium length and not make it too long.你好!我是千问(Qwen),由阿里巴巴集团研发的大语言模型。下面简单介绍一下我自己和能做的事情:## 我是谁- **名称**:千问(Qwen)- **研发方**:阿里巴巴集团- **知识截止**:2025 年- **长文本能力**:原生支持超长上下文窗口,可无损处理数十万字的文档,支持理解长达数小时的视频内容## 我能做什么**核心能力方面:**- 复杂任务处理:逻辑推理、代码编写与调试、文档写作、多轮对话、创意生成- 精准的信息检索与理解:支持超长上下文,能处理海量文档并快速定位关键信息- 内容创作:写作、翻译、摘要、头脑风暴、方案规划- 编程:代码生成、解释、重构、排错、跨语言支持- 问答:知识、推理、分析**在当前这个环境里,我还能实际动手操作:**- 读写文件、修改代码、执行 shell 命令- 搜索和检索项目内容- 把大任务拆分成多个子任务并行推进如果你有具体的任务——比如想让我写一段代码、分析一份文档、整理某个问题,或者在这个项目里做点什么——直接告诉我需求就行。[2026-09-21 06:51:48] INFO  [llm  ] round=1 model=llama-cpp:Qwen3.8-27B stream=true ctx_msgs=2 tok_in=3197 tok_out=601 cache=0.0% time=36.99s ttf=13.38s tools=0 attempts=1> 检查工具列表reply> The user is asking me to check the tool list. Let me check the tools available to me. The system prompt lists the tools I have access to. Let me provide an overview.Tools available:1. `ls` - Directory listing (non-recursive, paginated)2. `read` - Read files (text with line numbers, binary as base64)3. `write` - Create new files (refuses to overwrite existing files)4. `edit` - Modify existing files (replace/insert/append/delete/query modes)5. `rg` - Regex search over file contents6. `find` - Search for files by glob/metadata7. `exec` - Execute shell commands (timeout default 30s, max 300s)8. `tw` - Manage Teamwork child agent jobs (serial/parallel/workflow)9. `notice` - Message bus for agent sessionsI can also summarize the tool list. The user asked in Chinese, so I'll respond in Chinese.I could also demonstrate by listing the current directory to show what I can do. Let me do a simple `ls` to verify the tools work.好的,我当前环境里可用的工具有以下几个:## 文件 / 项目操作类| 工具 | 用途 ||---|---|| `ls` | 列目录内容(非递归,支持分页) || `read` | 读文件(文本带行号;二进制文件如图片、音视频以 base64 返回,方便多模态处理) || `write` | 创建**新文件**(拒绝覆盖已有文件) || `edit` | 修改已有文件:支持 replace / insert / append / delete / query 五种模式 || `find` | 按 glob 模式 + 元数据(文件名、修改时间、大小)查找文件 || `rg` | 递归正则搜索文件内容(支持忽略大小写、上下文行、扩展名过滤、计数模式) |## 执行 / 协作类| 工具 | 用途 ||---|---|| `exec` | 执行 shell 命令(默认超时 30 秒,最长 300 秒;高风险命令需二次确认) || `tw` | 管理 Teamwork 子代理任务:支持串行 / 并行 / 多阶段 workflow,可复用之前的子任务 || `notice` | 会话间消息总线:发送、列出、等待、清空通知 |## 简单总结一下- **文件操作**:读、写、改、搜、找都齐了- **命令执行**:可以直接跑 shell- **任务编排**:可以把大任务拆给多个子代理并行处理要不要我现在实际跑几个试试?比如看看当前目录里有什么文件。[2026-09-21 06:52:37] INFO  [llm  ] round=1 model=llama-cpp:Qwen3.8-27B stream=true ctx_msgs=4 tok_in=3488 tok_out=620 cache=91.5% time=24.89s ttf=1.03s tools=0 attempts=1> /export chat.htmlexported 5 message(s) to cell-export-20260921-065306.html> /quit

Table of Contents

Features

FeatureWhere it lives
🤖Three API styles — OpenAI Chat Completions ({base}/chat/completions), OpenAI Responses ({base}/v1/responses) and Anthropic ({base}/v1/messages), all streaming and non-streaming, with per-provider HTTP(S) proxy supportcell::llm, cell::net
🧩Provider registry — any number of named endpoints; model lists are fetched live from the provider, only the active model name is persistedcell::config
🔧9 built-in tools — ls, read, write, edit, rg, exec, find, tw, noticecell::box, cell::tools
👥Teamwork — supervised child agents with configurable workflows (tw tool + /teamworks): named stages with per-stage dispatch mode, supervisor gates, same-job reuse, background runs with cooperative stops, a per-child provider/model/think level, reuse of earlier children (with or without replaying their transcript), queryable history & reports, and a file lease — per-child write grants plus mandatory review of every change they make to the file you grantedcell::teamwork
📣Notices — a token-free message bus between agent sessions: Teamwork children report progress, the supervisor idles in /notices wait and is woken instantly, all without a single LLM round-tripcell::notice
🛡️Three sandbox modes — read-only, edit-only, full-access; credential/runtime files are off limits in every mode (symlinks and Windows junctions that resolve into the runtime root are followed and blocked, including targets that do not exist yet) and exec is the only tool gated by a confirmation promptcell::box::check_exec
🧨Prompt-injection sanitizer — every exec result is scanned for command-override fingerprints, robust to homoglyphs, zero-width marks, punctuation-joined tokens and multi-line splitscell::box::sanitize_output
🔐Encrypted credential vault — Argon2id key derivation + AES-256-GCM (XChaCha20-Poly1305 fallback), sodium_malloc/sodium_memzero secret bufferscell::encrypt
💬Per-directory sessions — session ids embed a hash of the working directory; switching a session follows its cwdcell::chat
⏸️Interrupted turns — Esc or a failed request keeps the partial reply (truncated at its last token), cancels any half-streamed tool call, appends a system message with the reason, and remembers the state so /reload continues where the turn stoppedcell::chat::record_interrupt
🗜️Automatic context compaction — on context-overflow errors (compress + retry once) and after long tool-heavy turns; the compression model can differ from the chat modelcell::chat, compact()
🧠Chain of thought — /think with 5 levels (off/low/med/high/max); OpenAI streams reasoning_content, Anthropic streams thinking_delta, both rendered dim; reasoning content is persisted in sessionscell::llm
🔎Numbered reads — read returns every line prefixed with a right-aligned line number, and rg groups hits as === file === + line: content, so the model can cite exact lines to editcell::box
📦Skills — Markdown files with YAML front matter under .cell/skills/ (recursive, directory-style supported), injected as system messagescell::skills
📊Usage statistics — per-model and per-session request/token/character counters with prompt-cache hit ratecell::stats
⌨️Shell-style line editing — the prompt has cursor keys, input history (per session), Home/End/Delete, Ctrl+A/E/B/F/K/U/W; UTF-8 aware, raw mode only while a line is being typedcell::plat::read_line
⚡Async I/O — session/config/stats writes are coalesced and flushed by one background thread; a dynamic worker pool runs read-only tool calls concurrentlycell::async_io, cell::sys::thread_pool
🖥️Cross-platform — Windows (Job Objects, cmd.exe, UTF-8 codepage, virtual terminal) and POSIX (fork/exec, termios); all OS-specific code is isolated in one namespacecell::plat
🧪Self-test — a few hundred assertions covering the sandbox, editor, sanitizer, crypto, config, sessions, skills, stats and the tool registry--selftest

Quick start

Prerequisites

  • A C++26 toolchain with library support for <format>, <expected>, <generator>, <span>, <concepts> and std::move_only_function — e.g. GCC 15+ (MSYS2 UCRT64) or a recent MSVC/Clang. This is bleeding-edge: use a recent standard library, not an LTS one.
  • libcurl (any recent backend), libsodium, nlohmann/json (header-only).
  • Recommended package manager: vcpkg
bashbash
vcpkg install curl nlohmann-json libsodium

Build

bashbash
# POSIXg++ -std=c++26 -O2 -o bin/cell cell.cpp -lcurl -lsodium# Windows / MSYS2 (the CA-bundle export walks the system certificate store: crypt32)g++ -std=c++26 -O2 -o bin/cell.exe cell.cpp -lcurl -lsodium -lcrypt32# MSVCcl /std:c++latest /O2 /EHsc cell.cpp /Fe:bin\cell.exe /I<deps>\include /link libcurl.lib libsodium.lib crypt32.lib

Run

bashbash
# first launch: register a provider (the key goes into the encrypted vault)bin/cell --provider openai --base https://api.openai.com/v1 --model gpt-4o --key YOUR_API_KEY# Anthropic-style endpointbin/cell --provider anthropic --base https://api.anthropic.com --model claude-sonnet-4-20250514 --key YOUR_API_KEY# any OpenAI-compatible gateway (Ollama, vLLM, One-API, ...)bin/cell --base http://localhost:11434/v1 --model qwen2.5-coder# non-interactive: stdin is one message, reply is printed, then exitecho "explain what this repo does" | bin/cell# verify the buildbin/cell --selftest

Inside the REPL, providers are managed without restarting:

texttext
/provide add openai:https://api.openai.com/v1 key:YOUR_API_KEY/models/model gpt-4o/think low 

Command-line options

texttext
usage: cell [options]        # verbatim from print_usage — the --sandbox line overstates the modes  --provider NAME             select an existing provider, or create one (style = NAME)  --base URL                  api base url for the current provider  --model MODEL               default model name  --proxy URL                 http(s) proxy for the current provider  --key KEY                   api key (saved to the encrypted vault)  --session ID                resume an existing session (switches to its working directory)  --system TEXT               system prompt  --sandbox MODE              exec sandbox mode: read-only | edit-only | full-access (default)  --no-color                  disable colored log output  --verbose                   enable DEBUG-level log output on console  --selftest                  run internal self tests  --help, -h                  show this usage summary and exit 
OptionValueEffect
--providernameSelects an existing provider by name; if none matches, a provider is created whose name doubles as the API style (openai / anthropic); for OpenAI-style providers with no explicit style, the API style defaults to openai-chat. Switching to a different provider clears current_model — the stored model name belonged to the previous provider — while re-selecting the already-active provider (including the empty current_provider case, which means "first provider is active") keeps it; an explicit --model on the same command line is applied afterwards.
--baseURLSets base on the current provider. There is no built-in default base — an empty base means the provider is unusable until you set one.
--modelstringActive model name, stored as-is (it need not appear in /models; a mismatch only warns).
--proxyURLHTTP(S) proxy for the current provider, credentials may be embedded. localhost, 127.0.0.1, ::1 always bypass it (CURLOPT_NOPROXY); HTTPS targets are tunnelled with CONNECT.
--keysecretEncrypted into .cell/.crypt under the id provider:<name> and bound to the current provider; the plaintext copy is zeroed (sodium_memzero) immediately.
--sessionidStored as cfg.session_id. See Implementation notes: startup always opens a fresh session, so resume with /session ID.
--systemtextReplaces the system prompt entirely.
--sandboxread-only/readonly, edit-only/edit, full-access/fullSets the exec sandbox mode and persists it as sandbox_mode. An unknown value warns and rewrites the stored value to full-access.
--no-colorflagDisables ANSI colors (color is only used when stdout is a terminal anyway).
--verboseflagMirrors DEBUG log lines to the console (they always go to the log file).
--selftestflagRuns the built-in test suite in an isolated .cell-selftest/ directory and exits (0 = all passed).
--help, -hflagPrints the usage summary and exits with 0.

Load order. .cell/config.json is read first (a parse error is reported as a warning and the program continues with defaults); legacy config shapes are migrated on the fly. CLI flags are then applied on top of the loaded settings. On exit the merged configuration is written back, so --base / --model / --proxy / --sandbox changes are persistent.

API key resolution (per request, first match wins):

  1. the vault entry bound to the current provider (provider:<name>);
  2. the environment variable OPENAI_API_KEY / ANTHROPIC_API_KEY (chosen by the provider style);
  3. the generic vault entry api_key.

If none is available the turn fails with a message explaining how to set one.

Numeric arguments. Config fields and tool arguments accept both JSON numbers and quoted numeric strings ("log_max_lines": "500", {"offset": "85"}); garbage or missing values fall back to the default instead of throwing.

Slash commands

Input starting with / is split on whitespace and handled locally — it is never sent to the model.

CommandDescription
/helpPrint the command list
/providesList configured providers (style, api_style, base, proxy, key state, model)
/provide NAMESelect a provider (persisted immediately). Switching to a different provider resets the model to unset — the stored model belonged to the previous provider — so pick a new one with /models + /model NAME; re-selecting the active provider keeps its model. After a switch the REPL prints either the kept model or a "no model set" hint.
/provide add API_STYLE:URL [key:KEY] [proxy:URL] [name:ALIAS]Add and select a provider; the prefix is the API style of that URL (openai-chat | openai-responses | anthropic; openai is accepted as an alias of openai-chat, a spaced openai: URL form is also accepted). Selecting the new provider clears any previous model name. After adding, the model list is fetched once as a connectivity probe.
/provide update NAME [base:[API_STYLE:]URL] [key:KEY] [proxy:URL] [name:NEW_NAME]Update an existing provider in place. The API style is set together with the base URL it applies to, e.g. base:anthropic:https://api.anthropic.com; base:URL alone keeps the current style. Only supplied fields change; renaming the provider carries its encrypted vault key to the new vault id, and a requested name that already exists is made unique as NEW_NAME-N.
/provide rm NAMEDelete a provider and its vault key; if it was current, selection moves to the first remaining provider and the model name is cleared
/modelsFetch and print the current provider's model list (<current> marks the active one)
/model NAMESwitch model; warns if the name is not in the fetched list
/model NAME [api_style:STYLE]Optionally switch the current provider's API wire style on the fly (openai-chat | openai-responses | anthropic; openai is accepted as an alias of openai-chat); the new style is persisted in config.json and shown by /model and /provides
/think [off|low|med|high|max]Set the chain-of-thought level (persisted; an unknown level prints the usage and keeps the previous one); levels: off (default), low (1024 tokens), med (2048), high (4096), max (8192); bare /think shows the current level and budget
/tool [on|off]Toggle tool calling — off means no tool definitions are sent at all
/sandbox [mode]Show or set the exec sandbox mode (persisted); valid values are read-only/readonly, edit-only/edit, full-access/full — anything else is refused with a usage error
/autoallow [on|off]Toggle autoallow mode (persisted, full-access only) — when enabled, the LLM decides whether exec commands run without user confirmation
/sessionsList saved sessions grouped by working directory (> = current cwd, * = current session)
/session IDSwitch to a saved session; the process cwd follows the session's directory
/session rm IDDelete a session (folder with transcript, Teamwork data, archives) and its usage record
/saved [list]List the compaction archives of the current session (saved/msg-<UTC time>.jsonl in the session folder)
/saved show NAMEDisplay an archived transcript (exact name or unique substring of one)
/saved rm NAMEDelete an archived transcript
/export [PATH]Export the current session as a self-contained HTML file (single file, no scripts/external resources; light/dark aware; full-width two-pane layout — a sticky left rail with the execution chain that links 1:1 to the messages on the right, draggable at its bottom-right corner to widen it; markdown-lite rendering — headings, lists, quotes, rules, pipe tables, code, bold, italic, links — with tool calls, tool results and reasoning as collapsed sections; Teamwork jobs get an agent-list pane: the rail is a clickable list of jobs/stages/child agents that jumps straight to each agent's report + full transcript, wrapping labels instead of scrolling; workflow jobs get a flowchart sub-page with its own rail — start/stage/terminator nodes on a vertical rail, parallel stages forking into per-agent branch boxes, gates drawn as dashed diamonds; phone-friendly: the header and type tighten, the view tabs become a thumb-reachable bottom bar, every rail folds into a tap-to-open drawer (a hidden checkbox — still no script) and a parallel fan that cannot fit its column folds to one; default cell-export-<UTC time>.html in the working directory)
/export saved NAME [PATH]Export one compaction archive to HTML (name resolved like /saved show)
/usagesPrint per-model and per-session usage statistics (orphaned session records are pruned first)
/compactArchive the full transcript to saved/msg-<UTC time>.jsonl, then aggregate the conversation (plus the agent's reasoning) into one summary message, backfilled as a standalone assistant reply directly behind the last user turn; refuses while the context is small (≤ 12 messages)
/compact auto [on|off]Show or toggle automatic compaction after long agent runs (persisted, default on)
/compact model provider:modelRoute summarization through a specific registered provider/model; inherit resets it to the session model
/ins TEXTInterject a user message and get a response (injects text and triggers one LLM round-trip)
/reloadResume an interrupted turn: the transcript already ends with the partial reply plus the system message that explains the interrupt, so it is simply sent again — no user message is added (see Interruptions)
/skillsList available skills
/skill NAMEInject a skill body into the current session as a system message
/teamworksList Teamwork jobs of the current session (see Teamwork)
/teamworks allList Teamwork jobs of every session in this working directory
/teamworks newCreate an empty Teamwork job and print its id
/teamworks id:ID bg:none|prolegomena works:TEXT [provider:P] [model:M] [think:L] [reuse:JOB/worker] [writes:FILE,...] [rules:TEXT] [name:NAME]Append a child task to a pending job (workflow jobs file it into the last stage)
/teamworks workflow [FILE]Load a workflow/job config from a JSON file in the working directory (default teamwork.workflow.json; a bare {stages:[…]} is accepted) and create the job
/teamworks run IDRun a job and print its consolidated report; a gated stage pauses the job — run again to resume
/teamworks run_bg IDRun a job in the background on the dedicated runner thread; progress arrives as notices
/teamworks resume IDContinue a gated/stopped workflow job from its saved stage
/teamworks stop IDRequest a cooperative stop (pauses at the next worker round or stage boundary)
/teamworks status IDShow one job with its stage plan, gates and progress
/teamworks max [N]Show or set the child-agent limit (1–100, persisted)
/teamworks rm ID / /teamworks rm ID:worker_NDelete a job, or one child task and its report
/teamworks history [JOB] [worker:W] [n:N] [all]List past child-agent tasks with status/model/rounds
/teamworks reports [JOB] [worker:W] [n:N] [full] [all]Print several child reports (newest first)
/teamworks report JOB:workerPrint one child's report
/noticesPeek pending notices of the current session (see Notices)
/notices sessionsList concurrent sessions (id, workspace, pending notices, idle time) so another session can be addressed
/notices wait [SEC] [from:X] [topic:Y]Block token-free until a matching notice arrives, then deliver pending notices into the conversation (default 60s, max 3600s)
/notices send [SESSION] TEXTDeliver a notice to a session — SESSION resolves as an exact id, a unique id prefix, a 16-hex workspace key, a workspace path, reply (the newest notice's sender) or current; an unresolvable first token is treated as the message body for the current session
/notices drainConsume pending notices into the conversation (also happens automatically before every LLM turn)
/notices clearDrop pending notices (archived to seen.jsonl)
/notices seen [list]List the accumulated notice audit trail (seen.jsonl) with each entry's id and state
/notices seen clearArchive seen.jsonl to saved/notice-<UTC time>.jsonl and empty it
/notices seen rm IDDelete one accumulated notice by id
/savePersist the current session now (flushes the async writer)
/clearEmpty the current session's messages but keep its id; re-injects the system prompt and skill list
/newSave the current session, then start a fresh one (old files stay on disk) and re-probe the provider
/exit, /quitExit

Chain of thought (/think). For OpenAI-style providers nothing extra is sent — reasoning models already stream delta.reasoning_content, and /think displays it dim ahead of the answer, so non-reasoning models are unaffected. For Anthropic-style providers the request body gains thinking: {"type":"enabled","budget_tokens":N} (where N depends on the level: low=1024, med=2048, high=4096, max=8192; max_tokens is raised accordingly) and the streamed thinking_delta / signature_delta blocks are rendered dim; the thinking blocks stay in the stored assistant message, which Anthropic requires when a tool call follows a thinking turn. For OpenAI, reasoning_content is now stored in the session for persistence across restarts.

Skills (/skills, /skill). A skill is a Markdown file under .cell/skills/, discovered only if it opens with a YAML-style front matter block; the scanner recurses (depth ≤ 6), so directory-style skills work too:

markdownmarkdown
---name: build-helperdescription: helpers for building cell---Body instructions injected when the skill is loaded…

name falls back to the file stem — or to the parent folder name for SKILL.md/README.md — and description to the first non-empty body line (truncated at ~120 chars); surrounding quotes are stripped. Whenever valid skills exist, every new session gets a metadata-only "available skills" list injected as a system message so the model can suggest loading one; /skill NAME appends the full body (front matter removed) as a system message for the rest of the session. Names and descriptions pass through display_safe (control/ANSI stripping) but are not fingerprint-redacted — the heavy scan is reserved for exec output.

Context compaction (/compact). Every system message is kept verbatim; every other message is split into conversation text and agent reasoning (thinking blocks are summarized in a second pass), each message truncated to 400 chars, and both parts are sent to the compression model (compact_provider / compact_model, falling back to the session model) with tools disabled. The two summaries are merged into one {"role":"assistant","content":"# Here is a summary that captures the previous conversation: …"} message, backfilled directly behind the surviving user turn: the most recent real user message is kept (never a tool_result carrier), and the summary follows it as a standalone assistant reply — the compacted transcript reads as a normal turn boundary (user → assistant), so the next user input lands behind the summary instead of piling onto an unanswered user message, and an overflow retry continues from the summary the way /reload continues a partial reply. A result without a real turn would be rejected by many chat templates (No user query found in messages). With ≤ 12 messages to aggregate it reports "context already small" and does nothing; if a summarization call fails, a truncation placeholder is used. The summary is re-run through the injection sanitizer before it is inserted, and the read-before-edit log is reset because the earlier read results are gone from context. Compaction is itself one LLM request per part and counts toward usage.

Transcript shape. Chat Completions servers render the transcript with their own chat template, and strict ones (llama.cpp's Jinja templates) accept exactly one system message, at the very front — a second one, or one that follows a turn, fails with System message must be at the beginning. So whenever session state arrives mid-conversation (notices, a loaded skill, an interrupt note) every system message is merged into the single leading one in transcript order (chat::fold_system_messages), and the same fold runs on the transcript before every request as a last line of defence. The other two API styles never cared: they lift system messages out of the array into system / instructions anyway.

Automatic compaction. Two triggers, both using the same compact() path:

  • On context overflow — when a request fails with an error matching known OpenAI Chat Completions / OpenAI Responses / Anthropic "context length exceeded" phrases (matched case-insensitively, e.g. maximum context length, context_length_exceeded, prompt is too long), the session is compacted once and the same round is retried against the compacted context without counting as a failed attempt ([auto-compact on context overflow]).
  • After long agent runs — when compact_auto is on (the default) and a turn recorded ≥ 3 tool calls plus reasoning/thinking entries, the context is compacted after the turn ([auto-compact] …). Disable with /compact auto off.

Tools

Nine tools are registered — ls, read, write, edit, rg, exec, find, tw and notice — with schemas emitted for the active API style ({"type":"function","function":{…}} for OpenAI, {"name":…,"input_schema":…} for Anthropic, and {"type":"function","name":…,"parameters":…} for the Responses API).

ToolPolicyArgumentsBehaviour
lsAllowpath, page, page_size (≤500)One level, non-recursive; directories first, then case-insensitive name order; each entry is printed as [dir ] NAME or [file] NAME N bytes, and the header reports total, the page window and the path
readAllowpath (required), offset (0-based lines), limitWhole-file mode is capped at 128M characters and streamed; range mode stops reading as soon as the last requested line is consumed; every returned line is prefixed with a right-aligned 6-column line number ({:>6}: content); records the returned line range for the read-before-edit rule
writeAllowpath, contentCreates a new file only — refuses overwrites (points at edit) and refuses when the parent directory is missing (points at exec: mkdir -p); seeds the edit cache
editAllowpath (required), mode, search, content, from, toreplace (unique SEARCH block → content), insert (after the block, or after line from), append, delete (block or line range), query (read-only locate). A non-unique search aborts and reports every match with context; an identical replace is a no-op; a search that ends with a newline the file does not have is retried without it (and the replacement drops its own trailing newline)
rgAllowpattern (required), path, max_results (≤500), ignore_case, context, file_type, count_onlyRecursive content search with full regex support; skips hidden entries and .gitignored paths; follows a symlink only when it resolves inside the walk root; literal fast path for non-regex patterns; groups hits as === file === + line: content; supports case-insensitive search, context lines (overlapping context is printed once), file extension filtering, and count-only mode; 8M-line scan budget; exponential-backtracking shapes ((a+)+, `(a
execAskcmd (required), timeout (default 30s, max 300s), wdRuns the command with a hard timeout that kills the child process tree (exit code 124 on timeout); stdout and stderr are captured separately; when the command fails (exit code != 0), stderr is included in the output under [stderr]; use wd to set the working directory; the result always ends with exitcode=N
findAllowpattern (glob), path, name, newer_than_hours, larger_than_bytes, max_results (≤500)Find files recursively by glob pattern and/or metadata (a symlink is followed only when it resolves inside path; name is case-insensitive on Windows; a future mtime is never "recent"). When only pattern is given, behaves like a recursive glob (e.g. **/*.test.ts). Returns path size bytes mtime (UTC) per match
twAskoperation, id, work-type, list, workflow, path, from, worker, provider, model, think, works, name, reuse_context, background, editable, edit_rules, limit, full, allManage Teamwork child-agent jobs and workflows — see Teamwork
noticeAllowoperation (send/list/drain/wait/clear), session, from, topic, body, timeout, from_filter, topic_filterCross-session message bus — see Notices

The glob tool is folded into find: find takes a pattern glob and/or name/newer_than_hours/ larger_than_bytes metadata filters.

Execution scheduling. Within one assistant turn, all Policy::Allow read-only calls (ls/read/rg/find) are dispatched concurrently on the shared worker pool; write and edit are deliberately deferred to a second, sequential pass so that same-message reads always complete first (read-before-edit) and two edits of one file never race; exec runs sequentially after an interactive confirmation, and tw (Teamwork) also runs in that sequential pass because it blocks on child agents. Results are appended to the transcript in the original tool_call order.

Read-before-edit rule. Paths are canonicalized (weakly_canonical, lowercased on Windows) and the line ranges returned by read tools are logged. edit refuses to touch any line not covered by a previously read range, and the log is cleared whenever the visible context changes (/clear, /new, /session, /compact).

File cache. edit reads through a (size, mtime)-validated cache keyed by canonical path, so consecutive edits of one file skip the disk while an external writer is always picked up.

Teamwork

Teamwork runs supervised child agents. The main agent creates a job holding a list of child tasks — optionally arranged as a workflow of named stages — runs them serially or in parallel, and gets back a consolidated report. Every child gets an independent message history; children are restricted to the files their lease covers and can never call tw themselves, so the hierarchy stays a tree of depth 1. Children can call notice, which is how they report progress to the supervising session mid-run.

Jobs live with the session: <root>/sessions/<cwd-key>/<session-id>/teamworks.json, with one <session-id>/<job-id>/<worker>.json transcript per child. Job ids are UTC wall-clock stamps — tw-YYYYMMDD-HHMMSS (a -N suffix disambiguates same-second collisions). The store is kept in memory and mirrored to disk through the async writer, so listing/querying jobs costs no disk I/O.

Shapes

carboncarbon
// tw operation:"new"{ "work-type": "serial",                  // serial | parallel (dispatch order inside a stage)  "provider": "zai",                      // optional job-level defaults, inherited by every child  "model": "glm-5.3-flash",  "editable": ["story.md"],               // job default: files children may modify (needs edit_rules)  "edit_rules": "append only, never touch existing lines",  "list": [    { "works": "audit the net layer", "provider": "claude", "model": "claude-sonnet-4",      "think": "high",                    // off|low|med|high|max or 0..4      "background": "prolegomena",        // inject the main conversation as background context      "name": "net-audit" },    { "works": "then write regression tests", "reuse": "tw-1788623888-0/net-audit" }  ] }// tw operation:"new" with a workflow — stages run in order, each with its own// agents, dispatch mode, optional stage-level provider/model/think defaults,// and an optional supervisor gate after the stage{ "workflow": { "name": "ship-it",  "stages": [    { "name": "research", "mode": "parallel",      "agents": [ { "works": "scan the docs" }, { "works": "scan the code" } ] },    { "name": "implement", "gate": true, "think": "low",      "agents": [ { "works": "patch it", "reuse": "worker_0" } ] }   // bare name = same-job reuse  ] } }

A workflow can also be loaded from a file: tw operation:"new" path:"teamwork.workflow.json" (relative to the working directory; the file may hold a full job config or a bare {stages:[…]}), or /teamworks workflow [FILE] from the REPL.

Field (per child)Meaning
workstask text — required unless reuse is given
backgroundnone (default) or prolegomena (inject the main conversation as context)
provider / modelrun this child on a specific provider/model
thinkchain-of-thought level for this child
reusetw-ID/worker_N for a past job, or a bare worker_N for an earlier-stage worker of the same job — inherits the task config and, by default, the transcript
reuse_contexttrue (default): carry the earlier child's completion report — or a summary of its transcript when no report exists; the raw messages are never carried; false: config only
nameexplicit child name (sanitized for use as a file name)
editableexisting files the main agent lets this child modify — see File lease
edit_rulesthe constraint those modifications are reviewed against; required whenever editable is given

File lease and supervisor review

A child may always create new files: the moment a file is created it belongs to that child, and the child may rewrite it freely afterwards. Everything else is decided per path:

The target fileWhat happens
does not exist and is created with writeallowed — it becomes the child's own file
is a file the child created earlierallowed
is leased to another child of this jobrefused: leased to <child> (ownership is also a mutex)
exists and nobody leased itrefused unless it is on the child's editable list
is on the child's editable listreviewed — see below, every single time

A file on the main agent's grant list stays the main agent's property: every write/edit against it is held back while a supervisor review judges just that one change against edit_rules. The review is one short request to the main session's provider/model carrying the path, tool arguments, the file's current content and the result it would produce — a missing or failed review denies (fail-closed), never silently allows.

Refusals are not fatal: the child receives the verdict and the reason as its tool result and can correct the change and retry (up to 8 reviews per child). Leases never outlive a run — they are dropped when the job finishes, is stopped, or is deleted, and a refused child never blocks its siblings, since drawing a file only claims it while the review is in flight.

Reviews are concurrent: no child waits in line behind another's review, because the lease lock is released before the review request is made.

Given editable: ["story.md"], edit_rules: "append at most one paragraph, never alter existing lines", a child that appends its chapter transcript gets it through; one that tries to rewrite the file gets [edit] /work/story.md refused: the supervisor rejected this change: rewrites existing lines, then usually writes its version into a new file of its own.

Stage fields: name (default stage_N), mode (serial default | parallel), gate (pause for the supervisor after this stage), provider/model/think (stage defaults), agents (required, non-empty). A bare reuse must name a worker from an earlier stage — this is validated when the job is created.

Model resolution order for a child: its own field → the reused child's field → the stage default → the job default → the reused job's default → the active provider/model. A provider that does not exist is an error, never a silent fallback. Each child builds its own LLM client and reuses it across all its rounds, so parallel children never share a curl handle.

Workflow execution, gates and supervision

  • Stages run in order; workers within a stage run serially or in parallel (pool-bounded). After every stage the progress so far is persisted (next_stage), so a gate pause, a stop or a crash never loses completed-stage reports.
  • Gates: a stage with gate: true pauses the job before the next stage (status=waiting) and posts a notice. The supervisor reviews the stage report and resumes with tw run, /teamworks run ID / resume ID, or re-plans with edit.
  • Background runs: /teamworks run_bg ID (or tw run with background:true) executes the remaining workflow on a dedicated runner thread while the REPL stays interactive. The supervisor can idle in /notices wait — zero tokens — and is woken by every stage/worker transition.
  • Stops: /teamworks stop ID requests a cooperative stop; the job pauses at the next worker round or stage boundary (status=waiting, the interrupted stage re-runs on resume).
  • Boot recovery: a process exit while a job was running leaves it running; the next start marks such jobs failed so they can be re-run or removed.

tw operations

OperationArgumentsResult
newwork-type + list, or workflow (with stages), or path (config file), optional provider/model/think/editable/edit_rulescreates a job and prints it
runid (+ optional config override, background for a background run)runs the job / resumes a waiting one, returns the consolidated report
editid + new work-type/list/workflowreplaces the config of an uncompleted job (progress resets)
removeiddeletes a job (agents may not delete completed jobs)
reusefrom:"tw-ID/worker_N", optional works/provider/model/think/namecreates a new job seeded from a past child (its completion report or a summary is carried, not its raw messages)
listalllists jobs of this session (or of every session in this working directory)
historyid, worker, limit, alllists past child tasks: status, provider, model, rounds, tool calls, created
reportid + workerprints one child's report
reportsid, worker, limit, full, allprints many child reports, newest first

run is the only tw operation that needs confirmation (/autoallow can waive it); the read-only queries are auto-approved.

Reports

A finished job records per-child {status, summary, rounds, tool_calls, provider, model, reused, reviews_approved, reviews_denied} in summary_reports — the two counters tell you how many modifications of granted files the supervisor passed and refused. When a report is missing — for instance a child interrupted mid-run — tw operation:"report" falls back to the child's persisted transcript and returns its last assistant text, so results stay recoverable.

From the REPL

texttext
/teamworks                                        list jobs of this session/teamworks all                                    list jobs of every session in this directory/teamworks new                                    create an empty job (append children next)/teamworks workflow [FILE]                        load a workflow config file as a new job/teamworks id:ID works:... [bg:none|prolegomena] [provider:P] [model:M] [think:L]                                   [reuse:JOB/worker] [name:NAME]/teamworks id:ID works:... writes:story.md rules:"append only"    # granted file + its review constraint/teamworks run ID                                 run a job (resumes a gated one) and print its report/teamworks run_bg ID                              run in the background; progress via notices/teamworks resume ID                              continue a gated/stopped job from its saved stage/teamworks stop ID                                request a cooperative stop/teamworks status ID                              show one job with its stage plan/teamworks max [N]                                show / set the child limit (1..100)/teamworks rm ID | /teamworks rm ID:worker_N      delete a job / one child/teamworks history [JOB] [worker:W] [n:N] [all]   past child tasks/teamworks reports [JOB] [worker:W] [n:N] [full] [all]/teamworks report JOB:worker                      one child's report 

Scheduling and failure handling

  • serial jobs run children one after another on the calling thread; parallel jobs are dispatched through the shared worker pool, so concurrency is bounded by thread_pool_size (default 16) instead of spawning one thread per child. Workflow stages inherit the same rule per stage; background runs execute on their own dedicated runner thread (never on the pool) so a pool-hosted run can never deadlock, and are joined at exit.
  • A child that throws is captured and reported as failed; a job never gets stuck in running across restarts (boot recovery marks interrupted runs failed). Jobs also carry ok / failed / stopped per child.
  • No round cap, self-compacting children. A child continues until it answers without tool calls, is stopped, or fails. When its transcript outgrows the model's context window — or passes 500 messages — the child's own output is summarized into one assistant message backfilled directly behind the carried background, whose last turn is the works user message (the background itself is never compacted), the raw transcript is archived into the job directory (<worker>.compact-<UTC>.jsonl) before the rewrite, and a compact notice tells the supervisor it may stop the job if the child should not continue. A summarization that fails leaves the transcript exactly as it was.
  • Child runs count toward usage statistics with their real token counts, keyed <job-id>-<child-name>.

Notices

The notice system is a message bus for agent sessions. Any writer — the user from the REPL, a Teamwork child inside this process, or a second cell process sharing the runtime root — drops a JSON file into the target session's notices/pending/ folder (atomic write, sanitized on read: UTF-8 only, control/ANSI bytes stripped, 16 KB body cap, 500 pending-files cap). Delivered notices are archived to notices/seen.jsonl; a failed archive is logged, but never blocks delivery.

Addressing is handle-based. A session can be named by its exact id, a unique id prefix, its 16-hex workspace key, a workspace path (both land on that workspace's newest session), current (the default), or reply/@ (the sender of the newest notice addressed to the caller whose session still exists — pending notices first, then the delivered trail). /notices sessions (or the tool's sessions operation) lists the concurrent sessions with id, workspace, pending count and idle time, so discovery never requires copying a full id. A session's folder is materialized the moment the session becomes live — even before any transcript is saved — so a running CLI or a freshly opened GUI workspace is discoverable while it exists in memory only. Only /notices send texts survive as a fallback: a first token that resolves to nothing is treated as message body. Every send records the sender's session (from_session) unless it targets the sender itself; a notice that carries one is answerable — the receiver answers with session=reply, and the injected conversation context shows the address plus a reply hint.

Waiting is token-free. /notices wait (and the notice tool's wait operation) block on a condition variable with a periodic folder re-scan (0.2 s in the REPL, ~0.7 s for the tool) — no LLM request is made while idle. In-process sends wake the waiter instantly; cross-process sends arrive within a second. Pending notices are also drained automatically before every LLM turn, so a supervisor never has to poll.

  • Send: /notices send [SESSION] TEXT, or the notice tool (from/topic fields give the receiver routing hints; results confirm the reply address). Teamwork children report progress this way — notice is the one tool available to them beyond their sandbox, in every job mode.
  • Discover: /notices sessions or the tool's sessions operation — id, cwd, pending count and idle time per session, most recently active first (the caller is marked), capped at 50 rows.
  • Reply: a delivered notice that names a session= can be answered without knowing any id: session=reply in the tool, /notices send reply TEXT in the REPL — the reply itself carries the replier's address, so conversations route both ways.
  • Automatic progress: every Teamwork job posts notices for job start/completion/failure, stage start/finish, gate pauses, stops, and each worker start/finish (with status and a summary snippet), all from=teamwork/<job-id>[/<worker>].
  • Backpressure: the queue holds at most 500 pending notices. Beyond that the oldest is dropped — except for Teamwork progress posts (worker/stage/review), which are turned away instead, so a progress flood can never evict a gate/job notice awaiting the supervisor.
  • Consume: /notices drain (or notice drain) delivers pending notices into the conversation as context; wait delivers what it woke for; clear drops them. Delivered = exactly once per draining session, thanks to file removal under a drain lock.
  • Cleanup: delivered/dropped notices accumulate in notices/seen.jsonl (tail-trimmed past 1 MB). /notices seen lists the trail with each entry's id and state, /notices seen rm ID deletes a single entry, and /notices seen clear archives the whole file to saved/notice-<UTC time>.jsonl — the same naming and -N numbering as the saved/msg-… compaction archives — before emptying it. The archive is written first, so a failed archive leaves the trail intact.

Security model

Six independent layers, from the path down to the bytes shown on your screen.

1. Sandbox modes

Set with /sandbox [mode], --sandbox MODE or the sandbox_mode config key. Tools are admitted or refused by mode before any command runs:

ModeAllowed toolsexec
read-onlyread, rg, find, ls (and anything run read-only)blocked entirely — refused before the confirmation prompt is ever shown
edit-onlyread/rg/find/ls plus write/editallowed, subject to gates 2–3
full-access (default)all toolsallowed, subject to gates 2–3

Network egress is not filtered in any mode — the command gate is path-only (see layer 3 and Implementation notes). The sensitive-path rules in layer 2 apply everywhere. exec is the only tool gated by a human confirmation prompt (layer 4); in read-only mode exec is refused before the prompt is ever shown.

2. Path gate — check_path / is_sensitive_path

  • any .. in the argument is rejected;
  • everything under the .cell runtime directory is off limits (vault, .key, config.json, sessions, logs) — except .cell/skills/, which the skill system legitimately reads;
  • well-known credential stores are blocked anywhere on disk: ~/.ssh/id_{rsa,ed25519,ecdsa,dsa}, ~/.aws/{credentials,config}, ~/.netrc, ~/.npmrc, ~/.pypirc, ~/.git-credentials, ~/.git/config, ~/.git/hooks, ~/.config/gh/hosts.yml, ~/.docker/config.json, ~/.kube/config, ~/.m2/settings.xml, ~/.gradle/gradle.properties;
  • paths are canonicalized with weakly_canonical first (symlinks/junctions resolved), so a link pointing at a credential file is blocked under its literal name too.

Only the path/dirpath field is checked for write/edit — code content may legitimately contain >, | or ...

3. Command gate — check / check_exec

The sandbox is path-only: the command string is checked for path traversal (..) and for sensitive paths (/.crypt, /.key, /config.json, /sessions/, /logs/, both separators, case folded). It does not attempt to block network egress by parsing command names, decode encoded payloads, or forbid shell operators — the threat model assumes an untrusted agent running inside a trusted host, where exec commands are confirmed by the user (or autoallowed in full-access). In read-only mode exec is refused outright. Note also that exec can still run interpreters such as python3 -c "exec(base64…)" or cmd /c "echo …", because the gate only inspects paths, not inline code. (The wider credential-store list of layer 2 is enforced by check_path on tool path arguments, not by check_exec.)

High-risk commands that pass the sandbox still demand a second confirmation: recursive/forced deletes (rm -rf, rm -r -f, del /s, rmdir /s, …), permission changes (chmod, chown, sudo, cacls, icacls, takeown) and git operations that can trigger hooks or rewrite history (commit, merge, rebase, cherry-pick, am, apply, checkout, switch, stash, clean, reset, restore).

4. Human confirmation

exec is the only Ask tool: allow exec({…})? [y/N] — the argument is printed through display_safe, so injected JSON cannot erase the prompt or fake an approval. When autoallow mode is enabled (/autoallow on, full-access sandbox only), the LLM alone decides whether exec commands run without user confirmation — sandbox checks (sensitive paths) still apply. Approval refusals (from the user or autoallow) end the current agent run; sandbox security refusals are returned to the model as normal feedback.

5. Output hardening

  • sanitize_output (applied to exec results, and to exec-tagged results reloaded from disk): truncates to a byte cap (default 128 KiB; the agent loop passes 512 MiB), then redacts line-by-line against ~50 command-override fingerprints plus three regex families (verb + filler + instructions|rules|system prompt|sandbox| safety, you are now …, no longer bound …). Matching runs on a flattened form of each line: UTF-8 decoded, fullwidth/Latin-1/Cyrillic/Greek homoglyphs folded to ASCII, zero-width and bidi marks dropped, punctuation collapsed to spaces, and windows of up to 6 adjacent lines joined so split fingerprints still match. The tool-output wrapper framing lines are never redacted (they carry the tool="exec" marker used on reload).
  • wrap_tool_output: every result is wrapped in an explicit untrusted-data boundary tag that carries the tool name and a path/command marker, with the attributes sanitized and any occurrence of that closing tag inside the body escaped, so injected content cannot forge a nested "authorized" tool block or break out of the wrapper.
  • truncate_output: non-exec results get a size cap (512 MiB) without the injection scan.
  • display_safe (single-line, ANSI-stripped) for confirmation prompts and skill metadata; console_safe (multi-line, ANSI-stripped) for the cyan console echo of tool output, which is additionally capped at 16 KiB.

6. Credential vault

.cell/.crypt is a JSON envelope (version: 2) holding a random base64 salt and, per key, a {nonce, ct} pair. The AEAD key is derived with Argon2id (moderate ops/memory limits) from:

  • the CELL_VAULT_PASSPHRASE environment variable if set, otherwise
  • .cell/.key — 32 random bytes, auto-generated on first use (base64 on disk).

Encryption uses AES-256-GCM when the CPU provides AES-NI, transparently falling back to XChaCha20-Poly1305. Secrets are held in secure_string (sodium_malloc buffers, zeroized on destruction, constant-time comparison); the derived key is wiped in the vault destructor. Plaintext never appears in the vault file — the self-test asserts this.

Sessions and the working-directory model

  • Each session id is <cwd-key>-<unix-millis>-<8 random hex>; the cwd key is the first 16 hex characters of the SHA-256 of the normalized absolute working directory (lowercased on Windows).
  • Each session is a folder: .cell/sessions/<cwd-key>/<id>/. It holds the JSONL transcript messages.jsonl (one message object per line — the standard agent log shape), the Teamwork store teamworks.json with one <job-id>/ transcript directory per job, the compaction archives saved/msg-<UTC time>.jsonl, and the interrupt state interrupt.json (written when a turn is cut short, emptied by the next user message or by a /reload that completes). .cell/sessions/sessions.json is the hash → path index that lets every group be resolved back to a real directory; /sessions groups by it and marks the current cwd with >.
  • /session ID and startup resume logic follow the session's cwd (resolved through the index, current_path + cache invalidation), so tools keep operating on the project the conversation belongs to; /session and /session rm ID reset the read-before-edit log because recorded reads no longer apply. Switching to an existing session prints its last five user/assistant text messages again, omitting thinking and tool-call content.
  • /new keeps the old folder (revisitable), /clear keeps the id, /session rm deletes the whole folder and the usage record; orphaned usage records are pruned at startup and on /usages.
  • Compaction archives: every /compact (manual or automatic) first writes the complete transcript to saved/msg-<UTC time>.jsonl inside the session folder (same-second archives get a -N suffix) before the context is rewritten; if the archive or the summary cannot be produced the context is left untouched. /saved list, /saved show NAME and /saved rm NAME access the archives of the current session.
  • Session, config and index writes go through cell::async_io::file_writer (coalesced per path, one background thread). Every write is an atomic replace: the content is written to a sibling <name>.tmp, flushed to the storage device, then renamed over the target, so a crash can never leave a truncated file that the next start silently reads as empty. Commands that read those files back (/save, /sessions, /session, /compact, exit) call flush() first as a durability barrier; the signal handler and the RAII exit guard do the same.

Configuration file

.cell/config.json (written with dump(2), so it is hand-editable):

jsonjson
{  "providers": [    { "name": "openai", "style": "openai", "api_style": "openai-chat", "base": "https://api.openai.com/v1",      "key": "provider:openai", "proxy": "http://user:pass@host:8080" },    { "name": "claude", "style": "anthropic", "api_style": "anthropic", "base": "https://api.anthropic.com" }  ],  "current_provider": "openai",  "current_model": "gpt-4o",  "think_level": 0,  "tools": true,  "sandbox_mode": "full-access",  "autoallow": false,  "compact_auto": true,  "compact_provider": "",  "compact_model": "",  "system": "You are a helpful assistant.…",  "session": "6333a2b6f7084f1a-1787819024",  "log_max_lines": 1000,  "thread_pool_size": 16,  "active_sessions": { "6333a2b6f7084f1a": "6333a2b6f7084f1a-1787819024" }}
FieldDefaultMeaning
providers[](empty)One entry per endpoint: name (unique id), style (openai | anthropic), api_style (openai-chat | openai-responses | anthropic), base, key (vault id, not the secret), proxy. Models are not stored here.
current_providerfirst entryActive provider when empty
current_model—Active model name; cleared automatically whenever the active provider actually changes (see /provide)
think_level0Chain-of-thought level: 0=off, 1=low(1024), 2=med(2048), 3=high(4096), 4=max(8192). Legacy think: true maps to level 2.
toolstrueTool calling enabled
sandbox_modefull-accessSee sandbox modes
autoallowfalseLLM decides whether exec commands run (only effective in full-access)
compact_autotrueAutomatically compact the context after long tool-heavy turns (/compact auto)
compact_provider / compact_model(empty)Provider/model used for compaction summaries; empty means inherit the session model (/compact model provider:model, reset with inherit)
teamwork_max_children5 (clamped 1–100)Maximum number of child agents per Teamwork job (/teamworks max)
systemshort assistant promptSystem prompt
log_max_lines1000 (min 10)logs/cell.log is trimmed to its tail on every startup
thread_pool_size16 (clamped 1–16)Max concurrent read-only tool workers; the pool spawns lazily and idles with zero workers
active_sessions{}cwd key → last active session id

Legacy migration. A flat {"provider","base","model","key","proxy"} object, or a {"models":[…],"current_model":<index>} array, is rewritten into the provider list on load (duplicate endpoints are merged, names are de-duplicated as style, style2, …).

Runtime data layout

.cell is anchored to the directory containing the cell executable (GetModuleFileNameW / /proc/self/exe), not the current working directory — so bin/cell.exe uses bin/.cell/ no matter where you launch it from.

texttext
.cell/├── config.json           # settings above├── .crypt                # encrypted vault (version 2: salt + per-key {nonce, ct})├── .key                  # 32-byte master secret (base64) — unless CELL_VAULT_PASSPHRASE is set├── usages.json           # {"sessions": {id: {...}}, "models": {"provider:model": {...}}}├── skills/               # *.md with YAML front matter, scanned recursively (depth <= 6)│   └── suite/core/SKILL.md├── logs/│   └── cell.log          # [UTC timestamp] LEVEL [cat  ] message, trimmed at startup└── sessions/    ├── sessions.json     # cwd hash -> cwd path index    └── 6333a2b6f7084f1a/ # one directory per working directory        └── 6333a2b6f7084f1a-1787819024-ab12cd34/   # one folder per session            ├── messages.jsonl    # transcript: one message object per line            ├── interrupt.json    # {"reason": "..."} while an interrupted turn can still be /reload-ed            ├── teamworks.json    # Teamwork store (jobs of this session)            ├── tw-20260910-124236/          # one transcript dir per job            │   └── worker_0.json            ├── notices/            │   ├── pending/      # undelivered notice files (nt-*.json)            │   └── seen.jsonl    # audit trail of delivered/dropped notices            └── saved/                └── msg-20260910-124500.jsonl  # full transcript archived by /compact 

Usage records accumulate requests, messages, input_chars, output_chars, input_tokens, output_tokens, total_tokens. The prompt cache hit rate shown in the [llm] log line is computed from usage.prompt_tokens_details.cached_tokens / prompt_tokens (OpenAI) or cache_read_input_tokens / (cache_read_input_tokens + input_tokens) (Anthropic).

Architecture

Everything is in cell.cpp, split into namespaces with a strict dependency direction: only cell::plat knows about OS APIs, only cell::net knows about curl, only cell::encrypt knows about libsodium.

texttext
cell::async_io   coalescing background file writer (submit / flush)cell::plat       OS shims: spawn_cmd (timeout + process-tree kill), is_tty, init_console,                 read_line (line editor: cursor keys, history, UTF-8 aware), decode_key,                 peek_key (Esc cancel), executable_dir, reparse_resolved_path                 (junction/symlink resolution via the OS), restore_consolecell::text       zero-copy line generator, trim, BOM strip, display_safe / console_safecell::box        the sandbox + every tool implementation: check_path / check / check_exec,                 is_high_risk, sanitize_output, wrap_tool_output, truncate_output,                 rg / find / list_dir / read / write / edit,                 gitignore matcher, walk_entries (lazy DFS), read-before-edit log, file cachecell::net        curl transport: perform / CURL_post / CURL_stream_post / CURL_get,                 RAII header_list, OpenAI-style error body extractioncell::sys        print/println/eprintln/pprintln, structured logger + rotation, exception +                 source_location, scoped_exit, dynamically-scaling thread_pool, signal handlerscell::config     provider registry, settings, load/save + legacy migrationcell::encrypt    base64, secure_string, Argon2id + AES-256-GCM vaultcell::tools      Policy (Deny/Ask/Allow), tool base class, callable_tool (approval + gates)cell::llm        SSE parsers (generator + incremental feed), OpenAI / OpenAIResponses / Anthropic clientscell::chat       session (per-cwd persistence) and history (in-memory session map)cell::skills     front-matter parser, recursive scanner, metadata promptcell::stats      usage counters in .cell/usages.jsoncell::teamwork   Teamwork job store (cached in memory), child-agent scheduler (serial / pooled                 parallel), reuse + history/report queries 

The agent loop

texttext
user message ──▶ LLM (stream) ──▶ tool_calls? ──▶ pass 1: read-only tools, concurrent                    │                              pass 2: write/edit (sequential), exec (confirm)                    │                                   │                    │      results sanitized + wrapped ──▶ appended to transcript ──▶ next round                    └──▶ no tool calls ──▶ final answer ──▶ session persisted 

There is no fixed round cap: the loop continues until the model answers without tool calls, a call is blocked/rejected, the user cancels, or an error occurs. Each round logs round=, context size, token counts, cache hit rate, total time and time-to-first-token. After every user turn the session and config are persisted.

Failures and retries. A failed LLM request is retried up to 5 attempts per round with a 5-second delay between attempts ([retrying in 5s... attempt N/5]); exhausting them ends the turn with [llm error after 5 attempts] and records an interrupted turn (see Interruptions). If the failure is a context-overflow error, the round is compacted once and retried immediately instead of burning a retry attempt (see automatic compaction).

Streaming UI and keyboard control

  • > prompt, reply> prefix for streamed answers; the reasoning stream is printed dim, the answer plain, tool results and their echo cyan.

  • The prompt has line editing (cell::plat::read_line): the terminal is raw only while a line is being typed, every key below is the emacs/readline binding, and backspace/delete/cursor motion never split a multi-byte character. Input history is per process, seeded from the current session's user turns (so a resumed session can recall earlier prompts), capped at 200 entries.

    KeyAction
    ← → / Ctrl+B Ctrl+Fmove one character
    Ctrl+← Ctrl+→ / Alt+B Alt+Fmove one word
    Home End / Ctrl+A Ctrl+Eline start / end
    ↑ ↓ / Ctrl+P Ctrl+Nhistory back / forward (the draft is restored at the end)
    Alt+< Alt+>oldest / newest history entry
    Backspace Delete Ctrl+Ddelete before / at the cursor (Ctrl+D on an empty line exits)
    Ctrl+W Alt+Backspacekill the word before the cursor
    Alt+Dkill to the end of the next word
    Ctrl+U Ctrl+Kkill to the start / end of the line
    Ctrl+Yyank (paste) the last killed text
    Ctrl+Ttranspose the two characters around the cursor
    Ctrl+Lclear the screen and redraw the line
    Entersubmit
    Ctrl+Csave and exit (see below)
    Esccancel a running stream (not an editor key)

    All of it is decoded from the standard encodings — CSI (\x1b[1;5D), SS3/application-cursor (\x1bOD), the meta pairs (\x1bb), and Windows scan codes — so the same bindings work in xterm, tmux, the Linux console and the Windows console.

  • While waiting for the first token a ⏳ Ns spinner is refreshed every 500 ms; afterwards a ~N tok counter is refreshed on line boundaries.

  • Esc during a stream cancels it: the turn is recorded as interrupted — the partial reply (chain-of-thought and answer text as far as it got) stays in the transcript and [cancelled] is printed. See Interruptions. (Implemented by cell::plat::peek_key, which temporarily puts stdin in raw mode; only a lone Esc cancels, so pressing an arrow key during a stream no longer aborts the turn.)

  • Ctrl+C (and SIGTERM/SIGHUP/SIGABRT on Windows) runs the persistence hook — repair the tool transcript, save session, save config, flush async writes — restores the terminal and exits with the signal number. At the prompt the editor handles Ctrl+C itself and leaves through the same path as /exit (the session/config guard). The terminating signals are taken from a dedicated sigwait thread, so the persistence code never runs inside an asynchronous signal handler; the process then leaves with _Exit, deliberately skipping static destructors, because tearing those down while the REPL, the async writer and worker threads are still alive is a heap race (it used to die with "out of memory (operator new failed)" instead of exiting). A second Ctrl+C, or a 10 s watchdog, forces the exit if the persistence path ever stalls. SIGSEGV/SIGFPE/SIGILL deliberately do not try to serialize state: they restore the default action and terminate.

  • Non-interactive mode (stdin is not a tty) reads all of stdin as one message, requires a configured provider, runs the agent loop once and exits.

Interruptions and /reload

A turn can end before the model is finished in two ways: Esc during the stream, or an LLM request that fails on its last retry. Either way the turn is not thrown away:

  • Whatever the model had already produced is kept verbatim and truncated at the last token that fully arrived (sse_feed only applies complete stream events, so the accumulated text always ends on a token boundary — nothing is completed or padded).
  • A tool call that was still being emitted is cancelled: its arguments are usually truncated JSON, it was never approved or executed, and no result is invented for it. The partial assistant message is stored without its tool_calls, so the transcript stays valid for every provider.
  • A system message is appended that records the reason (Esc, or the error and attempt count) — it is part of the conversation, not just console output, so the model sees why the previous turn stopped.
  • The session remembers this as its interrupt state (interrupt.json in the session folder, so it survives a restart). It is cleared by the next user message (including /ins) or by a /reload that reaches a natural end; /clear drops it too.

/reload continues exactly where the turn stopped: it sends the transcript as it stands (partial reply + interrupt notice, no new user message) and lets the model pick up. If that attempt is interrupted as well, the state is simply refreshed with the new reason. /reload with nothing to resume reports an error instead of sending anything.

Logging, signals and exit codes

Log lines are [YYYY-MM-DD HH:MM:SS] LEVEL [cat ] message (UTC). Categories include boot, core, llm, tool, sess, ctx, cmd, user, probe, provider, model, think, sandbox, skill, stats, vault. Only llm and tool activity is mirrored to the console (ERROR always is; DEBUG only with --verbose), so the terminal stays readable while the file keeps everything. Writes are buffered in 16 KiB chunks and flushed immediately for ERROR.

SituationExit code
Normal exit, or --selftest passed0
Unknown option / missing option value1
Non-interactive run with no provider configured1
Fatal cell::sys::exception or unhandled std::exception (logged, then the RAII guard persists state)1
Terminated by a caught signalthe signal number

A set_terminate handler records the type of any escaping exception, and a set_new_handler records out-of-memory before aborting.

Self-test

bashbash
cell --selftest

Runs against a throwaway .cell-selftest/ root (removed afterwards) and covers: high-risk and sandbox decisions in all three modes, path/traversal/symlink/credential blocking (including the is_sensitive_path rule set itself, POSIX symlinks — dangling ones included — and, on Windows, a mklink /J junction into the runtime root, since neither can be created through this toolchain's std::filesystem), the injection sanitizer (case, \r, zero-width, fullwidth, Cyrillic/Greek, accented, split-across-lines, punctuation-joined, paraphrases, oversized output), wrap_tool_output forgery resistance, read/write/write_new/edit (all five modes, ambiguity, partial-read coverage, no-ops), rg/find/ls semantics and guards, exec timeouts and exit codes, quoted numeric arguments, the tool registry and its policies, 16-way concurrent read-only tool calls, Teamwork job normalization (per-child provider/model/think, reuse references, name sanitizing, limits), store round-trips, child append/remove, history and report queries, incremental SSE parsing and buffer compaction, the lazy directory walker, thread-pool job accounting, logger rotation, vault round-trip and persistence, config save/load/migration/error handling, session grouping, /new semantics, the cwd index, load-time re-sanitization of exec results, interrupted turns (partial reply truncation, tool-call cancellation, the reason notice, and the persisted/cleared interrupt state), the line editor (CSI/SS3/meta/key-code decoding for every bound key, word boundaries, UTF-8 cursor stepping and display width, history dedup/cap, and the motion/kill/yank/transpose semantics of line_buffer::apply), skill discovery (including directory-style) and usage statistics. Prints selftest OK / selftest FAILED.

Implementation notes

A few places where the code and its own help text/comments differ, or where behaviour is intentionally simpler than it looks:

  • The sandbox is path-only: box::check_exec / box::check do not parse command names, decode encoded payloads, or block network egress by binary name — they only reject path traversal and sensitive paths. Network egress is "denied" in the sense that there is no whitelist of allowed commands; exec is gated instead by the sandbox mode, a human confirmation (or autoallow), and the high-risk second-confirmation list.
  • Switching providers resets the model name: --provider NAME (existing provider), /provide NAME and /provide add all route through config::select_provider, which clears current_model when the selection actually changes. Re-selecting the already-active provider keeps its model, and an empty current_provider counts as "first provider is active" for that purpose. A requested model on the same command line (--model) is applied after the switch.
  • Startup resumes the last session used in the current cwd, recorded in active_sessions; if no record exists yet, the most recently written session file for the cwd is selected. The last five user/assistant text messages are printed again, excluding thinking and tool-call content. --session / the session field are still persisted for compatibility, but startup uses the active-session record instead.
  • exec is the only tool whose output is scanned for injection fingerprints; the other tools are considered sandboxed at call time and get a size cap only. The tool descriptions themselves can also drift from the implementation (e.g. exec's denies-network claim, read's numbered lines), which is normal for strings embedded next to the code.
  • The tool-output wrapper tag is written with an escaped forward slash in the source; the escape collapses to a plain slash at runtime, so the marker that reaches the transcript is an opening tag carrying the tool and path attributes, and the reload-time sanitizer keys off the exec value of that attribute.

License

MIT — see LICENSE.

评论