CRAN release, batching the 0.7.1.1 to 0.7.1.94 development cycles below.
bot_configure(),
bot_send(), bot_poll(),
bot_run(), bot_run_init(),
bot_run_step(), bot_request_flush() and
bot_archive_all(); the matrix_* names still
work and are deprecated.tool_delegate(),
tool_job_status(), tool_job_cancel() and
tool_job_resume(). Unattended tool calls pass through rules
in code, an optional monitor, and a person./rc gives each CLI session a private
room from which it can be driven.audit_read() and the
audit_payload_*() functions.voice_serve() runs the AgentVoice gRPC service for
one-to-one voice./auto runs, a continual harness of approved
lessons, per-tool output caps, and an opt-in supervised
run_r worker./model lists models and sets reasoning effort; images
in a room are passed to the model as images.run_r returns what the code prints along with its
value./call and /hangup are
gone, and the voice.stt, voice.tts, and
voice.call settings are no longer read. The code is kept on
the livekitr-calls branch. AgentVoice
(voice_serve()) is unchanged.Additional_repositories is removed.openai_codex, and openai models that llm.api
routes there) now closes its unfinished tool calls with
function_call_output entries. They were given Chat
Completions role = "tool" messages, which a Responses
history does not accept./rc now creates a private Matrix room per CLI session
by default and reuses it when reconnecting or resuming. Explicit room
overrides remain available. The courier enables encryption before
inviting operators when configured for E2EE, and recovers rooms after
interrupted creation/setup./clear, /reset, and /new in
remote control start a fresh conversation in a new room, leaving a link
in the old room. Disconnected session rooms ask for a CLI reconnect
instead of starting an independent bot conversation.turn()$usage total when request
records are partial; terminal records retain both totals and identify
the canonical choice. Complete request coverage returns normalized
input/output/total token, cache and cost fields. Transcript usage is
explicitly tagged llm.api; its pi-style envelope does not
imply pi usage field names.auto_log_read() now counts blank lines as malformed and
reports their positions rather than silently ignoring them./job resume <id> and talker
job_resume for incomplete local doer jobs. Save immutable
per-attempt history and workspace snapshots, validate identity and file
integrity, and restore them in a fresh worker under the normal checkout
lock. Preserve newer checkout files and identify unavailable workspace
objects. Resume requests require the original requester or
operator.cat("token:", value)
through remote approval while retaining credential-value and
known-secret checks. Explain credential and oversized-call refusals as
declines, with no pending local approval, and suggest credential
references or smaller reviewable calls.send_room_file(path) for bot operators to send a
requested attachment to the current conversation through the host’s chat
client. Models and workers do not receive chat credentials. Preserve
tool policy and supervision, refuse credential/configuration files, and
record confirmed attachment event IDs.ask_room(room, question, thread) for one bot
to consult its own conversation in another room. Answer asynchronously
with the destination’s conversational model and a snapshot of its
current text context, with no tools, recursive requests, or rewrites of
its live window. Record the question and answer in both conversations;
retain cancellation and recovery.context_edit.retain_turns for conversation owners:
protect the recent human-led turns, including assistant replies and
tools, from CLM edits. Doers do not inherit this retention floor. The
package default remains 0.chat(), rather than combining the saved provider
with a configured model. Honor explicit overrides; a provider change
uses its matching configured model or provider default.\u
escapes now, which R turns into the same characters at parse time. This
clears the one WARNING R CMD check had been reporting, and
CI now fails on a check WARNING (WARNINGS_ARE_ERRORS).bot_run_init() takes hooks, a named list
of callbacks for policy the loop asks the host about:
on_clear(chat, cfg, room_id, title, vault_ref) decides
whether a cleared conversation becomes a room of its own, and
thread_context(chat, room_id, thread_root) names the
archive a thread continues. bot_segment_from_clear() and
bot_thread_archive(), which made that decision and read
that index here, are gone: a bare bot (bot_run(), or no
hooks) archives and resets at /clear and never
rehydrates. The segment_rooms config gate moves with the
segment code into the host (cerebro)./sidebar [N] and “Let’s take this offline” for
copying recent conversation into an operator’s private room. Route
active job progress, approvals and results there, with
private-destination checks and worker retirement to keep subsequent
shared-room work separate. Import the excerpt as one user-role quotation
with source attribution./approve-all [job-id] command to authorize remaining
requests for one active job. Add status and revoke commands; preserve
policy denials and per-call credential approvals, and verify operator
membership on use. Ordinary words and /auto do not grant
it.activity_detail: true enables previews./model now lists current and configured models plus the
active provider’s catalog, with numeric selection tied to the last
displayed list. The CLI, chat(), and Matrix rooms share the
menu. Live discovery uses llm.api’s optional list_models()
API; discovery failures keep configured choices./effort to show or change a conversation’s
reasoning effort. /effort default restores its
configuration/provider default. Split talkers change independently of
their doers.openai_compatible
during model discovery. Check Responses routing before offering context
editing; reject unsupported pure-CLM requests before generation and skip
incompatible fallback models./remote-control (alias /rc) to
continue the live CLI or chat() session from a Matrix room
through a running local bot. Keep the same model, history, workspace,
and tool policy, with operator-only input and request-specific
approvals. Relay bounded progress and replies without a second sync
client./auto, /max-tokens,
/stop, and disconnect, with queued output delivery and
callback restoration when control returns locally./auto continues the current conversation. Add auto
status and resume, expose the execution setting, and hide tools
unavailable to the auto monitor.read_handle as a read operation for approval
policy on every channel. Auto runs omit tools whose read/write/exec
operation is unknown, including unclassified package and MCP tools./max-tokens and report an unrecovered
cutoff as a stopped auto run.context_edit.strategy = "clm" disables automatic
summary compaction and holder archival. Doers inherit the editing
policy; reviewers and monitors keep their existing configuration.context_vault before removal. Original entries are
preserved as Markdown and provider-native JSON. Older human turns may be
filed and dropped, never rewritten; the latest human turn remains
protected. Workers never inherit vault filing./clear.edit_context schema’s required fields as
a JSON array. The single required field previously became a string,
causing providers to reject requests with context editing enabled before
generating a reply.edit_context tool drops or summarizes completed entries and
maintains a task note while protecting instructions, human messages, and
the current tool batch. Edits commit through the session journal at a
checkpoint; compact edit segments share immutable entries, and failed
writes prevent the live rewrite. context_edit.enabled /
cadence, session overrides,
/context-edit on|off|status, and
--context-edit control the feature. Anthropic and OpenAI
chat-completions shapes are supported; threshold compaction remains the
fallback. Subagents inherit the effective setting, and CLI/room reports
and structured events show each applied edit.<id>.journal/ beside the transcript
now holds live.json, the live history as last mirrored
after every assistant message and tool result, and
segment-NNNN.json, the exact history each compaction
replaced, written before the rewrite so a write failure blocks the
compaction rather than losing the prefix. The record is the segments
past their inherited heads plus the live tail: every entry once, no
synthetic summaries. The same record covers archival, subagents (whose
per-turn transcript slice now comes from the journal and is correct
across a compaction inside the turn), Matrix rooms and threads, and
interrupted turns. journal_enabled: false turns it off.
This is the pattern a downstream host already ran for long autonomous
sessions, now built in for every surface.fetch_url). The talker’s toolset gains
fetch_url, so a question about a page the user names (a
URL, a GitHub issue and its comments, a raw file) is answered by reading
it, not by handing off a whole job. Without it, a talker asked to read
an upstream issue had no way to fetch it and escalated to cloning the
repository through the doer, which then tripped the
write-outside-project gate. The guidance tells the talker to fetch and
answer, and never to clone a repository just to read something.delegate gains attach_context: the message the
turn is answering is written beside the job as context.md
and the task points the worker at it, so the content never passes
through the talker’s tokens.Matrix tool calls are supervised;
auto_approve_asks no longer approves everything.
The setting used to answer “yes” to every call policy asked about, in a
room’s turns and in its jobs, the credential-path rule’s “ask” included.
Now every call in a room goes through three steps. Rules in code read it
first, shell commands and R code included, and put to a person:
credentials, writes outside the project, elevated privileges, other
machines, publishing, and git commands that throw work away. With
auto_approve_asks: true, what the rules leave open goes to
a monitor, a model with read-only tools and the project’s instructions
in a process of its own, which approves, refuses with a reason the
working agent reads, or passes the call to a person. A person answers by
reaction in the room, as before; without the setting a person is asked
in place of the monitor. A job’s result says how its calls were
answered. The monitor’s model and any extra writable directories are set
under supervisor in the user’s own config (a project config
cannot set them); see vignette("configuration"). The
monitor is started on first need, retired when idle, and counted against
jobs.max_workers like a doer or reviewer. The rules read
text and are not a sandbox.
A tool result that was cut for length can be
searched. read_handle gains
op = "grep" (the lines matching pattern, with
line numbers and a count of how many lines were searched) and
op = "lines" (start to end, 40 at
a time). The talker, the reviewer, and the monitor now have
read_handle: the talker was shown the first 40 lines of a
long result and told to read the rest with a tool it did not have, and
the other two had no way at all. The notice on a cut result now names
only a tool the session has, and says so when it has none. In a Matrix
bot each room keeps its own cut results (named .o_001, …),
so one room cannot open what another room’s tools returned; they shared
the process’s store before.
Matrix approvals only accept an operator or the room’s
one human. Any reaction other than the bot’s own used to answer
an approval prompt, so a second bot in the room could approve this bot’s
tool calls. Approvers are now the configured operators, or,
with none configured, the room’s one human when it has exactly one.
Other reactions are ignored. A room with no possible approver gets a
notice and the request is declined. Bots not listed under
bots count as humans.
Talker mode: delegate work and keep talking.
With "talker": {"enabled": true} in a bot config or the
corteza config, a session runs on a fast model (Haiku 4.5 for anthropic,
gpt-6-luna for openai, or talker$model) with read-only
tools plus delegate, job_status, and
job_cancel. Delegated work runs on a persistent per-session
worker using the session’s configured model. In Matrix the bot keeps
answering while jobs run, posts each result to the room or thread it
came from, and takes approvals as reactions on later polls. In the CLI
and chat(), results and approvals appear before the next
prompt; /jobs and /cancel manage them. Off by
default.
The doer starts with the project’s context. A delegated job’s worker gets, after its role, what a session opened in its directory gets: the shared instructions file, the project’s AGENTS.md or CLAUDE.md, the briefing, and configured context files. The task text is all a talker hands over, so without this a project’s rules reached the doer only when the talker restated them. A reviewer gets the shared and project instructions after its own prompt, so it can say when the work broke one; its tools stay read-only and confined to the checkout.
A Matrix bot’s rooms can hand work to each
other. delegate takes room (a room’s
name, id, or project directory). The job is then that room’s: it runs on
that room’s doer, in its directory, under its checkout lock, with
approvals asked there. That room is told when the job is accepted, in
the room and in its talker’s history, and the result is posted in both
rooms. Either room can ask about the job or cancel it. Only a configured
operator’s request is handed over. A room whose topic names another
directory than its running session works in is not handed work until the
two agree (/clear there starts a session in the new
directory). When no room matches, the talker is told that every room was
checked, shown the nearest names, and told when the project directory
exists with no room working in it; the reply no longer lists every room,
which a bot in many rooms had cut short.
A job’s result is retried until it is posted. A result, or the notice of a hand-off, that could not be sent (homeserver down, token mid-rotation) was dropped and reported as delivered. It is now kept in the state directory and retried by the poll loop with a growing delay, and set aside after a day. The job itself is never resubmitted.
A job runs in the directory its record names. A
job queued for one directory fails without running if its session has
since moved to another, and a live worker is reused only while it is in
the session’s directory. /clear and /model no
longer orphan a running job: the job and its worker move to the
replacement session.
A Matrix bot retires idle job workers. Each room
and thread kept a doer process, and a reviewer once it had one, for as
long as the bot ran. A worker idle for
jobs$worker_idle_minutes (default 30; 0 to disable) is now
closed, and no more than jobs$max_workers (default
8) are kept, longest idle closed first. Both are read from the corteza config for the bot’s own directory. A worker running a job, or waiting on an approval, is never closed, so the count can exceed the limit while that many jobs run. The next job in that room starts a new process that restores the worker’s saved workspace and conversation.
A thinking setting names the Anthropic
thinking type. thinking in the corteza config, in
a talker block, or under jobs and
jobs$reviewer, is sent as the request’s
thinking.type. Claude Sonnet 5.5 thinks unless told
otherwise and refuses "disabled";
"between_tools" is its setting for answering without
thinking first, as in
"talker": {"enabled": true, "model": "claude-sonnet-5-5", "thinking": "between_tools"}.
The value belongs to one model, so a fallback to another model does not
carry it and /model clears a talker’s. It cannot be
combined with thinking_budget_tokens. Requires llm.api
0.1.9.13, whose agent() takes
thinking.
Jobs are durable and never re-run blindly. Each
job’s intent is written before a worker sees it and its outcome beside
it. After a restart, a job that never started is still queued; one that
started with no outcome is reported indeterminate and not
re-run. The worker checkpoints its R workspace and history at each job
boundary and a replacement restores them. Jobs carry an owner, so bots
sharing a machine or a room never settle or start each other’s
jobs.
One writing job per checkout. A job that edits takes its checkout’s lock (the git toplevel) and later writers queue until it ends, across every bot on the machine. Edits made outside jobs are not covered.
Optional review of delegated work.
delegate(task, review = TRUE), or
"jobs": {"review": true} as the default, follows a finished
job with a review by a second worker that has read-only tools, no
network, no access outside the checkout, and no way to run code. It
reads the job’s diff and report and answers with a verdict and findings.
The diff is taken between snapshots of the checkout as the job started
and ended, so it leaves out changes that were already there, and it
covers untracked files up to
"jobs": {"review_snapshot_max_mb": 20} in total (ignored
files are never covered). Past that limit the two snapshots still cover
the same files: files that were untracked at the start are left out of
both, or the job’s new files are left out of the end, and the reviewer
is told which. The checkout stays locked from the start of the job to
the end of the review. If the bot restarts in between and another job
takes the checkout first, the review is cancelled instead of run on a
changed tree.
"jobs": {"reviewer": {"provider": ..., "model": ...}} runs
the reviewer on a different model. The reviewer cannot run tests, and a
review does not start a revision by itself.
git_status, git_diff, and
git_log no longer run commands or read outside
allowed_paths. A ref or path could carry shell
commands, a ref could be an option, and path could name any
repository on the machine. Git now runs without a shell, and a session
confined with allowed_paths is held to it, including inside
a subdirectory of a repository. Three things behave differently in every
session:
git_diff is a literal path. Globs
and pathspec magic such as :(top) are not interpreted.<rev>:<path>, the object id of a file or a
tree, a tag on one, and anything starting with - are
refused.post-index-change
hook, external diff, textconv, or gpg, and no fetch of objects a partial
clone lacks. A file under a clean filter (git-lfs) is compared as its
raw content, and a submodule by its commit only.Matrix /model now changes the model replies
use. It set a field turn() never read, so it only
renamed the badge. The badge also showed the provider’s default model
for sessions created from cfg$model; it now shows the model
in use.
voice_turn(),
voice_turn_report()) now has a second media backend: a call
over LiveKit, where the bot is a participant and hears one audio stream
per person. The call runs in a worker process beside the bot
(R/call-loop.R, R/call-worker.R): it cuts each
person’s audio into utterances by signal energy, posts each to an
OpenAI-shaped transcription route, answers through the room’s session,
synthesizes the reply a sentence at a time through an OpenAI-shaped
synthesis route, and publishes it in 200 ms chunks. Someone talking over
the bot stops it within a chunk, cancels the generation, and trims the
reply posted to the room to what was heard. With several people the bot
answers when addressed by one of its names or as a follow-up within 20
s; with one, always. The bot process posts and edits on the worker’s
behalf and tells it the call’s media keys; the worker never holds a
Matrix credential. Configured under voice.stt,
voice.tts and voice.call in the bot config
(see vignette("configuration")); needs
livekitr (Suggests). Each route speaks one of two wires:
the OpenAI audio API, or wire: "gpu-host", a gpu.ctl host’s
POST /infer spoken directly (whisper and chatterbox
entries; the bearer token from key_file, the voice cloned
from the reference WAV named by voice.tts.voice). The bot
joins a room’s MatrixRTC call when someone else is in it (a call button
posts a membership event and nothing else; chat.api 0.1.0.1 reports
those from the poll), in the rooms voice.call.rooms allows,
and hangs up when everyone else has left; /call joins by
hand and /hangup leaves. Joining goes through chat.api’s
call surface (chat_call_join(), an encrypted client). The
bot process forwards the media keys that arrive on each poll to the
worker and shortens its long poll to half a second while a call is on,
so the worker’s replies are posted promptly.
voice.call.greeting is said once on joining;
voice.call.connect_delay_s holds the worker between the
membership and the media connection for clients that only notice
participants who arrive after them. Tried live against FluffyChat 2.10
on a LiveKit SFU: the bot joins, is heard, and hears the room once the
peers’ media keys arrive; the log names each key’s sender and
recipients, never the keys.run_r releases its output sink when a worker
call is interrupted. The supervised worker aborts an
over-deadline run_r with an interrupt, which unwound past
the stream cleanup and left a sink, its file connection, and the temp
file open in the persistent worker. With no ceiling, repeated timeouts
accumulate sinks, connections, and files over a long run. Cleanup now
runs on interrupts as well as on errors and normal returns./auto runs continuously when no
--loops is given. Previously a bare
/auto <goal> stopped after the configured iteration
cap (default 10). It now runs continuously, governed by the monitor
subagent and the resource caps – tool calls, tokens, time, cost, and the
stall guard – rather than an iteration count, matching the “work until
done, stop at the boundary” model of an Auto approval mode.
--loops N still sets an explicit iteration cap. The run is
never truly unbounded: max_tool_calls and
stall_loops stay finite by validation, so a continuous run
always stops on some cap or a monitor verdict.corteza.narration_streak tool-call turns,
default 3) is reworded to match.chat(), once per model response and independent of
the approval prompt – previously it surfaced only inside an approval
prompt, so an auto-approved call (every call in an unattended run)
showed nothing. See cli_commentary_lines().task_create is accepted automatically instead
of prompting a human (which would hang an interactive run or
default-deny a non-interactive one), and the task guidance drops its
“ask clarifying questions first / wait for plan approval” steps in
favour of the unattended flow. Attended sessions still prompt for plan
approval exactly as before.AUTO_STATUS: blocked is a recognised status: the loop
treats it as an escalation and stops immediately, rather than routing
the request through the monitor as ordinary progress where a
continue verdict would run another turn on an unanswered
question. The autonomy prompt tells a blocked worker to report it rather
than emit continue with prose.run_r starts with R’s default
packages. A supervised worker
(run_r_mode = "worker") is a bare
callr::r_session, which loads only base plus
callr’s own tools, so common helpers from
utils/stats/ methods
(str, tail, head,
setNames, capture.output, …) were missing,
unlike in-process run_r which inherits the front end’s
default packages. The worker now sets R_DEFAULT_PACKAGES to
the normal default set so both modes see the same base environment. A
host that sets R_DEFAULT_PACKAGES in
run_r_worker_options$env still wins.run_r returns what the code prints.
Text written with cat(), print(),
message(), or a warning comes back ahead of the final
value’s print, like a console, in both run_r_modes. Streams
interleave in emission order, so a message() before a
cat() prints before it. Warnings are muffled only under the
default warn < 2; with options(warn = 2) a
warning still becomes an error and halts. Output written before an error
is kept ahead of the Error: line. tool_run_r()
also returns an r_error flag so a caller can tell a failed
evaluation from a successful one, without changing the model-facing text
or isError. Previously only the final value’s print was
returned and everything else was lost.config$tool_output_caps raises the 50-line / 5000-character
tool-result cap for named tools whose results the model must see whole
(a host-supplied game board, a table), keyed by tool name with
max_chars and/or max_lines. Other tools keep
the universal cap, and a result past the raised cap still stashes to a
handle.config$run_r_worker_options passes named arguments to
callr::r_session_options() for the supervised
run_r worker (env, libpath,
cmdargs, arch), so a strict host can run only
the model’s R under a sandbox wrapper at
R.home("bin")/<arch>/R.run_r_mode = "worker"
gives a session one private persistent callr::r_session,
applies skill_timeout, bounds model requests with
skill_timeout_max, retains partial state after a clean
interrupt, and routes handle inspection back to the owning worker. The
historical in-process tool_run_r(code, envir=) contract
remains the default. Timeout results tell the agent to inspect retained
state and optimize, split, parallelize, or approximate before retrying.
The session’s skill_timeout now also supplies the default
argument to self-bounded bash, cmd, and
run_r_script calls; explicit caller values continue to
win.report = TRUE request asks
for status, notes, findings, concerns, deviations, open questions, and
feedback. The parent still receives a character reply for compatibility;
the report is rendered for the model and retained in a
corteza_report attribute for R callers.query_subagent tool now defaults to
wait = FALSE: the prompt starts at once and
collect_subagent fetches the reply, so several subagents
can work in parallel while the parent continues. With
wait = TRUE, subagent_query() fires the same
one-shot call and collects with a real deadline (timeout,
default 60 s; Inf opts out of the bound). A child that has
not replied by then leaves the query pending for
subagent_collect() or subagent_kill() and the
call returns NULL, where it previously blocked for as long as the child
took. A timeout that is not a single non-negative number is refused
before the prompt is fired. /ask in the REPL says so and
points at /collect; the hall monitor reads a timed-out
query as no verdict and escalates, as its contract already said it
should.temperature, allowing compaction to work with subscription
endpoints and models such as Claude Opus 5 that reject it.bot_default_cwd() capitalised the Matrix
localpart, so a bot logged in as @cornelius:cornball.ai
fell back to an empty ~/Cornelius beside the
~/cornelius instance directory cerebro runs it from, and
created it. The fallback is now ~/cornelius. An instance
directory with other capitalisation, such as ~/LilCasey,
was mismatched before and still is.instruction_roots
entry may point at a skill directory outside the root, such as a
package’s inst/skills/<skill>. corteza walks the
target as a bounded alias and derives the id from the link name, so one
flat hub of package links can serve corteza, Claude Code, and Codex
alike. Deeper symlinks must still stay inside their root, skill
resources still cannot reach outside their bundle, and a skill reached
through two roots stays a single entry owned by the more specific
root.Context compaction now belongs to the shared agent
lifecycle. turn() checks the live context before a
run and after each complete assistant/tool result batch, so direct
callers cannot bypass it. A single long autonomous prompt can be split
at a safe call/result boundary while retaining a recent token tail;
Anthropic, OpenAI Chat, and Responses/Codex native histories are all
pairing-aware. A real context-window overflow gets one compact-and-
continue retry without inventing a user message, and usage from both
attempts is retained. CLI and chat() manual/automatic
compaction now use this same path and durably record summaries through a
pre-rewrite lifecycle hook. Codex compaction omits unsupported sampling
controls, and caught summarizer failures are exposed through a lifecycle
hook instead of remaining silent.
Compaction briefs can be domain-specific.
new_session(compaction_prompt = ) lets a long-running host
state what evidence and working state its summary must preserve, while
NULL retains corteza’s existing coding-agent brief.
Context accounting understands Codex and provider products. Responses output, function-call results, and opaque reasoning state are no longer reported as near-empty history. Context-window lookup can distinguish the public API’s model limit from the smaller ChatGPT Codex working window.
Instruction skills are now compact, lazy, and
session-scoped. Startup discovers exact SKILL.md
files recursively, puts only their names, one-line descriptions, and
stable ids in the prompt, and exposes the read-only
skill_instructions tool for exact body or relative-resource
retrieval. Each CLI, console, Matrix room, and subagent owns an
immutable hash snapshot; changed, foreign, traversal, and
symlink-escaping resources are refused. Explicit
instruction_roots merge by stable root id and
instruction_disabled ids union across global and project
config. Instruction documents remain separate from executable tools, and
the legacy full-body format_skill_docs() contract remains
available.
System context is now a provenance-carrying saber
manifest. Corteza requires saber 0.7.2.3 at its published
commit, renders compact shared and project instructions through its
manifest API, and retains the source metadata on CLI,
chat(), and Matrix sessions. /context reports
included and skipped sources, their token estimates, reasons, and
paths/origins without exposing their bodies. The migration removes
corteza’s obsolete inlined saber::agent_context() fallback
and the eager package-help layer that duplicated tool schemas at
substantial token cost; package help remains available on demand.
Claude-specific global instructions and basename- matched memory are no
longer guessed implicitly—operators can add any such files explicitly
through context_files.
run_r can now be capability-scoped without
changing its default.
tool_run_r(code, envir = ...) accepts a caller-owned
persistent R environment whose assignments and large-result handles
remain private to that scope. Existing calls still evaluate in
globalenv() and retain workspace auto-capture; the
model-facing tool schema still exposes only code, so
hosts—not models—select the execution boundary.
Limit failover now finishes interrupted tool runs without
replaying completed tools. Provider-native histories are
bridged to portable text when the next candidate uses a different wire.
Matrix configs also honor reasoning_effort and can set
fallback_primary_retry_at (for example,
"Mon 03:00") so a limited primary is retried at its weekly
reset while the fallback serves requests in the meantime. Every fallback
reply now names the actual model/provider; crossing from subscription
providers onto an API-key provider produces an intentionally loud
billable-usage banner.
new_session(cache = "5m") (or "1h", or
"none") reaches llm.api::agent(cache = ), with
config$cache as the fallback. Anthropic-only, and stripped
for other providers – per candidate during a fallback too – so a codex
session never carries it and llm.api’s off-Anthropic warning stays
meaningful. Validated as a closed set, because llm.api’s
match.arg would otherwise partial-match "5" to
"5m" silently. Billing-only: the model sees byte-identical
input, so this cannot change a result. With llm.api 0.1.9.6, now the
Imports floor, the marker covers the message history as well as the
system prompt, so each request in a tool loop reads the previous
request’s context from cache and pays fresh input only for what was
appended since. The floor is raised because an older llm.api marks the
system prompt alone and would silently deliver a fraction of that.reasoning_effort now works on Anthropic
too. Claude’s depth control is
output_config.effort, not the thinking budget, and its
scale is "low", "medium", "high",
"xhigh", "max" – the API names it in the 400
when refused. Rather than add a second setting,
reasoning_effort is routed to whichever field the
provider’s wire uses, so a session sets it once and it means the same
thing on either family. Routing is symmetric, because a limit fallback
re-routes per candidate and a one-hop-only value would silently vanish
on the provider that answered. thinking_budget_tokens is
unchanged and still Anthropic-only; the two are independent.API error (400): Invalid value: 'thinking' –
and since a 400 is not a limit error, that ended the turn instead of
continuing down the chain. A Matrix bot on anthropic_claude
hit this the moment an Anthropic usage limit sent it to its
openai_codex fallback mid-conversation. Candidates whose
wire cannot replay the history’s shape are now skipped (with a message),
so the walk reaches one that can.new_session() takes reasoning_effort
("low"/"medium"/"high", forwarded
on the openai and openai_codex wires) and
thinking_budget_tokens (Anthropic extended thinking). Both
resolve session-field-then-config like max_tokens, so
reasoning_effort or thinking_budget_tokens in
.corteza/config.json reaches the CLI, chat(),
and Matrix surfaces without further wiring. Each is dropped for
providers whose wire cannot carry it – including after a limit fallback
rewrites the provider, where an effort setting meant for codex would
otherwise reach the Anthropic Messages body as an unknown field and
400..corteza/harness.json travels with a
cloned repo, so it is third-party data: entries are validated as they
load, and project lessons render in a separate “Untrusted project notes”
block that tells the model to treat them as reference material and never
as instructions, unless the project is trusted via
harness_trust_project in the global config.
Rollback is now a compare-and-swap that refuses when an entry changed
after the refinement being reverted, instead of silently destroying the
later edit. Store writes use a unique temp file, check
file.rename(), and hold a lock across the whole
read-modify-write so concurrent sessions can’t discard each other’s
entries. /refine shows evidence, kind, and path with each
proposed edit and enforces the 8-edit maximum in code.harness_auto: config flag for
recording lessons without prompting (implemented as the default for the
harness_note permission, so there is one enforcement path;
explicit permissions still win). Auto-recorded notes remain in the
ledger and reversible.cwd, so
harness_note wrote to the process working directory instead
of the session’s project. Found by the new end-to-end approval
tests.harness_note(title, fact, evidence) is a tool the model
calls the moment it verifies a durable fact (approval- gated by
default); /refine reviews the conversation into proposed
edits shown one at a time for approval, with /refine list,
/refine rollback <id>, and
/refine promote <id>. Entries carry ids, versions,
provenance, and evidence; every applied edit records before/after
snapshots so rollback is a generated inverse; the base system prompt is
never in the store. Project scope lives in
.corteza/harness.json, global in the user data dir.
Injection is the whole store while small (empty store adds nothing to
context).new_session() gains
max_tokens: a per-response output-token budget
forwarded to llm.api::agent (NULL defers to
config$max_tokens, then llm.api’s provider default — 4096
on Anthropic). Agent turns that write long tool calls, big
run_r bodies especially, need more than that default: a
response the budget cuts off ends the turn with
[Output truncated: max_tokens] (llm.api#39) instead of the
model’s answer. Resolution follows the
web_search/base_url idiom via
.session_max_tokens(); both boundaries validate (single
positive whole number — as.integer() alone would truncate
fractions and pass NA through). Requires llm.api >= 0.1.9.5:
forwarding a budget onto an older llm.api walks straight into the
silent-truncation bug #39 fixes.agents/<agent>/auto/<run_id>.jsonl.
run_start is written before validation (a refused start
still appears), each gate consultation gets a record naming the deciding
authority (budget / envelope / monitor / accounting) — envelope-refused
calls previously left no trace anywhere — each monitor progress verdict
is recorded, and run_end carries a mechanical stop
category, spend, executed calls, and the worktree delta. A file with no
run_end is a run whose process died. The
auto_run_id also stamps main-session trace entries written
during the run and the monitor’s transcript header, so the three
surfaces join; attended formats are unchanged.turn() falls back to other providers on limit
errors. A new fallback config key (Matrix config
and .corteza/config.json; a bot_configure()
argument) lists "model provider" pairs to try when the
primary answers with HTTP 429/503/529 or a rate, usage, or quota
message. The provider that tripped is skipped process-wide for
fallback_cooldown_minutes (default 30), then the primary is
tried again. A limit hit after tool calls have run is not retried, so no
tool call executes twice. Other errors pass through unchanged./auto [--loops N] <goal> runs bounded
unattended iterations, supervised by a read-only “hall monitor”
subagent. The monitor is the point: turn() routes only
"ask" verdicts to approval_cb, and the default
tensor resolves ordinary project writes to "allow" outside
~/projects, so a supervisor wired there would never be
consulted for the edits that matter. Every call surviving
policy() now passes a session$auto_gate
instead, whatever the verdict..corteza/config.json
travels with a cloned repo. Shell and R execution is off unless enabled
per run with --exec or in your own global config, because
those tools’ effects cannot be resolved from their arguments.subagent_turn_init() previously built its session without a
web_search argument, so every subagent had server-side
search on regardless of its tool preset./permissions switches approval mode for the
session (/permissions allow|ask|deny), instead of
only printing settings. Changing how much corteza asks before running a
tool previously meant editing config.json and restarting.
Session-scoped like /dryrun; the reply names the config
file for anyone who wants it permanent, and says plainly that
allow does not waive the credential-path guard, which
short-circuits ahead of the config overlay.grpc_* function surface): Suggests, namespace
qualifiers, the runtime floor (rgrpc >= 0.1.0, the first release
under the new name), and the refusal messages.user_id), OpenID
verification goes straight to the configured server URL
instead of through Matrix discovery — a tailnet homeserver is
deliberately not publicly discoverable. Every other server name still
discovers normally, and the bypass changes only where the question is
asked: the sub-domain binding and membership check are untouched.
Derived entirely from the bot config; no hostname constant in code or
config. Replaces the hostname-rewriting shadow in the launch
script.voice.speech_style in the config
replaces the instruction’s text; typed room chat keeps markdown.gpu-voice-alloc/1 contract. The request body
carries the protocol version, and every mint presents the service
credential from the new voice.allocator_token config key (a
configured allocator without its token refuses with a named config
error). The response envelope (ok, v,
allocation_id, room_id) is validated before
the grant is read, a refusal’s error sentence is surfaced,
a 401 is reported as the config problem it is, and the allocation id is
logged so log lines and revocation can name the grant.llm.api::llm_cancel():
the provider stops generating and the turn returns its partial reply
immediately, instead of running out a tail nobody is listening to. The
voice server is single-threaded, so that tail was exactly the delay
before the user’s next turn could start – and it billed for every token.
The room record still keeps everything generated up to the cut.voice_serve() runs the AgentVoice gRPC service a fluffychat
live-voice client talks to: AllocateVoice verifies the
caller’s Matrix OpenID token against their own homeserver, checks room
membership, and relays a media grant from the fleet allocator
(voice.allocator, HTTP); Converse runs one
turn through the shared turn() machinery, streaming text
deltas as the model produces them; ReportTurn cuts the
stored room message down to the prefix the user actually heard, counted
in code points. The reply is posted when generation ends and edited down
on report, so a session that dies unreported leaves the full text
standing – the documented fallback. Audio never crosses corteza: the
client streams PCM to the model hosts directly. Runs as its own R
process, next to the room poll loop, and needs the grpc and
RProtoBuf Suggests. The contract is
inst/proto/cornball/agent/v1/agent_voice.proto, vendored
from cornball-ai/fluffychat.cfg$transport picks the client constructor behind
bot_chat_client(): absent or "matrix" is the
Matrix adapter as before; a
{"constructor": "pkg::fn", "args": {...}} list builds any
other chat.api client, and the whole room loop runs on it.
The adapter must hold its own poll cursor and report
first_run; the existing guard now says so.A segment room is not named after the command that ended
it. The fork auto-prepends a bare /clear as its
own message, so a conversation opening with one produced a permanent
room titled /clear (2026-08-20). Commands are skipped when
picking the title.
A conversation the user said nothing in gets no
room. A /clear straight after a
/clear archives fine and used to become a sidebar entry
that will never be worth reopening. The bar is “said anything at all”,
not a turn count – a threshold would be a number nobody chose.
openai_codex get 20 MB, and a provider that is
not listed takes the conservative default rather than a guess.
image_max_bytes still overrides all of it. The “skipping”
message now names the limit as well as the size, since a line saying a
picture was refused without saying what would have passed is the one
thing a reader has to go find out.A picture posted in a room reaches the model as a
picture. An image arrives as its own message carrying an
attachment that names where the bytes live; corteza fetches it through
the transport (chat.api::chat_download()), encodes it, and
sends it alongside the text as llm.api::llm_content().
Before this the bot saw only the filename, and answered that no image
was attached.
On by default for the cloud providers, off for ollama (a
local build is as likely to be a text-only 9b as a vision model). Both
are overridable with images: true/false in the
Matrix config, and image_max_bytes sets the per-image
ceiling (5 MB by default). One unfetchable picture – an encrypted
attachment, most likely – costs the message its image and nothing else.
Requires chat.api >= 0.0.1.24 and an llm.api with
llm_content(); without either, the text goes through as it
did before and a message on stderr says why.
ai.cornball.fold index the fold writes into the topic room.
Replies go back into the thread they answer, falling back to the room
where the transport cannot route one. Requires chat.api >=
0.0.1.23.segment_rooms, /clear files the ended
conversation as a “segment”: a private room named by the conversation’s
opening line, carrying the archived-transcript pointer as a notice and
an ai.cornball.lifecycle state event that clients group and
demote by. Off unless configured. Requires chat.api >= 0.0.1.22
(chat_set_state()); older transports skip with a
message.matrix_* is now bot_*, and
R/matrix.R is R/rooms.R. corteza
reaches every transport through the chat.api contract and
makes no mx.api or mx.client call of its own,
so the prefix named a coupling that no longer exists. Of the 74
functions renamed, roughly 20 were ever transport; the rest are agent
behaviour that had been wearing a Matrix prefix since the day it was
written.
The eight exported names stay as wrappers that warn and forward:
matrix_configure, matrix_send,
matrix_poll, matrix_run,
matrix_run_init, matrix_run_step,
matrix_archive_all, matrix_request_flush.
Nothing about behaviour differs. They are scheduled for removal at
1.0.0.
Message records use the contract’s field names:
room_id is channel, event_id is
id, matching chat.api::chat_message().
cfg$room_id is unchanged – it is a key in a JSON file on
disk, and renaming it would be a config migration rather than a
rename.
The last mx.* calls are gone.
Credentials and current-state reads go through chat.api:
chat_matrix_config(), chat_config_save(),
chat_matrix_configure() and
chat_set_identity() for the credential lifecycle,
chat_channels(), chat_history(),
chat_pending() and chat_mark_read() for the
state the poll cursor cannot answer.
The startup invite catch-up was the last raw sync, and it was raw
precisely because an invitation is standing state that a cursor cannot
deliver. chat_pending() is the verb for it.
mx.api leaves Suggests, and
matrix_require_mx() checks chat.api alone:
repeating a downstream package’s own requirements is asserting facts
only that package can keep true. mx.client stays in
Suggests because the integration tests stub it to drive a real relogin
through the whole stack.
chat_poll() no longer returns
$client. corteza used to take the post-sync config
out of it and rebuild a client per send, because a /model
rename could rotate the access token and the config file the two shared
was the only thing keeping them in step.
chat_set_identity() leaves the rotation on the client that
performed it, so there is one client for the poll and every send after
it.
Invitations ride the transport contract, and
res$raw goes unread. The poll loop reads
chat_poll()$invites and joins with
chat_join(). That was the last thing it took off the raw
sync response, so the migration’s stated finish line is reached: every
message, decrypted message, reaction and invitation now arrives as a
record.
matrix_invite_inviters() is deleted. Reading who sent an
invitation meant walking the stripped invite_state, which
put Matrix sync-shape knowledge two packages away from the sync;
mx.client owns it now and the record carries it.
The operator gate is unchanged in behaviour and clearer in reporting: an invitation from someone not on the list and one whose sender the homeserver never named both refuse, and now say which is which.
One invite that cannot be joined no longer risks the others.
chat_join() raises so each failure is visible, and
matrix_accept_invites() catches per room so a revoked
invitation cannot take the whole poll with it.
The startup catch-up still reads a raw sync. It needs a full no-since
sync to see invitations issued while the bot was down, and
chat_poll() only ever resumes from a cursor – so it stays
until the contract grows a verb for that. It goes through the same gate,
so the operator policy is written once.
Requires chat.api >= 0.0.1.14 and
mx.client >= 0.2.0.4.
Room metadata rides the transport contract.
mx_room_name(), mx_room_topic() and
mx_room_members() are gone from the Matrix loop, replaced
by chat_channel_info() and chat_members().
Three of the nine remaining direct mx.api calls go with them.
matrix_new_session() made three state reads and fetched
the topic twice: once inside matrix_room_cwd() for the
working directory, and again for the room description. It makes one now,
and matrix_room_cwd() takes the topic rather than fetching
one.
matrix_archive_session(),
matrix_archive_all() and
matrix_handle_flush_signal() take a chat
client where they took an mx_sess;
matrix_room_members_cached() likewise.
matrix_archive_all() is exported, so that is a signature
change.
A failed member lookup still keeps the previous cache rather than
emptying it. chat_members() raises instead of returning
character() precisely so the two can be told apart – an
empty room and an unanswerable question are different, and only one of
them means the room emptied.
Also drops the unused mx_sess parameter from
matrix_default_system(), which no caller passed.
Requires chat.api >= 0.0.1.13, enforced at
runtime.
Reaction approval rides the transport contract.
matrix_reaction_approval() no longer calls
mx.api::mx_react() or runs its own private
mx_sync loop; it uses chat_react() and
chat_poll()$reactions. Two of the four direct mx.api sites
are gone with it. The approve/deny vocabulary stays in corteza, where
the prompt that teaches a user which emoji to tap also lives, and is now
overridable per config via approve_keys /
deny_keys.
Three bugs fixed on the way:
cfg$room_id, so in every room but one no tap was
ever seen.save_cursor = FALSE, so its private cursor cannot be
written over the bot’s own position. This was not reachable before – the
old loop drove mx_sync directly and never touched the
stored token – but it becomes possible the moment the loop goes through
chat_poll(), and it is the failure that would have silently
eaten every message sent while a user was deciding.mx_extract_reaction_verdict()’s corteza wrapper is
deleted: it delegated the approve/deny vocabulary to the transport
package.
Requires chat.api >= 0.0.1.12 and
mx.client >= 0.2.0.3, both enforced at runtime.
End-to-end encryption moves into the chat.api Matrix
adapter. R/matrix_crypto.R is gone and
mx.crypto has left Suggests. corteza no longer builds an
Olm account, carries Megolm sessions, tracks which rooms are encrypted,
or keeps a second send path for encrypted rooms. Setting
e2ee in the Matrix config now enables encryption in the
adapter, which encrypts and decrypts on both sides of
chat_poll() / chat_send(); decrypted messages
arrive in chat_poll()$messages like any other traffic, so
the poll loop stops reading m.room.encrypted out of the raw
sync. matrix_poll() drops its crypto argument
and matrix_run_init() no longer returns a crypto context.
Requires chat.api >= 0.0.1.9, enforced at runtime.
corteza names no crypto store. The adapter derives one per Matrix
device, (user_id, device_id), and records that identity in
the store so another device cannot open it. This replaces
dirname(config)/crypto, which tied the device identity to
wherever the config file sat, so a moved config silently minted a new
device and lost every Megolm session with it.
mx.client floor raised to 0.2.0.2.
That release stops mx_send_encrypted() reporting success
for a message nobody can read: it filters its own device by
(user_id, device_id) rather than device_id
alone, refuses to post when every recipient device was skipped, and
treats a partial /keys/query or /keys/claim as
unanswered rather than as a user with no devices. corteza does not call
it directly, but every encrypted reply this loop sends goes through
it.
CI installs and version-checks the Matrix stack.
It is all Suggests, and _R_CHECK_FORCE_SUGGESTS_=false let
a run pass without any of it – which it did, silently, because every
Matrix test sits behind a requireNamespace() guard that
skips the whole integration file. The workflow now installs
mx.api, mx.client, and chat.api
from source and fails if either declared floor is unmet, so the
transport tests cannot skip themselves into a green tick.
Additional_repositories declares the cornball
drat. chat.api is a Suggests and is not on CRAN,
so CRAN could not resolve it and the check NOTE listed it with no
availability line – which blocked a release. It is now published at https://cornball-ai.github.io/drat/ and declared here,
so the NOTE reports chat.api yes against the declared
repository, the form Writing R Extensions describes for exactly this
case./model
acknowledgement was dropped silently;
matrix_update_displayname() now returns the refreshed
config. Startup and /clear advertised
cfg$model rather than the runtime override the next session
is built with. The mx.client >= 0.2.0 floor is now
enforced at runtime, not only declared, so a stale installed copy fails
loudly instead of losing the rename to a best-effort
tryCatch.matrix_configure(model_badge =) key: "never"
(default, unchanged behavior), "non_default", or
"always". When a badge applies, deterministic post-turn
code — not the model — prepends a
⚡ <model> (<provider>) first line to each
reply and renames the bot to <name> ⚡ <model>,
so a room session that /model-switched to a paid provider
is visible on every message and in the sender line. In
"non_default" mode silence means the configured default:
the badge appears only while a switch is live and disappears on
/clear or a process restart (both reset the profile name
too). The display name is account-global, so with sessions in several
rooms the most recent switch wins; the per-reply badge line is always
room-accurate. display_name optionally sets the base name
the rename builds on (default: the user-id localpart). The profile
update goes through the new mx.client mx_set_displayname()
(>= 0.2.0), which refreshes a rotated token instead of silently
failing the rename.chat_send(), chat_poll(), and
chat_typing() instead of calling mx.client and mx.api
directly. Encrypted rooms stay on the mx.crypto path, and corteza still
reads invites, reactions, and encrypted events off the raw sync response
itself. chat.api is a Suggests, gated by the same check as
the rest of the Matrix stack, so it is only needed on hosts that enable
the channel.openai_compatible provider for generic
OpenAI-compatible endpoints (#149). Point corteza at
OpenRouter, DeepSeek, DeepInfra, a local proxy, or a corporate gateway
via a base_url config key or the --base-url
CLI flag (falling back to OPENAI_COMPATIBLE_BASE_URL). The
model id is passed through untouched and is required; the API key comes
from OPENAI_COMPATIBLE_API_KEY or
OPENAI_API_KEY, and a keyless gateway works with neither. A
missing endpoint or model is reported at session setup rather than at
the first request. turn() applies the endpoint through
llm.api::llm_base() scoped to each agent call, so a
mid-session /provider switch or a subagent on another
provider cannot inherit it. Requires llm.api >= 0.1.9.
See vignette("configuration").
Provider-native web search. The agent loop
enables llm.api::agent()’s server-side web search for the
hosted providers that support it (anthropic,
anthropic_claude, openai,
openai_codex, moonshot). On by default, no
Tavily key required; the model searches inside its own turn. Toggle with
--web-search / --no-web-search,
chat(web_search = ), or a web_search key in
.corteza/config.json. The Tavily web_search
tool remains the fallback for local models.
Matrix /model lists available models and
switches by number. Bare /model (or
//model where the client eats single slashes) replies with
a numbered menu instead of echoing the current settings: the configured
default, the live local Ollama inventory, and any hosted models declared
via the new matrix_configure(models =) key
("model provider" strings, since hosted providers cannot be
enumerated remotely). /model 3 switches the room session to
entry 3, setting model and provider together, so switching from a phone
client never needs a typed model name. The typed form
/model <name> [provider] is unchanged, and an
unreachable Ollama degrades the menu to the configured entries.
Private conversations and invites are restricted to
configured operators. The new operators key lists
the Matrix ids allowed a one-on-one conversation with the bot. A room
with one human is answered only when that human is an operator; everyone
else gets silence rather than a mention-gated session. Invites are gated
on the same list, read from the inviter in the stripped
invite_state, and an invite whose inviter cannot be
determined is refused. Group rooms are unaffected. Leaving
operators unset preserves the previous behavior.
Reply gating counts humans, not members. Rooms
whose only non-bot member is one human are answered without a mention;
rooms with two or more humans require a mention (replies count, since
clients put the replied-to user in m.mentions) or an open
engagement window: after the bot answers someone, that person’s plain
messages keep getting replies for 5 minutes, ending early when they
address someone else. Other bot accounts are declared via the new
matrix_configure(bots =) parameter; their messages always
require a mention. Room membership is cached per session and refreshed
on unknown senders or every 10 minutes, replacing the boot-time DM
snapshot that left rooms stuck on stale gating after membership changed.
With bots unset, behavior matches the old member-count rule
minus the staleness; during a members-API outage the gate fails open for
human senders instead of going silent.
Non-triggering messages are ingested instead of
dropped. In a room with more than one human, a message that did
not warrant a reply was marked seen, given a read receipt, then dropped
with a bare next: never appended to the session’s history
and, because it was already marked seen, never reconsidered. The agent
genuinely never saw other people’s non-mention messages in a busy room,
even though it had “read” them. Gated messages are now appended to
history as context; the reply gate and its bot-loop protection are
unchanged, only ingestion is. Turns are attributed with a
[sender] prefix in rooms with more than one human (both the
live poll and the startup backfill), and known bot senders are labeled
even in one-human rooms, so the model can tell speakers apart. A
lone-human DM is unchanged.
Rooms load project context into the room system
prompt. matrix_new_session() passed a non-NULL
system to session_setup() with
load_project_context = FALSE, so Matrix bots ran without
the project briefing, agent context files, .corteza
context_files, and package tool docs that other channels
receive. The new matrix_room_system() composes the Matrix
identity header with load_context() for the room
cwd.
Archives are built from a Matrix-visible event ledger,
not from provider history. History holds tool calls and tool
results that were never Matrix events, so a restarted process (which
rebuilds history from the server’s visible messages) could not align
with what it had archived, and tool-using rooms re-archived their whole
backfill on every restart. Sessions keep a transcript of
{event_id, role, content} and a queue of pending events,
written where events are seen or sent and drained once archived.
Archival keys on Matrix event ids, recognized against a bounded per-room
tail of already-archived ids. Transcripts no longer contain tool
turns.
Archive progress is persisted per room instead of tracked
in memory. matrix_archive_session() used
ingested_through, an index into the in-memory session
history, which reset on every restart while startup backfill refilled
history from the server. Each restart therefore re-archived the
backfilled tail. Progress is now an ordered key tail persisted per room
under CORTEZA_STATE_DIR, advanced only after a successful
ingest, so a failed archive retries rather than loses turns.
Archives are keyed by room id, not display name.
Room display names and avatars are mutable Matrix state, so archived
transcripts use the global Matrix room id for title and
source. The room name is retained only as archive-time
metadata, which keeps renames from splitting one chat into multiple raw
archive identities.
CRAN release consolidating the 0.6.9.1-0.6.9.8 development cycle plus a runtime-hardening batch. Last CRAN release: 0.6.9. Per-cycle detail follows.
matrix_run_init() / matrix_run_step(), with
the channel delegating session and transport plumbing to
mx.client.anthropic_claude provider (Claude on a
subscription via OAuth), requiring llm.api (>= 0.1.8).fetch_url/web_search,
now bounded by curl’s own timeout) are no longer wrapped in an R-level
setTimeLimit; the residual flush moved to
skill_run entry so a validation/dry-run early return can’t
leak a queued interrupt (#142, #139)./compact survives provider-native
history (Anthropic, OpenAI chat, and role-less OpenAI Codex
Responses entries), including tool calls and outputs./compact now renders Anthropic, OpenAI chat, and
role-less OpenAI Codex Responses history safely, including tool calls
and tool outputs. This prevents provider-native history entries from
wedging compaction.
Matrix documentation now matches the
anthropic_claude provider default and documents the
optional crypto polling context.
anthropic_claude provider, which drives
Claude on a Claude subscription (OAuth) via llm.api instead of an API
key. Added to matrix_configure()’s provider whitelist and
routed through the Anthropic message shape for interrupt-time
tool-result repair. Requires llm.api (>= 0.1.4.3).matrix_run_init() (build the session registry, catch up on
invites, backfill history, init E2EE) and
matrix_run_step(state, timeout) (one poll-and-reply
iteration). matrix_run() is now a thin wrapper over them.
Lets an external scheduler own the main process and interleave the
Matrix poll with its own work. No behavior change for
matrix_run().mx.client package instead
of hand-rolled plumbing over mx.api. Behavior is unchanged;
~37 fewer lines in the adapter.run_r/run_r_script semantics, and that the
bash tool makes corteza a general-purpose agent rather than R-only.tools::R_user_dir() roots to tempdir under
R CMD checkPR #96 already redirected R_USER_CACHE_DIR for the test
run (so saber’s transitive briefs writes landed in tempdir), but corteza
itself writes to all three tools::R_user_dir() roots —
cache (saber briefs, /last response),
data (session transcripts via R/paths.R,
matrix state), and config (matrix.json). Session tests were
still leaking files under
~/.local/share/R/corteza/agents/…/sessions/*.jsonl, which
would have tripped CRAN’s “checking for new files in some other
directories” NOTE on BDR’s donttest box even when local
tinypkgr::check() reports Status: OK. The
suite-level redirect in tests/tinytest.R now sets all three
(R_USER_CACHE_DIR, R_USER_DATA_DIR,
R_USER_CONFIG_DIR) to tempfiles for the check run; end-user
behavior is unchanged.
Patch release batching the 0.6.7.1 through 0.6.7.12 dev cycles. Per-PR detail is preserved in the dev-marker sections below.
chat(provider = "ollama") followed by an LLM-triggered
spawn_subagent was silently producing children on
Anthropic: the default tool_executor in
.make_tool_handler() called call_skill() with
no ctx, so tool_spawn_subagent() saw
ctx$session = NULL and subagent_spawn() fell
through to getOption("corteza.provider"). The executor now
passes ctx = list(session = session), so provider, model,
cwd, plan_mode, and
archival_depth all flow through to the child.
A single unbounded tool result (e.g. a bash command that
printed 57k lines) was flattened straight to the model and mirrored into
history, blowing the next call past the provider token limit;
/compact then couldn’t recover the session. Tool results
now pass through a universal guard (admit_tool_result())
that caps oversized output to a marker plus a recoverable handle, and
/compact elides giant message bodies so it can rescue an
already-wedged session.
chat()
crash on a model-less sessionA chat() started without an explicit model
no longer crashes after the first turn. The per-turn context meter
called context_limit_for_model() with a NULL model and
errored on the table subscript; it now falls back to the default limit
(and resolves a model-less session to the provider default for
display).
Packaging-only pass ahead of the 0.6.8 release: applied a deferred
rformat reflow, generated the user_interrupt_marker man
page, refreshed cran-comments.md, and turned the skills
vignette’s clone example into a
<your-skills-repo-url> placeholder so it carries no
dead link. No code behavior changes.
A turn interrupted with Ctrl+C / Esc now leaves the same “stop and ask the user what to do instead” instruction in history as an explicit deny, so the LLM checks in on the next turn rather than quietly continuing. Previously the interrupt marker was terser than the deny marker even though the approval prompt’s Esc/Ctrl+C hint routes through the interrupt path.
default_provider_model() now delegates to
llm.api::provider_default_model() instead of corteza’s own
provider-to-model table, which had drifted. corteza tracks llm.api’s
picks going forward (e.g. openai and moonshot
defaults shift to llm.api’s current choices). No change when you set a
model explicitly.
serve() no longer hands the subagent tools
(spawn_subagent, query_subagent,
collect_subagent, list_subagents,
kill_subagent) to MCP clients by default. A spawned
subagent runs its own agent loop and spends autonomously on the host’s
LLM credentials, so an unattended MCP client could otherwise trigger
unbounded cost it never sees.
Enable with subagents.expose_over_mcp (config, default
FALSE) or serve(expose_subagents = TRUE). When enabled,
cumulative subagent spend over MCP is capped:
spawn_subagent/query_subagent are refused once
spend crosses subagents.mcp_spend_cap_usd (default $5.00;
<= 0 disables) or an optional
subagents.mcp_spend_cap_tokens for providers that don’t
report cost. The cap reads the same meter as /spent; the
in-process chat()/CLI loop is unaffected.
/spent (alias /cost) reports the
approximate USD spent this process run, in both chat() and
the CLI. Each turn accumulates the cost scalar that llm.api
0.1.4 returns, plus token counts. When a model is absent from llm.api’s
price snapshot its cost is unknown, so totals are shown as a floor
rather than a precise figure.
Spend is process-lifetime: /clear no longer zeroes the
tally, it closes the current conversation and opens a new one, so
/spent itemizes each conversation between clears with a
grand total. /clear now also kills any live subagents (a
fresh conversation leaves none behind); their spend is retired into the
run total, shown as a separate process-level subagents line that keeps
counting an agent after it is killed. Resumed prior-run spend is not
loaded from disk.
The CLI no longer runs tools in a separate callr worker.
chat() and the CLI now drive one shared loop
(run_repl_loop) in a single R process, executing tools
in-process via corteza::turn().
.subagent_registry per session: a model-spawned
subagent shows up in /agents, and a /spawn-ed
one is reachable by query_subagent./flush moved into the shared loop. Per-surface
differences (rendering, help text, input reader) ride through injected
hooks, not forked logic.chat() gains the per-turn context meter and
auto-compaction the CLI already had./r, !) and the
foreground bash tool moved off blocking
system2() onto interruptible processx::run(),
so an interrupt returns to the prompt instead of killing the
session.worker_dispatch, worker_tool_list,
cli_worker_spawn, cli_worker_drain_events).
worker_init() stays exported: subagents call it across the
callr::r_session boundary to set cwd and register
skills.Hitting Ctrl+C (CLI) or Esc (RStudio chat) after a multi-tool turn no longer loses the tool calls that completed before the interrupt. Previously the next turn / next CLI invocation saw the original user prompt, an interrupt marker, and nothing else.
Imports: llm.api (>= 0.1.4) so agent()
exposes the history_callback parameter.
R/turn.R uses it to mirror the in-progress provider-native
history into the live session env as each assistant message and each
tool result lands. With the callback wired,
turn_session$history reflects the latest snapshot even when
an interrupt unwinds the turn mid-flight.repair_interrupted_tool_history() (with the
surrounding apply_exit_marker()) synthesizes
provider-correct tool_result blocks for any tool_use issued during the
current turn that never got matching results. Wired into chat()’s
interrupt and corteza_user_deny condition
handlers. Anthropic’s “every tool_use needs a tool_result” requirement
no longer 400s the next chat() API call after an interrupted multi-tool
turn.dump_completed_tools_summary() walks the
per-turn history slice and appends a text summary of completed tool
calls to the persistent flat-text session$messages before
the interrupt / denial marker. Wired into both CLI exit handlers
(inst/bin/corteza). The next CLI turn’s
api_history rebuild now carries the work the LLM
accomplished before the interrupt (“[ran tool_name(…) -> result]”
entries instead of nothing).on.exit guard around the archival ownership
transfer. An inner guard inside archival_archive_turn()
kills the holder subagent if anything between spawn and the explicit
transferred <- TRUE unwinds; an outer guard in
maybe_archive_turn() covers the parent-history-collapse
window. Either failure cleans the orphan instead of leaving it sitting
in .subagent_registry with no parent reference.subagent_query() / the query_subagent tool
gain a return_name argument. When set to a name (or
.h_NNN handle) the child left its result bound under, the
child resolves it after the turn and ships the value back; the parent
stashes it in the handle store and appends a
[stored as .h_NNN] block to the reply. A large structured
result is then referenced by name in a later run_r instead
of being inlined into the parent’s context, mirroring how
run_r already returns large values. The async
(wait = FALSE) path captures the name at query time and
applies it on collect.skill_install() clones GitHub repos via
processx::run() (already an import) instead of
system2(), with a nonzero git exit surfaced as a clear
error. The clone is extracted into an internal git_clone()
helper so its status handling is unit-testable against a local
repository, no network required.find_break_point() (text chunking) replaces two
backward character-scan loops with a two-tier regex: last newline, else
last whitespace (tabs included). Behavior preserved; added regression
tests for newline-beats-later-space and tab handling.Patch release batching the 0.6.6.1 through 0.6.6.20 dev cycles plus the out-of-band “Deny aborts the whole turn” change. Per-PR detail is preserved in the dev-marker sections below.
corteza_execute_in_chat() / _retain() now
expand the current line to the full top-level R statement before
sending, matching RStudio’s built-in Ctrl+Enter behavior. Before,
hitting Ctrl+Enter on the first line of
lm(y ~ x,\n data = df) sent only the unclosed first line
– annoying outside chat(), and broken inside
chat() where /r lm(y ~ x, failed to parse.
Buffer is parsed with keep.source = TRUE; falls back to
single-line when the buffer has a syntax error elsewhere or the cursor
sits on a blank / comment line outside any expression.chat() and the CLI /r handlers now read
continuation lines with a + prompt until the expression
parses, mirroring R’s built-in REPL continuation. Capped at 100
continuation lines to keep a stuck parse from blocking the REPL.handle_eval_env() used to skip reassignment when the
.h_NNN symbol already existed in globalenv, so rebinding a
handle id in the store left the old globalenv copy in place (codex
repro’d: tool_run_r('.h_001') returned the previous
snapshot after the store was rewritten). Now
handle_eval_env() assigns unconditionally and removes
globalenv bindings the package previously created that are no longer in
the store, via a new .handle_managed registry.
clear_handles() also sweeps the managed bindings out of
globalenv.session_id() ignored its caller’s agent and only collision-
checked the default agent’s transcript dir. With the docker-style
~6000-name pool, a non-main agent could mint a name that already existed
in its own store and silently reuse the transcript.
session_id() now takes agent_id, threads it to
.session_name_exists(), and the collision check now
consults both the transcript file and the in-store metadata for
that agent.ctx$path, so
tools::file_ext() returned "" and the addin
routed the line as “other” – inside chat() that meant the LLM saw raw R
code instead of a /r ... invocation. Untitled buffers are
now treated as R, matching RStudio’s built-in assumption.corteza_execute_in_chat() (plus
corteza_execute_in_chat_retain() for Alt+Enter) reads the
current line / selection from RStudio’s source editor and sends it to
the console, auto-prefixing /r for .R files
and ! for .sh / .bash files when
corteza::chat() is the active REPL. When
chat() is not running, no prefix is added – the addin is a
superset of RStudio’s default execute-line behavior, so binding it to
Ctrl+Enter doesn’t break normal R scripting outside chat.chat() now sets
options(corteza.chat_active = TRUE) on entry and clears it
on exit; the addin reads this flag to decide whether to prefix.rstudioapi added to Suggests. Addin file lives at
inst/rstudio/addins.dcf so RStudio picks it up after
install.Setup: in RStudio, open Tools -> Modify Keyboard Shortcuts, pick “Addins” in the dropdown, and bind Ctrl+Enter (and optionally Alt+Enter) to the two corteza addins. Override the built-in “Run current line/selection” mappings.
<- assignmentstool_run_r() was advertised as “Execute R code in the
session’s global environment”, but PR #36 (Phase 5 of CLI/worker split,
2026-04-21) silently changed it to evaluate in a fresh child env of
globalenv. Ordinary <- and = assignments
landed in the child env and disappeared when the call returned; only
<<- walked up the scope chain and persisted. Reported
2026-05-20.handle_eval_env() now copies handles INTO globalenv
(under their hidden .h_NNN names that ls()
filters out by default) and returns globalenv itself.
tool_run_r() evals in globalenv, so <-
writes the the right place and survives between calls.test_tool_impl.R and
test_handles.R exercise multi-call persistence with both
<- and =.U+1F7E8) laid out in a brick pattern: adjacent rows offset
by an odd number of cells so the kernels stagger like masonry instead of
stacking in straight columns. No ANSI escapes – the emoji is its own
colour, supported across every modern terminal.kimi-k2.6 for gpt-4o shifted
the right edge and broke the brick offset.0.6.6.17 -> v0.6.6.adjective_surname
(e.g. boring_wozniak) instead of UUIDs. Wordlists are the
Docker moby/moby adjectives plus ~60 scientist surnames. Collisions
retry up to 10 times before falling back to a hex suffix.corteza::chat() now prints a
session adjective_surname line below the banner, matching
the CLI’s layout.chat() and the ~/bin/corteza CLI now open
with a gold brain-corn silhouette rendered in 256-color ANSI, with the
corteza version, active model, provider, tool count, and
/help / /quit hints embedded directly inside
the kernels. Replaces the prior single-line
corteza chat | model @ provider header. Uses 256-color
index 220 (gold) for compatibility with terminals that don’t advertise
true-color via COLORTERM.corteza_startup_banner() in
R/banner.R – template-driven, so future tweaks to the
silhouette only touch one constant.! <cmd>
shell-line in chat() and CLI! <cmd> prompt prefix runs a shell command
locally (bash on unix, cmd.exe on Windows), prints output, and stages it
for the next LLM message. Matches the Claude Code / codex !
convention. The space after ! is required so prompts that
legitimately start with ! aren’t captured. Output is capped
at 4000 characters in the staged version (with a truncation note); the
on-screen output is full./r <expr> is now available in the CLI too –
previously chat() only. Same staging behavior: visible result prints
inline, gets queued for the next LLM message, with an oversized printed
result swapped for str() of the value.run_bang_shell() and
run_r_eval() live in R/chat-slash.R so both
surfaces use one source of truth for the local-eval + cap logic.task_create(tasks) /
task_update(index, status) LLM tools (Claude-Code-style
TaskCreate pattern). The list lives on the session, is injected into
each turn’s system prompt as numbered
[ ] / [>] / [x] / [-] lines, and is shown to the user as
a compact summary whenever it changes during a turn. Status is
pending, in_progress, completed,
or cancelled. Promoting a task to in_progress
auto-demotes any other in_progress task to
pending so the “one active at a time” invariant holds
without rejecting valid edits..make_tool_handler() (R/turn.R) rather than
the normal skill executor, so the CLI’s callr-worker dispatch can’t
strand mutations in the wrong process. The intercept fires before
dry-run, policy, and approval – task-list updates never prompt the
user./tasks (and /tasks clear) slash commands
in both chat() and the terminal CLI. /clear
wipes the task list along with the conversation, matching the “fresh
start” intent.session_new, session_save,
session_load) so it survives CLI restarts and
chat(session = ...) resumes./plan mode, which is a one-shot
research-and-propose flow; the task list tracks ongoing progress for
multi-step work without changing the policy gate.model is NULL, corteza::chat()’s
startup banner and per-turn “Thinking with …” line route through
resolve_provider_model(provider, model) instead of falling
straight to the literal “(provider default)” placeholder. With
config$provider = "moonshot" and no explicit
model, the banner now reads
kimi-k2.6 @ moonshot, matching what the terminal CLI
already showed. Unknown providers still degrade to the “(provider
default)” string.cli_approval_lines() takes a new
deny_label argument (default "Deny").
corteza::chat() passes "Deny (Esc)"; the
terminal CLI passes "Deny (Ctrl+C)". The key doesn’t
literally cancel the readline prompt itself, but it cancels the
in-flight turn, which is the user-facing escape hatch users want to know
about. Deny still raises the corteza_user_deny marker;
Esc/Ctrl+C during a turn raises an interrupt marker. The LLM treats both
as “stop and check with the user” – the wording of the interrupt marker
is less directive than the deny marker, worth tightening in a
follow-up.R/render-md-ansi.R adds
render_md_ansi(text) (internal): strip markdown syntax
markers and apply ANSI styling for terminal display, leaving the raw
markdown source intact when the output isn’t a TTY (NO_COLOR, piped to
file) or when the user opts out via
options(corteza.markdown = FALSE). Both
corteza::chat() and inst/bin/corteza now route
assistant responses through it, so the two surfaces stay in sync.
Replaces the smaller bespoke renderer that previously lived in
inst/bin/corteza. Handles H1/H2/H3 headings,
**bold**, *italic* / _italic_
(modern terminals interpret as italic), `inline code`,
fenced code blocks (dim, 2-space indent), - /
* bullets, > blockquote lines, and
[text](url) links.[user declined: ...] tool result that the LLM
saw and treated as feedback, planning the next call – which forced users
to mash “3” through cascades of dependent tool calls. The next turn now
starts with a history marker that names the denied tool and tells the
LLM to stop and ask the user what to do instead, rather than retrying or
planning a workaround./copy slash command copies the most recent
assistant response to the system clipboard via the optional
clipr package. Silent no-op until the first assistant reply
lands; prints a hint if clipr is not installed or the
clipboard isn’t available (e.g., headless Linux without xclip /
wl-clipboard).tool_run_r_script() switches its
callr::rscript() call from stdout = "|" to
stdout = NULL, with stderr = "2>&1"
unchanged. The old combination hangs indefinitely on Windows with CRAN
callr 3.7.6 when the child script errors via stop() — the
internal timeout never fires (r-lib/callr#313).
res$stdout is still populated by the
2>&1 redirect, so the return value and LLM-facing
text are identical. Can be reverted once we depend on a fixed callr
(>= the post-e93efd1 release).test_subagent_callr.R gate rationale corrected: that
gate is about per-test budget, not the callr bug; r_session
uses a separate code path and was empirically verified not
affected.skills_as_api_tools() (the chat() path) now applies the
same available() predicate filter that
schema_from_registry() (the CLI path) has always used.
Conditional tools — git_* when not inside a repo,
platform-specific shell tools — no longer show up in chat() while being
hidden in the CLI. Counts agree.turn_footer_line() defaults to the detected terminal
width (COLUMNS env, options("width")) instead
of a fixed 60-char line, so the ─ Worked for 3m 18s ────
separator reaches the right edge./status is now an alias of /context. Both
render the same block: a Codex-style header (corteza version,
model+provider, cwd, session id) followed by the context meter.system in bright
blue, tools in bright magenta, history in cyan
— so the bar maps visually to the breakdown rows below.New compact horizontal /context display answers the
two questions a user actually has: how full is context, and what’s using
it. Same in both corteza::chat() and the CLI:
Context 24.7K / 128.0K 19% compact 90%
[██████████..................................│.....]
system 22.0K 89%
tools 2.7K 11%
history 56
│ tick at
its cell position in the empty part of the bar.Dropped the “Project context still comes from saber::briefing() and saber::agent_context()…” paragraph — the bar already shows where the system budget is going, and the prose was longer than the data.
corteza::chat() or
~/bin/corteza turn, a dim footer line shows wall-clock
duration: ─ Worked for 3m 18s ────.inst/bin/corteza
only, so corteza::chat() couldn’t render the same
/status, /doctor, /config,
/diff, or /review output. They now live in
R/cli-helpers.R as internal package functions; both
surfaces call them. chat() gets all five commands plus
/last and /outputs./compact in chat() now uses the same
do_compact() the CLI does, instead of a separate inline
implementation. Both routes share one prompt and one chat-call
shape./last, /outputs) is
now session-scoped via session$sessionId rather than a
CLI-process global. Subagents and parents have isolated buffers;
/clear drops the outgoing session’s buffer./remember and /recall in the CLI are gone.
They called memory_store / memory_search /
strip_tags / parse_tags — none of which exist
in the package — and would error on use. /flush is alive
(rebuilt earlier this cycle as a real memory flush via
.run_memory_flush()) and stays.> prompt character. The bash hack used for line editing
now passes the prompt to read -e -p so readline owns the
cursor instead of the prompt being a cat() that readline
can’t see. ANSI color escapes in the prompt are wrapped in
\001 \002 so readline’s column math stays correct.Choice [1]: dropped the
[1] shell convention; the (Enter) hint already
lives on choice 1 itself, so the bracket was redundant.corteza::chat() and the CLI.
Two entries with two contracts:
/paste [optional text] — explicit “paste anything”
mode. Collects every line verbatim (logs, code, paths with literal
trailing \, etc.) until /end on its own line
or Ctrl+D.\ — drops
into bash-heredoc-with-continuation mode mid-line, seeded with what you
already typed. Keep ending lines with \ to continue; the
first line without a trailing \ is final and gets included.
\\ at end of a line stays literal. /end and
EOF also terminate./ is not
reinterpreted as a corteza command. No “Paste mode…” banner —
IYKYK.Reason
section (gate text + Policy: + Model route:)
is gone; Access collapses to a single line that names the
path or command (e.g. Write to CLAUDE.md,
Run command in /home/troy/corteza); the redundant
Path: detail line above Access is suppressed
when it would just repeat the same path. Choices 1 and 3 carry key
hints: (Enter) and (Esc).● User replied: summary paraphrasing the chosen action
(e.g. “Allow writing to CLAUDE.md once”).User replied: summary via ANSI cursor-up + clear-down.
In corteza::chat() running under RStudio (whose console
doesn’t honor cursor-position escapes) the block stays in scrollback
with the summary appended below.replace_in_file and write_file now attach
a unified-diff payload to their MCP result. The CLI and
corteza::chat() render it inline in the tool-call output as
⎿ Added N, removed M followed by one row per kept line
(NNNN +|-| content with red/green color) instead of the
prior N lines in Xms summary. The LLM-facing result text is
unchanged — the diff is for the human reading the terminal.replace_in_file →
“Update”, write_file → “Write” (matches the inline-diff
phrasing).diff -u. If
diff isn’t on PATH the tool degrades to a
one-line size summary rather than failing. Diff payload is capped at 200
lines / 20000 chars with a [diff truncated: N more lines]
marker so big writes don’t dump thousands of lines into chat
scrollback./diff slash command’s output is also
ANSI-colored.ansi_supported() / ansi_colors() in the
package are now the single source of truth for both
corteza::chat() and the ~/bin/corteza CLI.
RStudio’s R console (which is not a tty) is now correctly detected as
ANSI-capable, and NO_COLOR / FORCE_COLOR
overrides work in both surfaces.corteza::chat() and the
~/bin/corteza CLI catch the R-level interrupt.callr worker subprocess, the worker is sent
SIGINT so the in-flight tool (e.g. a long bash or
run_r call) actually stops. The worker is recycled only if
it doesn’t return to idle.[Interrupted by user before completing.] marker so the next
turn’s model sees that the prior turn ended early.corteza::chat() is interrupted by Esc
(RStudio’s console intercepts Ctrl+C for copy). In the terminal
~/bin/corteza CLI it’s Ctrl+C — terminals
send raw ^[ for Esc, which is not a signal.load_saber_briefing() now wraps
saber::briefing() in suppressMessages() so the
briefing text no longer leaks to the user’s terminal every time a
subagent calls session_setup().subagent_query(id, prompt, wait = FALSE) fires a prompt
and returns the canonical id immediately; the parent collects the reply
later with subagent_collect(id). A subagent can carry only
one in-flight async query at a time — both wait paths refuse to stack on
top of a pending call./queue <id> <prompt> (fire) and
/collect <id> (drain). /agents
distinguishes idle vs busy.collect_subagent mirrors the CLI
surface.agents/subagent-<id>/sessions/<id>.jsonl,
matching the shape archival holders already use. Disk space is cheap;
context is expensive. Compaction (below) can rewrite the in-memory
history without losing anything on disk.inst/bin/corteza
into package code so chat, the CLI loop, and subagents share the same
budget math: context_limit_for_model(),
format_tokens(), estimate_text_tokens(),
estimate_history_tokens(),
estimate_tool_tokens(),
estimate_live_context_tokens(),
context_usage_pct(). Also
default_provider_model() for resolving the model identity a
subagent will actually run with.subagents.context_compaction config block. Defaults
to mode: inherit_strict with compact_pct: 75.
Working subagents compact their own in-memory history after each turn
when usage passes the threshold; the on-disk transcript stays intact.
Archive holders are skipped via a kind marker stamped by
subagent_seed_history()./agents now shows model, age, live context (tokens /
limit), cumulative input/output tokens, and cumulative cost per
subagent. Live tokens are computed via a child-side
r_session$run() call per /agents invocation;
busy children show ctx ?. Cost is captured when the
provider returns it; shown as ? otherwise (most
non-Anthropic providers).plan_mode flag. When on, the LLM is
told to research and propose rather than act: the policy engine denies
write/exec tool calls (write_file,
replace_in_file, bash, run_r,
run_r_script), and an exit_plan_mode tool is
injected into the tool list. A successful exit_plan_mode
call flips the flag back off so the LLM proceeds with the work./plan slash command in chat() and the
corteza CLI: bare toggles, /plan <task>
enables and submits the task as the next prompt.plan_mode from
parent_session so spawning a child can’t launder a write
through plan mode.archival config block. Default off — CRAN users see
no behavior change. When enabled, finished turns collapse into a fresh
subagent that holds the full transcript, while the parent’s history
keeps a compact {summary, subagent_id} block. The LLM sees
live subagents in its system prompt and picks
query_subagent vs spawn_subagent as a normal
tool decision.[Max turns reached] is no longer a dead-end string:
with archival on, the full transcript persists in a subagent for
follow-up via query_subagent.archival.trigger.depth_cap).transcript_append infra under
agents/subagent-<id>/sessions/<id>.jsonl.subagent_seed_history,
subagent_turn_set_id.archival.enabled requires
subagents.enabled. No silent overrides.vignette("retroactive-extraction") for the full
opt-in surface, design notes, and known limitations./spawn now parses --model,
--preset, and --tools in any order. Matches
the MCP tool_spawn_subagent surface.investigate,
work, minimal). Default is
investigate (read/search only).subagent_spawn(tools = character(0)) is now a
documented configuration: spawns a holder with no active tools. Used by
the archival runtime to create transcript-only subagents.resolve_subagent_tools() honors
config$subagents$default_tools when neither preset nor
tools is supplied (was silently bypassed before).file("stdin") rather than stdin() (which reads
from the script source under Rscript -e), echo the client’s
protocolVersion in the initialize response
instead of hardcoding it, and serialize empty
capabilities.tools as a JSON object ({})
rather than an array ([]). Thanks to Grant McDermott (@grantmcdermott,
#62).configuration vignette covering config files and
precedence, CLI flags, the full JSON config-key surface (core, context,
safety, skills, subagents, channels, etc.), slash commands, MCP server
setup (stdio and socket transports), session tuning, systemd service,
and environment variables. Thanks to Bob Rudis (@hrbrmstr, #54).Live context indicator now reflects the actual size
of the next prompt (system + tools + message history) rather than
cumulative billed API tokens. Old behavior counted up forever —
/clear and /compact had no visible effect on
the indicator. Status line label updated from Usage to
Live context./context now prints live usage and the auto-compact
threshold alongside the loaded context files.resolve_provider_model(). Legacy kimi-k2 now
resolves to kimi-k2.6 for moonshot. Moonshot’s chat
temperature is forced to 1 (their API rejects other values on
kimi).session$compactions ->
session$compactionCount, matching
memoryFlushCompactionCount. Existing on-disk sessions show
0 compactions until the next compact; display-only._read_prompt_via_bash now prints the prompt from R and
captures input through a tempfile. Previously relied on
bash -p plus system2(stdout = TRUE), which was
fragile on terminals that mixed the prompt into stdout.First CRAN submission.
Eight-phase refactor of the command-line interface so its subprocess no longer speaks MCP internally.
callr::r_session worker.
Tool dispatch goes through corteza::worker_dispatch()
directly; no JSON-RPC, no tools/list handshake, no per-call
envelope on the CLI-to-worker path.serve() remains a spec-compliant MCP server for
external clients (Claude Desktop, VS Code, mcptools).
Public MCP behavior is unchanged.R/registry.R.
chat(), serve(), and the CLI all read from
.skill_registry. No state duplication.cli_worker_spawn(), worker_init(),
worker_dispatch(), worker_tool_list(),
cli_worker_drain_events() exposed with
@keywords internal so the callr session can reach them as
corteza::*.corteza_tool_error
condition class carries tool name, args, original class, and message
across the worker pipe.R/subagent.R) also use
callr::r_session instead of spawning
corteza::serve() children. Same architecture: one
persistent worker per subagent, direct tool dispatch, no MCP.R/schema.R with schema_from_fn() and
register_skill_from_fn(). Tool definitions are derived at
runtime from formals() and the package’s .Rd
files via tools::Rd_db(). Replaces 20+ hand-written
skill_spec(params = list(...)) blocks with one-line
registrations.(type) parenthetical in
@param docs ((character),
(integer), (logical),
(character vector),
(character; one of: a, b, c) for enums).schema_from_registry() produces the
Anthropic-API-shaped tools payload the CLI sends to the model — in the
CLI’s own process, not round-tripped through the worker.inst/tinytest/test_tool_schemas.R asserts every formal
maps to a @param entry and vice versa. Drift between doc
and signature fails the test suite.register_skill_from_fn() accepts an
available predicate; schema_from_registry()
filters tools whose predicate returns FALSE. Git tools gate
on .git; web search gates on TAVILY_API_KEY.
18-20% fewer tokens in the system prompt for a bare environment.tool_run_r() wraps non-scalar or over-threshold values
with with_handle(). The LLM gets a str()
summary plus an opaque .h_NNN handle instead of a flood of
printed output.tool_read_handle(handle, op) for subsequent
inspection (str, head, summary,
print). Handles are addressable by name in later
run_r calls.tool_call,
tool_result, timings) to stderr. CLI drains them between
calls.--trace flag (and
options(corteza.trace = TRUE)) pretty-prints the events
inline via printify::print_step() /
printify::print_message().NO_COLOR honored,
FORCE_COLOR overrides, classic Windows consoles fall back
to plain text.bash to an absolute path
(Rtools first, Git Bash fallback) so
C:\Windows\System32\bash.exe (the WSL launcher stub) cannot
intercept commands.cmd shell tool when no real bash is
found.normalizePath(winslash = "/")
consistently.callr (for the worker transport),
printify (for --trace rendering).codetools, curl,
jsonlite, llm.api, processx,
saber.mx.api, tinytest.