ask tool (core) - a model-facing tool that pauses the run and routes a decision or permission request to an answerer, so sub-agents and team stages get decisions mid-run instead of returning open questions. New src/replio/tools/ask.py (register_ask_tool, registered in Engine._init_tooling like delegate), schema question (required) plus context/options/target. target='human' (default) prompts the operator at the terminal via a new ReplUI.ask free-text prompt (? Answer:); a sub-agent's ask is prefixed with its sub_<...> session name because delegation and team stages run synchronously in-process while the terminal is free. target='lead' answers through a bounded non-streaming consultation with the delegating engine's model (_lead reference plus _ask_ui propagation in _new_sub_engine; the prompt includes the sub-agent's delegated task); a root engine has no lead and falls back to human. Headless roots with neither (run/serve/jobs) get an Error: ask has no one to answer ... result and the run continues without pausing - the async "pause a running job and resume on reply" variant stays tracked separately (TODO). The ask itself is not additionally gated: tool_permission.ask defaults to allow (operators can tools.deny: ["ask"]); the question (tool args) and answer (tool result) persist in the asking session and the call lands in the session permissions audit array. NullUI/HeadlessUI gain an ask stub returning None. Docs synced (tools.md Asking the human or the lead, config.mdtool_permission, security.md, swarm.md, jobs.md, AGENTS.md). tests/test_ask.py (9: schema/registration, human answer/empty-cancelled/context-options, no-channel error, lead consultation + fallback-to-human + root-fallback, sub-engine _lead/_ask_ui inheritance, full-loop persistence) and the schema-names test in test_engine.py
/connect is provider-only now - it connects a provider and stores its API key (and any custom base URL) in the global providers.json registry, and never touches the model (picked separately with /model). Bare /connect shows an interactive picker of known providers (core + plugin, (key) markers) accepting a number, a name, or a URL; /connect <name> presets a known provider's default base URL and prompts only the API key (API key [<stored>]: re-enters a missing or stale key, Enter keeps it); /connect <url> detects a known host or a plugin provider's default URL, otherwise creates a named custom provider with the name derived from the host (https://llm.acme.example/v1 -> acme-example) - /connect <url> <name> overrides the name. Stored custom providers appear in the picker for reconnecting. All forms keep the connect_check probe (GET /v1/models) with the Save anyway? path, write provider/base_url into config (model untouched, no model approval here anymore), re-init the provider, and print the Connected to <provider> (<base_url>) result plus a /model hint. _resolve_provider_factory now resolves a registry-named custom provider as OpenAICompatibleProvider so its stored key is found on reload. Docs synced (providers.md Setting up/Auto-detection, commands.md, config.md registry base_url semantics, plugins.md provider default-URL matching, testing.md). Tests: connect suite rewritten for the provider flow (18 cases: picker incl. stored-custom reconnect, named preset + stored-key keep/re-enter + unknown-name, URL known-host/plugin default match/name derivation/override/bare hostname, probe accept/decline, connect_check off, config model untouched)
Model refs unfold, gated on approval - a provider/model ref (e.g. opencode-go/deepseek-v4-flash) resolves to the provider, its default base URL, and the bare model wherever a model is set: config.model, /model <ref>, --model <ref>, and an agent type's model field (a type now pins provider and model together). resolve_model_ref in providers/registry.py unfolds only known providers with a default base URL. The resolved model must be approved (present in models.json): the REPL asks on load and in /model, /team run pre-checks the stages' type models and asks once, and headless runs auto-approve an explicit --model but deny a type/team-referenced model unless --approve-model is passed (replio run, replio jobs add, replio fleet config; jobs gain an approve_model field and the scheduler grants it, fleet config pre-approves into models.json). A denied ref sets an engine _provider_error that makes chat() return an error result, and a ref naming a provider without a key still switches but prints run /connect <provider>. Engine gains approve_models, unfold_ref, and _ensure_model_approved; _new_sub_engine/run_subagent/run_team gate type models. Docs synced (providers.md Model refs and approval, config.md, commands.md, types.md, jobs.md, fleet.md, testing.md). Tests: resolve_model_ref (7), engine unfold/gate/deny/grant/chat-short-circuit/subagent-gate/type-unfold/team-precheck, /model ref + prompt, CLI approval wiring, jobs approve_model round-trip
models.json reshaped to approved-model history - ModelEntry is now {provider, model, added_at, last_used} with no API keys or base URLs (those live in providers.json); ModelRegistry keys by (provider, model) (find/put/touch/remove, grouped kept, api_key_for dropped, no 0600), and old per-model-key entries are read for provider/model only - no migration, keys are re-entered via /connect. /model list groups approved models by provider with > for the active one and a (key) marker sourced from the provider registry, /model list --online takes base_url/api_key from the provider registry, /model <name> touches (provider, model), and the /connect picker shows [provider] model reusing the stored provider key. Docs synced (config.md, providers.md, commands.md, testing.md). tests/test_models.py rewritten (approved-history shape, old-shape dropped without migration) and test_commands.py connect/picker/model tests updated to assert keys on the provider registry
Provider registry - ~/.config/replio/providers.json stores active connections keyed by provider name: one API key per provider, and a custom base_url only when it differs from the provider's default (the class default applies otherwise). The engine now resolves the API key and a fallback base_url from this registry (Engine.providers, _reinit_provider/check_connection/list_models) instead of models.json, and /connect writes the provider entry alongside the model entry. File written 0600 when it holds keys, atomic replace, corrupt-file tolerant. ProviderRegistry/ProviderEntry in providers/registry.py. Docs synced (providers.md, config.md, security.md, commands.md, testing.md). tests/test_providers_registry.py (15 tests) plus test_engine.py registry-key and base_url-fallback coverage
Sequential team runs - Engine.run_team(team, task) executes a team stage-by-stage through the same in-process sub-engine as delegate. Each stage builds a per-run brief (team name + original task, a ## Stage N result block per prior stage capped at 4000 chars with a truncation marker, the previous stage's handoff_note, the shared team memory, and the stage's task_hint), runs via run_subagent into a fresh sub_<ts>_<parent> session, and records the result. A failing stage stops the run. After the run the whole team run is summarized (seeded with the prior memory, via the same compaction summarizer as /compact; a per-stage fallback stores when the summarizer fails) and written atomically to .replio/teams/<name>/memory.md (new team_memory_path/read_team_memory/write_team_memory helpers in teams.py, mirroring the jobs .memory.md pattern), which the next run's briefs read back. _new_sub_engine/run_subagent accept a mode override so a stage's mode applies to its sub-engine while an empty stage mode inherits the caller (previously sub-agents always ran build); delegate keeps the build default. New TeamRunResult dataclass. /team run <name> <task> runs a team from the REPL (one line per stage, final result, memory path), registered in the /team subcommands. Docs in docs/teams.md (Running a team section) and docs/swarm.md. tests/test_team_run.py (brief builder, sequential execution + exact brief persistence + parent linkage, stage mode override/inheritance, stop-on-failure, unknown stage type, zero stages, memory write/seed/fallback, /team run command). Completed PLAN M1 Engine.run_team; the remaining sequential-runs TODO scope (persistent member sessions for recurring teams) moves to M3
Renamed persona to agent type across the product - the concept is a named agent definition bundling identity (persona), function, authority, capability, expertise, and archetype into one reusable profile, and type is the neutral umbrella that covers all of them without colliding with role (chat message roles in the session/API), function (OpenAI function calling), or profile (the existing permission-profile language). Breaking rename with no legacy aliases: personas.py -> types.py (AgentType / TypeRegistry), bundled_personas.json -> bundled_types.json, /persona -> /type, --persona -> --type, personas.json -> types.json, the persona field in jobs and team stages and the delegate tool argument -> type, and the plugin hook register_personas -> register_types. Existing personas.json files, --persona flags, /persona calls, and plugins using the old hook must be updated. docs/personas.md -> docs/types.md documents the six axes and the naming rationale. Tests updated across the suite (test_types.py, delegate, subagent, teams, jobs, fleet, plugins, tool-policy). Docs, README, VISION, PLAN, TODO, AGENTS, and the site synced
Bundled web plugin renamed - replio-core-websearch -> replio-core-web (it ships both search and page fetching, so the old name understated it). Manifest bumped to 0.14.0, default plugins config, tool/plugin docs, and the web-search-unavailable message updated. Existing plugins configs that named the old plugin must switch to replio-core-web
GitHub Pages website - site/ mkdocs project (mkdocs-material theme) plus a deploy-site.yml workflow that assembles the site from repo sources on push to main (Overview, Vision, Roadmap, Backlog, Changelog, and the reference docs minus use-cases/vs) and publishes the static build to a gh-pages branch, so the site is regenerated every release with no drift. The app stays zero-dependency, mkdocs is CI-only. README gains a site badge and link. TODO/PLAN "Docs site" items trimmed to the remaining ReadTheDocs part
code_test interpreter resolution - replio-core-dev now resolves a leading python/python3 in the test command (dev.test_cmd, default python -m unittest discover) to sys.executable, so the default suite runs on machines with only python3 (no python on PATH). Custom commands are untouched. Docs in docs/tools.md, tests in plugins/replio-core-dev/tests/test_tools.py (default + explicit-config resolution)
OpenCode Zen + Go providers - opencode (https://opencode.ai/zen/v1, default kimi-k3) and opencode-go (https://opencode.ai/zen/go/v1, default deepseek-v4-flash) as OpenAI-compatible providers sharing the OpenCode API key (registered via /connect like any provider, no env var). detect_provider() recognizes opencode.ai (/zen/go path picks opencode-go, otherwise opencode) so /connect auto-switches. Model refs accept the opencode/<model-id> / opencode-go/<model-id> conventions as well as bare ids - the prefix is stripped from the payload (_payload override). The model-list endpoints are an inventory, not an entitlement check: inference still needs the matching paid subscription. Docs in docs/providers.md (built-in table, detection, reasoning row), docs/config.md, README.md. Tests in tests/test_providers.py (defaults, detection, registry, endpoints, ref-prefix stripping). Provider externalization to bundled plugins is tracked in TODO/PLAN
Tool-use evaluation harness - replio eval runs task fixtures through the headless agent loop and reports tool-use metrics. New src/replio/eval.py defines the JSON fixture model (task prompt, provisioned worktree files, expected tool-name trace, declarative verifier with exact/must_include/avoid/max_calls/min_calls/args), the runner (each fixture gets an isolated temp worktree with its own .replio/, a throwaway engine with HeadlessUI(auto='allow'), and a cwd switch so relative tool paths resolve there), and per-fixture + aggregate metrics (tool-call accuracy, redundant identical calls, engine + tool errors, provider tokens). replio eval list / run (with --fixture, --provider/--model/--base-url, --compare, --output table|json) are wired through main.py + cli.py. Fixtures are discovered from the new bundled replio-core-eval plugin (via a new register_fixtures plugin entry hook, PluginManager.register_fixtures), ~/.config/replio/eval/*.json, and .replio/eval/*.json, merged by id with local winning. The bundled catalog ships five fs fixtures (read-file-lines, find-then-read, list-directory, grep-symbol, page-large-file). Eval defaults to read/list/web allow and edit/bash/mcp deny, overridable per fixture. Docs in docs/eval.md (linked from docs/index.md, docs/commands.md, docs/plugins.md, AGENTS.md), and docs/writing-tools.md evaluating-tools section now points at the harness. Tests: tests/test_eval.py (model, verifier, metrics, discovery, cwd isolation), tests/test_cli.py eval coverage, tests/test_plugins.pyregister_fixtures hook + failure marking, plugins/replio-core-eval/tests/test_fixtures.py
Project instructions file - per-worktree AGENTS.md (or any project_instructions name, "" disables) is auto-loaded into the system prompt as a leading system message, capped at 20000 chars, present files only. It composes with system_prompt and the mode instruction and applies to sub-agents too (they share the worktree). Docs in config.md and tools.md. Tests: test_modes.py (instructions_file_section loading/absent/disabled/default/truncation) and test_engine.py (injection, absent skip, unset skip)
run_command command allowlist - tool_permission.bash_allow (default []) restricts run_command to commands whose every chained segment (over &&/||/;/|/&) starts with an allowed prefix. Heredocs and multi-line commands are rejected outright. Matching commands fall through to the normal bash action, others are denied. Implemented as a per-invocation policy resolver on run_command: the engine now injects the merged tool_permission into resolvers that declare a _permissions argument (a general capability, not a run_command special case), so it composes with modes, tools.deny/tools.allow, and worktree escalation. Docs in config.md (new bash_allow section) and tools.md. Tests: exec-suite allowlist cases (prefix, chain, pipe, heredoc, multi-line, empty) plus engine-level allow/deny
code_test / code_lint / code_format wrappers - new bundled replio-core-dev plugin. Each runs the project command from a config key (dev.test_cmd default python -m unittest discover, dev.lint_cmd default ruff check ., dev.format_cmd default ruff format .), appends an optional target, and reports exit code + output. bash permission, cwd scoping, hard timeout clamp. Added to the default plugin list. Tests: plugin suite (unittest run, config commands, target append, failure/unknown/timeout/truncation, metadata)
git tool - new bundled replio-core-git plugin. Read-only git (status/diff/log/branch/show/rev_parse, read permission, worktree-scoped cwd) and git_commit (add/commit, edit permission, always ask-gated via its resolver). Both run git via subprocess argv (never a shell string) with a hard timeout. No push/merge/checkout/history rewrite. Added to the default plugin list. Tests: plugin suite against a temp repo (status/diff/log/branch/show/rev_parse, add/commit/all, missing cwd, permissions, aliases)
file_edit tool - new bundled replio-core-edit plugin. Targeted search-and-replace at a path (old -> new, optional count, 0 = all), with a difflib diff preview in the status line. edit permission, path scoping, aliases edit, param aliases file/old_text/new_text. Complements file_write for surgical single-hunk edits. Added to the default plugin list. Docs in tools.md bundled-tools table. Tests: plugin suite (first/replace-all/count/delete/missing/directory/not-found/empty/aliases/metadata/status)
New bundled plugins ship in the default plugins list (replio-core-edit, replio-core-git, replio-core-dev) - test_bundled_plugins.py asserts discovery, origin, tools, and the default config set
Default tool-result cap - tool_max_result_chars now defaults to 100000 chars (Anthropic ~25k-token guide, 0 = unlimited), so every tool result is bounded by default: the fs tools (file_read/list_dir/glob/grep), run_command, and MCP tool results are each cut at a line boundary with a trailing ... (truncated) marker. list_dir gains an entry cap via the new list_dir_max_entries config (default 200, 0 = unlimited) with a ... (showing first N of M entries) marker, so a large tree stays bounded regardless of name lengths and the model is told how many entries there are. Docs synced (config.md, tools.md, writing-tools.md). Tests: fs suite (entry cap + default-boundary truncation) and test_config.py defaults
Default tool names namespaced, old names kept as aliases - web_fetch merges the former open and fetch_page web tools into one canonical fetch tool (url or a web_search result id, offset paging), file_read/file_write replace read_file/write_file, and web_search stays canonical. All old spellings still resolve at call time (open, fetch_page, read_file, write_file, read, write, view, search, web, ls, find, bash, exec), so existing configs, /tool calls, and session logs keep working. Only the advertised provider schema changes (13 -> 12 canonical tools). The fs tool error hints now point at the canonical names, and the noise_tools default trims all three fetch spellings. Docs synced across tools.md, writing-tools.md, config.md, session.md, plugins.md, fleet.md, security.md, usage/programming.md, use-cases, and AGENTS.md. Tests updated for the new canonical set (test_engine.py, test_commands.py, test_cli.py) plus websearch-suite coverage for the merged web_fetch
Tool schema advertises canonical names only - ToolRegistry.schema_filtered() now returns one schema per registered tool and drops the alias entries (bash/exec, read/view, ls, find, search) from what is sent to the provider (20 -> 13 tool definitions per request). Aliases still resolve at call time, so /tool, /help, policy, confirm, glyphs, and the unknown-tool hint are unchanged. A model that still calls a dialect name gets it absorbed. Docs synced (tools.md registration table). Tests: test_tool_registry.py (canonical-only advertisement, denied canonical stays hidden even when an alias is allowed) and test_engine.py (build-mode schema is exactly the 13 canonical names)
Agent-loop cancellation and tool-dialect hardening - Ctrl-C now cancels the running turn instead of declining one tool or killing the REPL. ReplUI.confirm re-raises KeyboardInterrupt (EOF still returns False), and Engine._agent_loop catches it during streaming, tool execution, and retry sleeps, merging partial output, printing (cancelled), persisting state, and returning TurnResult(status='cancelled') so the REPL returns to the prompt (headless replio run exits non-zero). Unknown tool calls now return Error: unknown tool "X". Available tools: ... via new ToolRegistry.is_registered()/primary_names(), so the model can self-correct instead of retrying a hallucinated name. Web-search and fs dialect absorption: web_search gains the search alias, grep gains the find alias (its query -> pattern alias already maps the model's args), and open treats a URL string passed in id as the URL. Tests: test_agent_loop.py (cancel mid-stream/after-tool, unknown-tool hint), test_ui.py (confirm Ctrl-C/EOF), websearch suite (search alias, URL-as-id), fs suite (find alias)
Skills registry - Skill (name, markdown content, optional description/tags) and SkillRegistry in new src/replio/skills.py: skills are flat <name>.md files in the local .replio/skills/ and global ~/.config/replio/skills/ dirs plus the in-memory plugin layer (register_skills hook, registry.add_plugin({...})), precedence plugin < global < local. Engine.skills wires the hook on first access, /plugins install/update/uninstall refresh all three registries live, and /skill manages the catalog (list/show/new/remove, plugin skills not removable). Persona skills (previously inert) are resolved and injected into the sub-agent system prompt as a ## Skills section (_new_sub_engine) - missing skills skip silently, empty-prompt personas keep the section as their whole prompt - and the same section is appended for jobs with --persona (scheduler._build_engine, via the shared skills_section helper). Docs in docs/skills.md, linked from docs/index.md. personas.mdskills field now points at the live registry, and plugins.md hook contract updated. tests/test_skills.py (registry, section, command), test_subagent.py injection cases, test_jobs.py_build_engine injection, test_plugins.py skills hook against a real registry
PluginManager._bundled_dir() hardens its fallback for source checkouts - when the replio.plugins.bundled subpackage cannot be imported (e.g. running with PYTHONPATH=src instead of an installed package), it now resolves the repo-root plugins/ directory before the stale in-package path, so bundled plugins load without pip install -e .. test_plugin_suites inherits the fix. Docs note in docs/testing.md (absolute PYTHONPATH for the detached-fleet daemon)
Teams registry - shape-only named team pipelines: Team (name, ordered stages, description, tags) + TeamStage (persona, optional mode/task_hint/handoff_note, plain-string shorthand) in new src/replio/teams.py, and TeamRegistry with the same four-layer merge as personas (bundled < plugin < global < local, per-field with stages replaced wholesale). src/replio/bundled_teams.json ships the two pre-carved pipelines referencing the bundled personas - writing (researcher > writer > referencer > editor) and programming (planner > programmer > tester > code-reviewer) - with concrete task hints and handoff notes. Plugins contribute teams via the existing register_teams entry hook (registry.add_plugin(...), in-memory layer, reload(plugin_manager=None) re-applies like personas), and Engine.teams wires it on first access. /plugins install/update/uninstall refresh both registries live (_refresh_registries). /team manages the catalog (list with tag filter + stage chain, show, new/override, remove, bundled remove rejected, origins marked bundled/plugin/global/local/merged). run lands with Engine.run_team (PLAN M1). Subsumes the TODO "Jobs registry - named team configurations" item. Docs in docs/teams.md (schema, layers, roster), linked from docs/index.md + docs/swarm.md. tests/test_teams.py (registry + command coverage) and test_plugins.py integration (real TeamRegistry hook dispatch, engine.teams)
Plugin contribution hooks - entry modules may now define register_personas(registry) (personas contributed via registry.add_plugin(entry), same shape as personas.json), register_teams(teams), and register_skills(skills). PluginManager gains the three matching dispatch methods on a shared _run_hook helper (extracted from register_tools/register_commands, identical semantics: loaded modules only, a hook exception marks the plugin error with f'<hook> failed: ...'). PersonaRegistry gains a fourth in-memory scope between bundled and global, so precedence is bundled < plugin < global < local and a personas.json entry always overrides a plugin-provided persona. origin() reports plugin/merged and /persona lists plugin personas as (plugin). New PersonaRegistry.reload(plugin_manager=None) re-reads the three files, clears the plugin scope, and re-applies register_personas when a plugin manager is passed. Engine.personas wires the hook on first access (sub-agents inherit it via the shared plugin manager), and /plugins install/update/uninstall refresh the running REPL's personas registry live (tools/commands still activate on next start). Plugin personas never touch disk - add_plugin writes nothing. Docs synced (plugins.md entry contract + precedence, personas.md layers, testing.md). tests/test_plugins.py (persona/team/skill hooks + failure marking + engine integration) and tests/test_personas.py (plugin scope merge, origins, reload re-read + re-apply) grow to 81 combined
Fleet orchestration supervisor - replio fleet runs many scoped replio serve agents as supervised children. src/replio/fleet.py ships the declarative roster .replio/fleet.json (AgentDef: name/dir/enabled/prefer_port/max_restarts/command test seam) and runtime state .replio/fleet.state.json (pids, ports, statuses, restart counts), plus FleetController: bind-probe port allocation (find_free_port(preferred, lo=8780, hi=8890)), urllib GET /health probing (2s timeout), spawn as [sys.executable, -m, replio, serve, --host, 127.0.0.1, --port, <n>, --path, <dir>] with per-agent stdout/stderr to <dir>/.replio/logs/<name>.log and REPLIO_FLEET_PORT/DIR/AGENT exported to children, and a supervisor loop with restart policy (5s backoff doubling to 60s, max_restarts budget pausing an agent as crashed, enabled gate), health-threshold failures, and graceful SIGINT down. replio fleet up runs the loop in the foreground (Ctrl-C = graceful down), up --detach re-execs a background daemon that signals SIGINT/SIGTERM to stop. CLI: init (scan subdirs holding .replio/config.json), add, remove, up, down, status (agent/enabled/port/pid/state/restarts/last-error table), restart [name|all] (stop + reset backoff + relaunch), logs <name> [n] [--follow], and config <name> per-agent config generation (--provider/--model/--persona/--system-prompt/--mode/--tools-deny/--tool-permission, selected keys only, persona prompt/model/permissions inlined, unknown persona errors). Docs in docs/fleet.md (Supervisor section), docs/commands.md, docs/testing.md. tests/test_fleet.py covers allocation, loopback probes, manifest/state round-trip and corruption tolerance, the full spawn > health > restart > down cycle, max_restarts give-up, disabled gate, env seams, the config CLI, and a detached-daemon end-to-end (25 tests, mock-only)
replio fleet CLI wiring - main.py fleet subcommand parser + dispatch, cli.pycmd_fleet with --path accepted before the subcommand like jobs
Config immutability is documented, not enforced: replio fleet config is the operator surface and a serve process has no config-write CLI path today. An engine-level guard stays an open TODO
Bundled plugins restructured to plugins/<name>/{src,tests} - plugin code lives in src/ (manifest entry points there, the loader puts the entry module's directory on sys.path, so sibling imports resolve from src/), and each bundled plugin ships its own unit suite in tests/. replio-core-fs + replio-core-exec suites moved out of test_machine_tools.py, replio-core-mcp out of test_mcp.py (now in-repo src/), and replio-core-websearch gains coverage for the DDG parser, display formatters, text extractor, and open target resolution. The core suite discovers the plugin suites via tests/test_plugin_suites.py. replio plugins test [name] runs them headless, and a missing plugins dispatch in replio main was fixed. test_tool_registry.py decoupled onto synthetic tools (plugin-specific registration metadata lives in the plugin suites), and the redundant mcp_* presence check was dropped from test_bundled_plugins.py. Packaging ships src/ + tests/. Docs synced (plugins.md, testing.md)
Test cleanup - dropped the redundant mcp_* tool-presence assertion from test_bundled_plugins.py (owned by the test_mcp.py registration suite) and single-sourced the fs/exec cap-config setup
Legacy support removal (breaking, dev-stage) - dropped the delegate_* session prefix: /session list annotates sub_* children only and old delegate_* files get no special handling. Dropped --max-context (per-run fresh session files plus the rolling run-memory summary make history capping unnecessary). Dropped the config migrations (plugins.enabled/plugins.deny -> plugins, and local api_key -> global). Old keys are simply ignored. Removed config.api_key entirely - API keys live only in the global model registry (~/.config/replio/models.json, via /connect), the engine no longer falls back to config.api_key (_reinit_provider/check_connection/list_models resolve from the registry), and /config + replio config treat api_key as an ordinary key (no forced-global write, masking, or 0600). Tests updated. Docs synced (config.md, providers.md, security.md, deploy.md, usage/programming.md, session.md, testing.md)
Per-run job session files and unified session naming - by default every job run gets a fresh session .replio/sessions/job_<YYYYMMDD>_<HHMMSS>_<name>.json (same-second collisions get a _2 suffix), so no single file grows unbounded. Retries within a run share that run's file, and --session opts into a stable growing session. The session-naming scheme is now type-prefixed across the board: interactive sessions auto-name as ses_<ts>_<slug>, delegation sub-agents write sub_<ts>_<parent-session> (the suffix is the calling session id, sanitized and capped), and jobs use job_*. /session list annotates sub_* children with their parent. run_subagent no longer auto-renames the sub-agent's session. Docs synced (docs/session.md naming scheme table + docs/jobs.md per-run session section, security.md/swarm.md/usage/programming.mdsub_*). Tests updated (test_subagent.py/test_delegate.py filter by parent id) and tests/test_jobs.py grows to 88 (per-run naming, collision dedupe, --session stability)
Job run memory - every run (successful or failed) is summarized and written to a rolling .replio/jobs/<name>.memory.md via the same compaction summarizer as /compact, seeded with the previous memory so context carries. A fallback (Run <ts>: verified|failed + first part of the output/error) is stored when the summarize call fails. The memory file is injected into the next run as a ## Run memory system prompt block, so the model knows what previous runs did without the session file growing. replio jobs show <name> prints the memory path + preview. The file is human-editable like the task file and never breaks a run. Docs in docs/jobs.md (Run memory section). tests/test_jobs.py grows to 82
Job task definition as a linked Markdown file - replio jobs add <name> --file <path> --cron ... links the job to a task file (default .replio/jobs/<name>.md) that is re-read at the start of every run, so editing the .md is how you change the job - no re-adding. A missing file is created from a template (# <name> / ## Task / ## Done when / ## Notes) at add or edit time, and a task file deleted afterwards fails the run with a clear task file not found reason instead of silently using stale instructions. --prompt is now optional (at least one of --prompt / --file required). With both, the prompt is the short per-run trigger. New replio jobs edit <name> (and /jobs edit) opens the task file in $EDITOR, creating the template first. The scheduler composes the run's system prompt as persona prompt + task-file contents (## Job task) + --system-prompt, with the generic recurring-job prompt as the fallback. Job.task_file is persisted (worktree-relative when under the worktree). Docs in docs/jobs.md (task-file section) and docs/commands.md. tests/test_jobs.py grows to 77
Job runtime operations - replio jobs status is the journalctl-style view (state, fired count with ok/failed, last error, next run, uptime since creation, per-run approval state). replio jobs stop <name> (alias of disable) makes stopping discoverable. replio jobs show prints the last run's output. replio jobs run --verbose streams the live turn (tokens stdout, tool activity stderr) for Tmux/REPL demos, and headless run now prints the final answer. Tests grow to 68 in tests/test_jobs.py
Job memory across runs - the rolling run-memory summary (.memory.md, see the run-memory bullet) plus a fresh per-run session file replace the earlier stable-session design. The register history is capped at the last 100 runs. Without --persona or --system-prompt, runs inject a default recurring-job system prompt so the model understands the recurring task and past runs. Docs in docs/jobs.md
Human-in-the-loop per-run approval - --require-approval arms exactly one run at a time: after every run the job parks in a new waiting_approval state, the daemon refuses to fire it, replio jobs status//jobs status shows WAITING for approve, and replio jobs approve <name> (or /jobs approve) arms only the next run before it parks again. reject clears the grant. Manual run still overrides. This is the per-run gate on top of arm/disarm. Mid-run blocking (an ask tool pausing a running job) is documented as planned in docs/jobs.md and tracked in TODO
Richer job run spec - --persona (system prompt, model override, tool carve), --system-prompt, --mode, --provider/--model, --tools-deny, and --tool-permission were already present. The scheduler now falls back to a generic recurring-job system prompt and exposes Job.ready_to_run(). A job can already orchestrate a team by delegating to personas (delegate category defaults to allow on the job's tool carve)
docs/jobs.md rewritten - status model with waiting_approval, the three HITL gates (arm/disarm, per-run approval, planned mid-run blocking), per-run session + rolling memory semantics, --verbose run output. docs/commands.md gains status/stop/--verbose and the new add flags
Scheduled / durable jobs - a job is a named prompt plus a schedule (cron 5-field expression, interval seconds, or a one-shot at datetime), a tool carve, and durability settings (retries with exponential backoff, per-attempt timeout). Jobs live in .replio/jobs.json, one register per worktree. replio jobs list/show/add/approve/reject/enable/disable/remove/run/daemon and an equivalent /jobs REPL command manage them. A job is a human-gated workflow: add starts proposed and only approve (or --approval auto, or a manual run) activates it. The daemon never approves. Running sets executing, then verified (ok/truncated) or failed after retries. Every attempt is recorded in an append-only history. Jobs run on a stable job.<name> session (cross-run context, resumable trail) through a fresh headless Engine per attempt with HeadlessUI(auto='deny') - ask-gated tools are denied, a job reaches only its allow tools inside its worktree. Scheduling is deterministic: next_run_at is the single source of truth, recomputed strictly after each run, so a stopped daemon never replays missed windows, and a one-shot at job disables itself after running. The scheduler is single-threaded (jobs run sequentially in name order). Concurrency is future work
Cron scheduling engine (stdlib, in jobs.py) - the 5-field parser supports *, */step, a-b, a-b/step, and a,b,c lists on all fields with dow0-7 (both 0 and 7 are Sunday). Day-of-month and day-of-week are restrictive (both must match). replio jobs daemon polls on --tick (default 15s) and runs due jobs, replio jobs run <name> [--no-retry] runs one now and exits 0/1. Per-job overrides (--mode, --provider/--model, --persona, --system-prompt, --tools-deny, --tool-permission) rebuild the engine from the same merge run_subagent uses. Docs in docs/jobs.md
Tests (tests/test_jobs.py, 57 tests) - cron parser field expansion and edge cases (leap day, month roll, restrictive day rule), next_run/compute_next_run/parse_dt, job model round-trip and the runnable gate, registry store (round-trip, remove, corrupt-file tolerance), scheduler run/tick under a mocked engine (verified/failed, retries with per-attempt history, unknown persona, one-shot at disables itself, approval gates), replio jobs CLI (add/approve/list cycle, auto-approval, bad cron, duplicates, run exit codes). Docs synced (commands.md CLI + /jobs tables, testing.md, use-cases/homelab, enterprise.md durable-workflows status)
Enterprise use-case note - the durable-workflows gap now points at what landed: scheduled jobs, retries with backoff, timeouts, resumability, and the proposed > approved > executing > verified > failed status model shipped as replio jobs. Dead-letter queues remain planned
Delegation workflow fixes - delegate now defaults to allow (runs without a confirm, DEFAULT_CONFIG.tool_permission.delegate and the per-persona resolver default, and a persona can still set delegate: "ask"), so /tool delegate no longer prompts. Sub-agent sessions link to their parent: Session gains parent_id and sub_sessions (persisted), run_subagent records both (/session preview shows parent/sub-sessions, /session list annotates delegate_* with (child of ...)). A sub-agent that finishes with no prose now returns a log-summary result instead of (no content) - files written (write_file), tool round counts, and the last bash line are read back from its session - keeping the delegate result informative for the lead model and the REPL. Direct /tool calls no longer double-print the delegate result (ToolRegistry.execute()/Engine._run_tool take an echo flag injected as a hidden _echo kwarg, and /tool passes echo=False). Docs synced (config.md, personas.md, swarm.md, commands.md, security.md, session.md, testing.md, AGENTS.md)
delegate tool (core) - run a task under a persona as a sub-agent and get its final answer. Permission resolves per invocation through a new permission_fn registration hook: ToolPolicy.action() now accepts the tool arguments and a per-tool resolvers map (policy stays the single resolution point, resolvers only refine a non-deny base and are skipped without args, so schema filtering is unaffected). The delegate tool's resolver follows the persona rule - a configured persona uses its own tool_permission (category delegate defaults to ask, new DEFAULT_CONFIG entry), a persona outside the registry is deny. Display is gated by the new delegate_echo config (default true): on, the final answer plus a sub footer (duration + completion tokens) print in the REPL. Off hides the result. Testable from the REPL via /tool delegate {"persona": ..., "task": ...}. Registered as a core tool in Engine._init_tooling, which also builds the resolver map. tests/test_delegate.py (8 tests) + resolver coverage in tests/test_tool_policy.py. Docs in docs/tools.md, docs/config.md, docs/swarm.md
In-process sub-agent engine - Engine.run_subagent(persona, task) executes a task under a selected persona and returns its TurnResult. A sub-engine is a fresh Engine built on a per-persona config: the persona's system_prompt becomes the system prompt, its model overrides the caller's model when set (else the caller's provider is reused), its tool_permission is merged over the caller's categories, and mode is forced to build so the carve alone decides edit/bash. It shares the caller's plugin manager and worktree, runs with a quiet NullUI (ask-gated tools auto-deny, so there are no interactive prompts), and persists a namespaced delegate_<persona>_<ts> session into the shared sessions/ dir. Engine.__init__ gains optional plugin_manager/provider injection knobs. Covers tests/test_subagent.py (9 tests). Docs expanded in docs/swarm.md
Persona tags - Persona gains a tags list for job-based grouping and filtering, using a controlled vocabulary (research, writing, programming, review) on the bundled roster (researcher = research+writing, editor = writing+review, code-reviewer = programming+review, etc.). /persona shows tags in list/show, and /persona list <tag> filters (unknown tags print the known vocabulary). List merge keeps the "local replaces lower layer" rule per field. Docs updated (docs/personas.md), tests grow to 27, and a Jobs registry (team configurations built on tags/personas) is noted as a future TODO item
Bundled default personas - the registry gains a third read-only bundled layer (src/replio/bundled_personas.json, shipped via package-data) with precedence bundled < global < local, mirroring bundled plugins. Ships two pre-carved teams: document (researcher, writer, referencer, editor) and programming (planner, programmer, tester, code-reviewer), each with a system prompt and per-persona tool_permission (researchers/editors are read-only, programmer/tester may run bash). Personas stay model-agnostic (model empty = inherit the caller). /persona shows (bundled)/(merged) origins, new <name> now overrides any existing persona (bundled included), and remove rejects bundled-only personas with override guidance. Docs updated (docs/personas.md roster table, docs/swarm.md). tests/test_personas.py grows to 21 tests
Personas registry - personas defined as a named catalog (system prompt, optional model override, optional skills and per-persona tool_permission), stored in single JSON files merged global (~/.config/replio/personas.json) then local (.replio/personas.json), field-by-field with local winning and empty local fields inheriting from global. PersonaRegistry (ModelRegistry-style, personas.py), /persona command (list / show / new / remove), Engine.personas accessor, docs/personas.md and a Personas and delegation section in docs/swarm.md (per-persona delegate permission rule and the document-pipeline use case). Covered by tests/test_personas.py (11 registry + 5 command tests)
Soft tool results now render as dimmed info lines - bundled tools declare a note predicate (read-only callable on the raw result) via registration metadata, and the shared dispatch point _run_tool echoes the note line under the activity line when it fires. (empty file), (empty directory), (no matches for "x"), (end of content)/(empty content), and No search results found. were previously invisible in the REPL (only persisted + fed to the model), while only Error: results rendered. Gated by the new show_notes config (default true). Error/note checks sit side by side so a result cannot double-render, and the echo path excludes note results
Session audit trail - every tool permission resolution and its outcome (allow/ask/deny -> granted/declined/denied, with the tool's path when present) is recorded to a new append-only session permissions array. Always-on with no config switch. Replaces the old "confirm prompts are ephemeral" guarantee in docs (AGENTS.md, tools.md, security.md, use-cases/index.md, session.md)
Stable message identifiers - every session message now carries an id (msg_<hex>) auto-assigned on creation, for cross-referencing and future delegation (sub_sessions). Legacy messages without an id remain readable
Configurable footer token counts - the (7.4s, 8 tokens) footer now builds from the new footer_tokens config (default ["context"], reproducing the old output). Segments render in order joined by /: context = <n> tokens (input/context size), in/out/thinking = <n>t pulled from provider usage (prompt_tokens, completion_tokens, completion_tokens_details.reasoning_tokens), with unavailable counts skipped and an empty list hiding the token section. show_context_size remains the master on/off. Engine._usage_counts() resolves the counts. footer(duration, counts) replaced the old (duration, usage, tokens) signature across ReplUI/HeadlessUI/NullUI
Thought-duration line for streamed thinking - with /thinking on, each thinking block now ends with a dimmed (Thought N.Ns) footer-style line (the duration was previously dropped for visible thinking, and only the show_thinking: off spinner path showed it). Gated by the new show_thought_duration config (default true). Mirrored in HeadlessUI (stderr) for replio run/serve, documented in docs/config.md, and covered in tests/test_ui.py
Global model registry (~/.config/replio/models.json, separate from config) - /connect appends configured models (provider/base_url/model) and now stores API keys there (0600), no longer in config.api_key (kept as a read-only legacy fallback). /model list shows configured models grouped by provider with the active one marked > and (key) presence. /model list --online [provider] probes a provider's advertised models. On a fresh project /connect offers a numbered picker (#N) that reuses a known model and its stored key, so deleting a project config can no longer lose connection credentials. The engine resolves the API key from the matching registry entry before falling back to config.api_key
/config handles single config lines in either scope - leading --global / --local flags (Git-like default stays local, matching replio config set --global), threaded through set, unset, and -a/-r list ops. Output prints the resolved scope (e.g. /config --global api_key <key> is the one-line global key set, masked in output). Config.apply() replaces the old persist=False flag - in-memory overrides are now an explicit, separate method used by one-shot CLI flags and Engine._reinit_provider normalization (which can no longer write config files at startup)
Config writes are now scoped and secure - the project-local file holds only explicitly selected keys (a save never re-writes the merged config, so secrets can't be copied down). replio config get/set/unset CLI (with --global/--local, --show-origin, JSON values) and /config unset + origin display in the listing. One-shot CLI overrides (replio run --provider/--model/--base-url/--mode) are no longer persisted. api_key was global-only (written 0600) and is superseded by the model registry above
Turn recovery for autonomous agents - on truncation (finish_reason=length) with a partial answer, auto_continue (default true) re-requests the turn with a "continue exactly where you stopped" instruction and stitches the parts into one message (capped by auto_continue_max, default 2). A stream that completes with no content is retried (stream_retries) before being flagged empty. Reasoning-only turns (thinking present, no content) are no longer errors. Partial output is persisted whenever the turn produced content or thinking
Thinking is captured from reasoning as well as reasoning_content deltas - ollama.com streams chain-of-thought under reasoning, so reasoning shows in the REPL and sessions for those endpoints instead of being silently dropped (tokens still counted against max_tokens)
Diagnosed a real truncation+empty-response pairing: a low explicit max_tokens, the dropped reasoning delta key, and no continuation. The mechanics are documented in docs/config.md and docs/providers.md (migration landed live: local max_tokens dropped, global max_tokens 0, api_key moved to global)
Confirm prompts align with activity glyphs - the ? prefix starts at the beginning of the line (no leading indent), so a confirm like ? read_file x.txt - approve? [y/N] sits flush under its % Search / ← Read activity line
Word-level streaming buffering - the REPL now buffers streamed tokens to word boundaries and prints whole words as they complete, so responses render smoothly without mid-word pauses or breaks. The <<< prefix appears on the first printed word, indentation and newlines flush correctly, and the trailing partial word is written before any status line, confirm prompt, or the turn footer. HeadlessUI and NullUI stream unchanged, and the content persisted to sessions is untouched. Gated by the new word_streaming config (default true), off restores character-by-character streaming
Multi-line REPL input - opening """ or ''' switches the prompt to ... until the block closes. The composed prompt is sent as one turn with the framing quotes stripped (both """...""" and task: """...""" work, indentation inside the block is kept, and the block is a single history entry). Balancing is overlap-aware, so """" reads as a matched pair. Ctrl-C or EOF on an open block exits the REPL cleanly instead of swallowing the interrupt
Available models are now discoverable - /models (alias /model-list) lists the connected provider's models. replio models [--path] does the same headlessly and exits 1 on a failed probe. When /connect saves a config whose model is missing from the advertised list, it offers Show available models? [y/N]. Engine.check_connection() now returns (ok, message, models) from a single _fetch_models() call, and Engine.list_models() proxies the same shared fetch. list_models() dropped its inline [Error] print (pure, returns [] on failure)
Multi-line input tests - tests/test_repl_input.py (22 tests: overlap-aware open-delim detection, framing strip variants incl. indentation and ''', and run-loop flows for single-line, block composition, EOF exit inside a block, and untouched slash commands)
Word-streaming tests - tests/test_ui.py (9 tests: boundary and tail flush, multi-word chunks hold only the partial word, newline flush, footer flush with separator newline, off-mode immediate writes, markdown bold across a flush boundary, flush before status and confirm prompts)
/connect tests the provider connection before saving - a GET <base_url>/v1/models probe via OpenAICompatibleProvider.check_connection() over the shared _fetch_models() helper. Broken values print the error and need an explicit Save anyway? [y/N] to persist. A success message notes when the configured model is missing from the model list
/provider <name> switches then probes the new connection and prints a warning when it fails (Run /connect to fix). Both probes are gated by the new connect_check config (default true). Set it to false to skip them for offline or flaky networks
Engine.check_connection() probes a throwaway provider built from optional overrides (never mutates config/self.provider), with provider resolution extracted into _resolve_provider_factory() (also cleans up _reinit_provider's unknown-provider handling)
Tests - check_connection and list_models coverage in tests/test_providers.py (loopback success/empty/model-note/HTTP, network error, silent-on-error) and tests/test_engine.py (resolution, override precedence, no state mutation, base-URL detection, list_models). /connect probe-before-commit (decline keeps config, accept saves, connect_check: false skips, model-mismatch show offer) plus /models and /provider warn in tests/test_commands.py. replio models in tests/test_cli.py. Docs in docs/config.md (connect_check), docs/providers.md, docs/commands.md, docs/testing.md
replio export <name> [--out <file>] - headless Markdown export for scripts and CI, reusing the same renderer and defaulting to the same .replio/exports/<name>.md target (--out - prints to stdout, exit 1 on an unknown session)
Session export to Markdown - /session export <name> [out] renders any saved session as a Markdown transcript (default .replio/exports/<name>.md, custom path via the second arg, - for stdout). The pure renderer in sessions/render.py covers the full persisted log: thinking, tool calls and results as fenced code blocks, tool analysis, command records, compaction summaries with the trimmed boundary, and the ## Errors section. It reads via read() (never switches the current session) and carries serialization-time transforms (noise_tools, session_tool_max_chars) through as persisted
/session export completes session names in the REPL, and the export format is documented in docs/session.md
Tests - tests/test_session_render.py (17 tests: renderer output per role, fence escaping for backtick-heavy tool results, persisted noise transforms, and /session export dispatch covering default/custom/stdout targets and the non-destructive read) plus tests/test_cli.py for replio export (default/custom/stdout targets, unknown session, main dispatch)
Failed tool calls now render a dimmed ! Error: ... line (first line of the result) under the activity line, for every tool through the shared dispatch point (agent loop, /tool, policy-denied calls), gated by the new show_errors config (default true)
list_dir and grep get the * glyph with distinct verbs (* List, * Grep), matching glob's * Glob - they no longer render as ← Read, which made directory listings and greps look like read_file calls
run_command now validates cwd (friendly Error: cwd not found instead of an OSError) and clamps timeout to 1..600 seconds, so a nonsense value like 10000 no longer executes
The confirm prompt prefix changed from ↳ to ? so it no longer collides with the reserved delegate-category glyph
Glyph activity lines and confirm prompts now show the parameters the model passed (← Read engine.py [offset=299, limit=85], $ Run pytest [cwd=/workspace, timeout=600]), gated by the new glyph_params config (default true). The label's own argument is not repeated
max_tokens now defaults to 8192 (sent to the provider, overriding low provider-side defaults like Ollama's 2048 cap). Setting it to 0 omits it from the payload so the provider's own default applies
Truncation messages now distinguish a configured max_tokens cap from the provider's own default limit (no more misleading (0) in the session log when the limit is unset)
Session auto-names are now ASCII-only: non-ASCII letters are transliterated (NFKD, e.g. prüfe becomes prufe) instead of being stored in filenames
/help now lists tools as indented subcommand-style rows under /tool (replacing the separate "Available tools:" section), filtered by policy and mode like the /tool listing. Bare /tool shows the same rows with short descriptions
Fixed streaming and non-streaming provider requests failing with 405 Method Not Allowed on endpoints that redirect - urllib converts POST to GET on 301/302/303 redirects, so api.ollama.com/v1/chat/completions (which 301-redirects to ollama.com) ended up GETting the chat endpoint and getting 405 back. A PostRedirectHandler now preserves the POST method and body across redirects (utils/http.py, providers/base.py), with loopback-server tests for both the SSE stream and non-streaming _post
Fixed OpenAI/Groq/Anthropic provider defaults posting to a doubled path - DEFAULT_BASE_URL already includes /v1, and _endpoint() appended /v1/chat/completions again, producing /v1/v1/chat/completions (404). _endpoint() and list_models() now normalize a trailing /v1 instead of doubling it, so default and custom base URLs both resolve correctly
Plan/Build (and custom) agent modes - a mode is a named posture combining a system instruction with tool-policy overrides. New mode (default build) and modes config keys ship the built-ins: build (no overrides, current behavior) and plan (read-only - denies the edit and bash categories and instructs the model to investigate and propose rather than modify). Custom modes can set system_prompt, tool_permission (merged over the base, mode wins per key), tools.deny (appended), and tools.allow (replaces when non-empty). An unknown mode falls back to build with no error
/mode command - no args lists the current mode and all defined modes, /mode <name> switches live (the next turn uses the new posture, mode switches are recorded as command messages), unknown names print the valid list. Mode names tab-complete in the REPL. The REPL banner and the replio serve stderr line show the active mode when it is not build
--mode <name> CLI flag on replio run and replio serve - headless agents start in the given posture (e.g. replio run --mode plan for a read-only review run)
Mode mechanics reuse the existing ToolPolicy - no new machinery: ToolPolicy.allowed() is now permission-aware, so a category-level deny (tool_permission.edit: deny and tool_permission.bash: deny) filters the tool from the provider schema and from /tool//help listings, not just direct calls. The MCP server's tool listing honors the same filtering
Mode instructions and system_prompt are injected at the engine level (_provider_messages) as a virtual system message, so the REPL, replio run, replio serve, and MCP all apply them. Headless modes now receive system_prompt (previously REPL-only, persisted as a session message - that block is removed)
Sessions record the active mode on every assistant message (mode field, parity with reasoning), so the posture in effect per turn is auditable from the append-only log
Tests - tests/test_modes.py (12 tests: resolve/merge rules, unknown fallback, instruction composition), plus plan-mode schema filtering, instruction injection, per-message mode, /mode command and completer, --mode CLI, and permission-aware allowed() coverage in engine/commands/cli/policy tests
Reasoning persisted in session logs - each assistant message records the reasoning config value in effect, alongside the existing thinking text. Reasoning text is always logged regardless of show_thinking (display hides it but never drops it from the log)
Thinking/reasoning toggle - two orthogonal knobs: show_thinking (default false) controls display (stream reasoning dimmed when on, else an animated spinner + + Thought N.Ns summary), and reasoning (default "auto") requests reasoning from the model and controls its token budget. reasoning maps false/"off" (no request), true/"on"/"auto" (provider default), and "low"/"medium"/"high" (budget hint) to provider params - OpenAI reasoning_effort, Anthropic thinking.budget_tokens (1024/2048/4096), Qwen/Ollama enable_thinking - with a generic reasoning_effort pass-through for other OpenAI-compatible endpoints. The /thinking on|off|? command toggles show_thinking live
Thinking spinner - when show_thinking: false the REPL now shows an animated ⠋⠙⠹... spinner (stdlib threading daemon, \r\033[K cleared) in place of the silent wait, then falls back to the + Thought N.Ns summary. No new config key - folded into show_thinking. Interactive ReplUI only, headless stderr output unchanged
Activity lines and tool status are ephemeral UI - emitted through the UI sink only and never persisted to session files (tool calls/results are recorded there). Regression test locks this in
MCP (Model Context Protocol) support via the bundled replio-core-mcp plugin (stdlib-only - JSON-RPC 2.0 over newline-delimited stdio and SSE over urllib, no third-party mcp library)
MCP client - connect to external servers over stdio or streamable HTTP and import their tools into the ToolRegistry, named <prefix>.<tool> and registered under the new mcp permission category (default ask, so each remote call confirms in the REPL). Management tools mcp_connect/mcp_list/mcp_disconnect and a /mcp command wrap the same registry dispatch. mcp.servers config defines server name, transport, command/url, prefix, headers, and timeout
Dual-era negotiation - the client probes server/discover (modern 2026-07-28 per-request _meta protocol) and falls back to the legacy initialize handshake (2025-11-25 and earlier), accepting a mutually supported version on unsupported-version errors
MCP server - exposes Replio's policy-filtered tools and its sessions as resources (replio://session/<name>) to external agents, over stdio (replio mcp) and HTTP (POST /mcp on replio serve). The server is dual-era too, serving server/discover/modern requests or the legacy initialize handshake depending on how the client opens. ask-policy tools run by default when serving (mcp_server.allow_ask, deferred to the external client as the human-in-the-loop)
Core stays MCP-agnostic - replio mcp and the /mcp HTTP route delegate to the plugin's mcp_server service (same generic register_services pattern as the web-search service), erroring cleanly if the plugin is not loaded
Tests - tests/test_mcp.py (27 tests): JSON-RPC framing, stdio/HTTP transports over real subprocesses and a loopback server, modern/legacy negotiation, tool import + prefixing, server dispatch (initialize/discover/tools/call/resources), _meta validation, and policy integration
Docs - docs/mcp.md (config schema, client + server usage, interop note and security), bundled-plugin and config/tools/testing references updated
Alias layer for tools and params - tool names can register aliases (read/view > read_file, ls > list_dir, bash/exec > run_command) and param_aliases (file > path, query > pattern, cmd > command, q > query, cursor > offset). The registry resolves aliases to the canonical tool and normalizes args, so the advertised schema stays in the project's own vocabulary while model-dialect tool and argument names are absorbed. /tool, /help, tool policy, confirm prompts, and glyph activity lines work through aliases unchanged
open web tool (replio-core-websearch) - fetch a web page by id (1-based result from the most recent web_search) or url, with offset (also accepted as cursor) to resume reading. web_search now retains its last results for open to resolve, and a shared _fetch_text helper gives both fetch_page and open offset-with-continuation paging, appending [offset N of M chars - continue with cursor=N] when content continues past the cap
Configurable stream retry - a provider stream that ends before a completion event with no streamed content is re-requested up to 1 + stream_retries times (default 3 total attempts, configurable) with stream_retry_delay (default 0.5s) between attempts. The retrying note shows the attempt count, and when tool calls have already run the warning notes the results are saved so the answer can be retried with a follow-up message
tool_max_result_chars config (default 0 = unlimited) - replaces the bundled plugins' hardcoded 8000-char tool-result cap with a configurable one (set via /config tool_max_result_chars N). With the default nothing is truncated, read_file headers now report the total line and character count so the model can page large files with offset/limit, and read_file(path, limit=0) returns just the header as a size probe before committing to a read. Handlers can read the config via a _config kwarg the registry passes only when declared
Glyph activity lines - typed <glyph> <verb> <key_arg> status (dimmed) replaces the [tool: key_arg] oneliner for mapped categories, gated by the new glyph_lines config (default true). ToolRegistry.activity() resolves the glyph from category defaults (read ← Read, write → Write, search % Search, exec $ Run, ask ~ Ask, todo - Todo, delegate ↳ Call) or per-tool glyph/verb overrides (glob * Glob, fetch_page ↓ Fetch). Disabling glyph_lines or an unmapped category keeps the [tool: key_arg] oneliner + detail lines. ReplUI/HeadlessUI render the glyph line, ephemeral UI only (never persisted to session files)
write_file reports the resolved absolute path to the model - the tool result now returns Created|Overwritten|Appended <resolved abs path> (<n> lines, <n> chars) instead of echoing the raw path arg, so when a relative path resolves to an unexpected directory (e.g. launched from ~) the model sees where the file actually landed, the tool description notes that relative paths resolve against the current working directory. Terminal status preview unchanged. Tests: create/overwrite/append result strings and relative-path resolution
Tab completion extended - filesystem path completion after command arguments (directories get a trailing / for continued descent), tool-name completion for /tool <prefix>, and subcommand completion for commands declaring subcommands (/session lo > load, /plugins dis > disable)
Fixed tab completion - the completer no longer fires on macOS libedit (readline.parse_and_bind('tab: complete') is GNU-only and silently inserts a literal tab, libedit needs bind ^I rl_complete). / is removed from the completer delimiters so the model-facing word keeps its slash and paths complete correctly
Per-turn stats on their own line - the (Ns, N tokens) footer no longer lands at the end of the last content line, ReplUI tracks whether streamed output ended with a newline and emits a separating one before the footer
One-shot retry for empty/truncated streams - a provider stream that ends without a completion event and with no streamed content is requested once more (with a "retrying" note) before the "Stream ended before a completion event" error is surfaced, masks transient provider drops such as Ollama cloud returning an empty follow-up stream after a tool call
write_file status preview ends with a dimmed parenthesized summary - resolved absolute path, line count, char count, and created/overwritten/appended action (tool result string stays minimal)
Thinking announce - + Thinking on its own line before streamed reasoning when show_thinking: true, when hidden, + Thought 12.3s (thinking duration) is printed instead and the reasoning text stays out of the terminal. The engine times the thinking window (thinking_begin/thinking_end UI hooks), HeadlessUI mirrors it in --verbose mode, and a spinner (⠧ Thinking) can replace the static markers later
Human-readable tool status - default oneliner is now [tool: key_arg] (e.g. [write_file: test.md], [run_command: echo hi]) instead of the raw args dump (content='...\n...', mode='w'), detail lines follow dimmed: write_file shows the written text via a registered status callback (new-file + line preview, or a difflib unified diff of existing files - works for append too), and run_command echoes its output (echo registration metadata)
list_dir gains depth (default 1) - higher values render an indented recursive tree (tree -L style), skipping SKIP_DIRS during descent, depth=1 output is unchanged
ToolRegistry.execute() drops arguments not declared in a tool's schema and null-valued arguments (e.g. a hallucinated recursive, or depth: null) - tools run with their valid args instead of erroring, worktree semantics documented (worktree = launch directory / --path, launching from ~ makes home the worktree, so subdirectories don't escalate)
Built-in features ship as bundled plugins - web_search/fetch_page and the machine tools (read_file, list_dir, write_file, glob, grep, run_command) moved out of the core (src/replio/web/, tools/builtins.py, tools/machine.py deleted) into three first-party bundled plugins under plugins/ (shipped as replio.plugins.bundled): replio-core-websearch, replio-core-fs, replio-core-exec. Packaged via package-dir mapping, so the repo-root plugins/ directory is the canonical source, discovery adds a bundled root with precedence bundled < global < local (local/global overrides win)
plugins config list - replaces plugins.enabled/plugins.deny (migrated automatically). The default config lists the bundled plugins so they are active out of the box, empty list = all discovered plugins load, /plugins enable/disable and install/uninstall maintain the list
register_services entry hook - plugins can power core non-tool features. replio-core-websearch registers the search service backing the web_search: true search-then-answer mode (engine._perform_search now routes through it and degrades gracefully when the plugin is absent)
PluginInfo.origin (bundled/global/local) shown in /plugins and replio plugins list, bundled plugins cannot be updated or uninstalled (disable instead)
tests/test_bundled_plugins.py (10 tests) - bundled discovery, tool registration, search service, bundled uninstall/update blocking, local override, config disable, default-config membership, test_tool_registry.py/test_machine_tools.py/test_commands.py/test_tool_calling.py/test_session_log.py reworked to load bundled plugins and patch the search service instead of replio.web.search
Plugin system (src/replio/plugins/) - external repositories register tools, providers, and slash commands without changing the core, PluginManager discovers plugins in ~/.config/replio/plugins/ and .replio/plugins/ (local wins), validates manifest.json (replio_version/python semver ranges), imports entry modules once, and hooks them into the live registries
Plugin entry contract - optional register_tools(registry) / register_providers(providers) / register_commands(commands) hooks on the entry module, matching the core's register_* conventions, plugin tools automatically inherit tool policy, /tool, /help, refinement, and session logging
Lazy plugin dependencies - third-party packages are imported inside plugin functions (never by the core), so the stdlib-only guarantee holds and a missing dep surfaces as a tool error with pip install guidance
/plugins command (/plugins list, /plugins <name> detail with per-dep status, enable/disable, install/update/uninstall) plus tab completion for plugin names, /connect now offers plugin providers
replio plugins CLI (list/install/update/uninstall) - headless plugin management so replio run/CI can use freshly installed plugins, install clones a git URL or copies a local path, records source, and --deps pip-installs the declared requires
Config keys plugins.enabled (allowlist, empty = all) and plugins.deny (always excluded) - activation is explicit and applies on the next start
tests/test_plugins.py (30 tests) - manifest parsing, replio_version/python compat skipping, enable/deny filtering, global/local precedence, entry load-error isolation, all three registration hooks, lazy-dep pip guidance, tool-policy filtering, install/update/uninstall from a local path, and Engine integration (tools + commands + /plugins)
REPL banner shows the version - Replio v0.12.0 (provider: model) on startup, gated by show_version config (default true), toggleable via /config show_version false
GET /version endpoint - the serve API exposes the installed version as {"version": ...}, alongside /health and /sessions
/version slash command (alias /v) - prints Replio <version> in the REPL, version lookup centralized in replio.get_version() and shared with the CLI --version flag
--version / -v CLI flag - replio --version prints the installed version (from package metadata) and exits
Clear screen on REPL start - the interactive REPL wipes scrollback + visible screen (\033[3J\033[2J\033[H) before printing the banner, gated by clear_screen config (default true), toggleable via /config clear_screen false. Headless run/serve modes are unaffected.
Engine headless agent core (engine.py) + TurnResult - the agent loop extracted from ChatLoop returns a structured {content, thinking, tool_calls, errors, duration, usage, model, provider, session, status} turn result, Engine.chat(text, autoname=True) and Engine.load_or_create_session(name) make sessions addressable from any front-end
UISink abstraction (ui.py) - the loop renders through ReplUI (terminal ANSI streaming, confirm prompts, footer), HeadlessUI (stderr diagnostics, auto-approve/deny confirm policy, never blocks on stdin), or NullUI (silent)
replio run - one-shot CLI mode (JSON or text output) over the same agent loop + session manager, flags --prompt, --provider, --model, --base-url, --output, --verbose, --session-id, --yes/--no, exits 0 on ok/truncated and 1 on error/empty
replio serve - stdlib ThreadingHTTPServer JSON API (POST /chat, GET /sessions, GET /health) with a shared engine serialized behind a lock, ask-gated tools are denied (fed back as [cancelled]), the server never blocks on stdin
Headless ask policy - tools gated by ask are denied by default in headless mode, --yes/--no override to approve or deny
Tests for headless entry points - test_engine.py (turn result, thinking split, session addressing, confirm policy), test_cli.py (mock provider, JSON/text output, session-id persistence, exit codes), test_server.py (ephemeral-port server against a mocked provider)
ChatLoop is now ChatLoop(Engine) - a thin REPL shell (readline, banner, prompt loop) inheriting the headless core, commands and session logic are unchanged
_handle_message() replaced by Engine.chat(), _StreamRenderer moved into ReplUI, and the <thinking> marker split is owned by the engine so thinking stays separate from content in JSON results
The input() confirm prompt moved from chat.py to ui.py (tests patch replio.ui.input)
OpenAI, Groq, and Anthropic providers (providers/openai.py, providers/groq.py, providers/anthropic.py) - OpenAI-compatible /v1/chat/completions subclasses of the shared OpenAICompatibleProvider, each with its own default base_url and default model (gpt-4o-mini, llama-3.3-70b-versatile, claude-sonnet-4-20250514)
Provider auto-detection - detect_provider(base_url) in providers/__init__.py matches well-known hosts (openai.com, groq.com, anthropic.com, ollama.com/ollama.ai) and falls back to the generic openai-compatible provider for any other OpenAI-compatible endpoint
PROVIDERS registry - _reinit_provider() resolves the configured provider name through the dict (ollama, openai, groq, anthropic, openai-compatible), unknown names are auto-detected from base_url with a printed note instead of silently falling back to ollama
/connect detects the provider from the entered base URL and switches automatically when it matches a known host
Agent-loop hardening - streamed content is persisted when the SSE stream ends without a done event, and session errors entries are recorded for silent failures: stream EOF before a completion event, empty/thinking-only done, and unexpected exceptions escaping the agent loop, unexpected exceptions in run() are caught, logged to the session errors, and the REPL stays alive instead of crashing
tests/test_providers.py - provider defaults, auth header, detect_provider, and registry coverage
OllamaProvider now subclasses the shared OpenAICompatibleProvider - the streaming/tool-call/error logic it previously owned moved to providers/base.py, behavior unchanged
Switching providers swaps in the new provider's default base_url/model when the configured values still match another provider's default (i.e. were never customized), explicit custom values are preserved
tests/test_ollama_provider.py - stream_sse patches retargeted to the base module where the streaming logic now lives
/session preview <name> - read-only structural preview (name, created/updated, message counts by role, tools used) without switching the active session, shown on /session load too
noise_tools config (default ["fetch_page"]) - results of these tools are replaced by a [<tool> result excluded from log, see tool call above for parameters] marker at persistence time only, the live turn still feeds the full result to the model, and the assistant tool_calls message keeps the query/URL so the log stays reproducible
Session-name tab completion - /session load and /session delete complete against saved session names (bash-style common-prefix completion, double-tab lists candidates)
Compaction summary visibility - /compact and /session load (when the compaction offer is accepted) print the generated summary text, stored as the result of the triggering command message (with a compact_from boundary index)
Context-size visibility - dimmed (Ns, N tokens) line after each response, using the provider's usage.prompt_tokens when reported (fallback: char-based estimate), gated by show_context_size (default true)
/session load compaction offer - always prompts Summarize & trim history before continuing? [y/N]
tests/test_http.py - stream_sse survives a multi-byte UTF-8 character split across 4096-byte read chunks, normal data-line flow, and [DONE]/error passthrough
Sessions are append-only logs - compaction no longer removes or rewrites session entries, it only trims the provider context via the summary record and boundary, so the full history always stays in the file
Provider payload is prepared from the log - role: command messages are never sent as-is, compaction summary records convert to system messages, and dangling tool messages are skipped
/session load and /session preview print a structural preview, /session load records the load as a command message in the loaded session
max_tokens default is now 0 (unset) - the value is omitted from the provider payload entirely, so the provider's own default applies and long answers are no longer cut at the old 2048-token default, set a cap explicitly via /config max_tokens N
/session new and /session load now reassign ChatLoop.current_session - previously they only updated SessionManager.current, so loaded/new sessions were never actually used for prompts, rendering, or autosave
If a configured max_tokens cap is hit (finish_reason: length), the loop now prints a visible truncation warning and records a session errors entry instead of stopping silently
OllamaProvider.chat() streaming captures usage from the final chunk and attaches it to the done event
Compaction failed with sessions containing command/tool messages - the summarizer now sanitizes the batch (drops command, drops tool_calls declarations, converts tool > user [tool result]), and provider errors are printed instead of swallowed as a generic "Compaction failed"
Tab completion for / commands never matched - readline passes the current word, and command names have no leading slash, so the prefix test always failed, the completer now strips the / when comparing and re-adds it on completion
stream_sse decoded each 4096-byte chunk with strict UTF-8, so a multi-byte character split across a chunk boundary raised UnicodeDecodeError - caught as a generic error that aborted the stream mid-output and killed the tool loop during long web research, buffering is now byte-based and each complete line is decoded with errors='replace'
Session metadata - created_at/updated_at (bumped on every message) and a top-level errors array with add_error(), to_dict() now persists every message, including role: tool
tool_analysis config (default false) - optional model-generated one-line insight summary stored as analysis on each tool message, so a session log can be reconstructed without re-running the tool, skipped for [cancelled]/error results
session_tool_max_chars config (default 0 = unlimited) - caps persisted tool-result content at serialization time only, the in-memory provider context always keeps full results
_StreamRenderer captures thinking_text, _agent_loop persists per-round reasoning as thinking metadata on tool-call and final assistant messages (still excluded from content)
Provider error events now append to the session errors array before the loop bails, instead of being dropped
SessionManager.save() accepts tool_max_chars, session_auto_save() forwards the config value
_execute_tool_calls() appends assistant tool-call and tool-result messages via Session.add_message() (single timestamp/updated_at path instead of direct list appends)
Docs updated to match: session files are now complete logs (tool results, thinking, errors), confirm prompts and tool status remain ephemeral UI
Tool short= registration metadata - human-readable one-line labels for the /help tools table (falls back to a truncated description)
CommandRegistry.canonical() and ToolRegistry.info() accessors backing the /help <name> detail views
glob tool - recursive pattern file discovery (**/*.py, src/**/chat.py), skips noise dirs (.git, .venv, __pycache__...), marks dirs with /, caps at 200 matches
grep tool - regex content search returning file:line: text matches with an optional file-filter glob, skips noise dirs, caps at 100 matches, friendly invalid-regex error
/config structured values - JSON auto-parse (["run_command"] > list, 0.3/2048/true > number/bool), -a/-r list add/remove, reload re-reads config from disk (warns when provider/model changed), unknown keys prompt y/N before storing
Command registration metadata - description and subcommands on CommandRegistry.register() (self.meta), canonical names tracked so aliases can't shadow real command names
Tool permission model (tools/policy.py) - ToolPolicy resolves each tool call to allow/ask/deny from configurable permission keys (tool_permission: read/list/edit/bash/web) plus name-level tools.allow/tools.deny (deny and allow-whitelist take precedence)
Path-scoped confirmation - read/write/list calls targeting paths outside the project worktree escalate to ask (opencode-style external_directory), so in-worktree file ops run unattended while external access prompts
Confirm prompts in the agent loop (_confirm_tool) - bash: ask by default so every run_command confirms, cancelled calls feed [cancelled] tool results back to the model, denied tools are filtered from the provider schema entirely
Registration metadata - category, permission, path_arg, key_arg on ToolRegistry.register() plus permission_for()/path_arg_for()/key_arg_for()/schema_filtered(), retrofitted web_search/fetch_page (forward-compat for the deferred activity-lines glyph system)
tests/test_tool_policy.py (allow/deny, permission keys, external-directory escalation, precedence) and tests/test_machine_tools.py (read/list/write/exec against a temp dir, timeout, metadata)
/helpAvailable tools section renders a clean two-column table using new short= registration labels (Run a shell command, Search the web, ...), category/permission/action now show only in /help <tool> details
/help is now the central help system - shows Available commands (aliases inline, subcommands at a standard 4-space indent) followed by Available tools (one per line with [category / permission-key: action], policy-filtered)
/help <name> shows details for a specific command (aliases + subcommands) or tool (category, permission: action, full parameter schema with required/optional), commands take precedence
/tool (no args) lists allowed tool names one per line and points to /help <tool> for details
read_file always emits a # <path> - N lines header (with the shown range when truncated) so the model knows the file's total size without extra reads
/help compacts to one line per command with aliases inline (/help, /h) and subcommand rows beneath (/session > new/list/load/delete/save), all aligned to a computed description column
/session (no args) reuses the shared subcommand metadata instead of a hardcoded usage block
/tool command respects tool policy - listing shows only allowed tools, execution routes through _run_tool() (deny rejection + confirm prompts)
Session.to_dict() and the agent loop unchanged - confirm prompts and tool status remain ephemeral REPL UI, never persisted
Config shallow-copied DEFAULT_CONFIG, sharing the nested tools.deny/tool_permission lists/dicts across instances - mutations (e.g. /config tools.deny -a ...) leaked into later sessions, now deep-copied on load and reload
/tool <name> <json-args> slash command - generic thin wrapper executing any registered tool via the same ToolRegistry the model uses, no args lists registered tools
tests/test_tool_registry.py - registry metadata tests: refine_required() for web_search vs fetch_page/unknown
Unified streaming agent loop (_agent_loop() in chat.py) - a single SSE stream per turn replaces the two-phase non-streaming decision + streaming call, tool_calls events are handled mid-stream, eliminating the double-call cost when no tools are used
_StreamRenderer in chat.py - stateful streaming display (thinking markers, markdown, <<< prefix) extracted from the old _stream_response and reused across loop rounds
OllamaProvider.chat() now accepts tools, forwards them in the stream payload, and accumulates fragmented delta.tool_calls into a tool_calls event
tests/helpers.py - shared make_chat() builder for ChatLoop tests
tests/test_agent_loop.py - loop-level tests: no-tools single round trip, thinking excluded from persisted content, error bail, empty stream
ToolRegistry.register() accepts refine metadata, refine_required() lookup added - web_search registered with refine=True, and _execute_tool_calls() now refines via metadata instead of special-casing the web_search name
_TextExtractor class in tools/builtins.py - stdlib HTMLParser-based text extraction for fetch_page
Test test_empty_stream_falls_back_to_nonstreaming_content - verifies fallback saves non-streaming content when streaming returns empty
Session.to_dict() filters out role: tool messages - session files only contain REPL-visible messages (user, assistant, command, system)
Final assistant response missing from session when streaming returned no tokens - fall back to non-streaming result content when _stream_response() returns empty
fetch_page returned raw HTML/JS/CSS noise - replaced regex tag-stripping with _TextExtractor (HTMLParser) that drops <script>, <style>, <svg>, <noscript> and extracts clean visible text
Session files stored raw tool-result content that was never displayed in the REPL - filtered role: tool messages from serialized output, assistant tool_calls (web_search, fetch_page declarations) still documented
3 new test cases: empty-content, multiple tool calls, and API error path (now 7 tests total)
Tool-call messages (assistant tool_calls + tool results) lost when streaming produced empty content or an exception occurred - wrapped _chat_with_tools() and _stream_response() in try/finally so session is always persisted
_chat_with_tools() no longer skips _stream_response() when chat_nonstreaming returns empty/null content - final response is always streamed
Session file rename left JSON name field stale - added session_auto_save() after rename so the file always has the correct session name
Auto-session naming: first user message auto-names the session (sanitized, truncated to 40 chars). Renames the .json file on disk
Markdown-aware streaming: code blocks in cyan, inline code in green, bold text rendered with ANSI bold. Disabled by default (markdown_streaming: false). Enable via config.
Thinking/reasoning token detection: provider-level reasoning_content field (DeepSeek R1, o1, o3). Configurable via show_thinking (default true)
Error handling: _post(), chat_nonstreaming(), chat(), _chat_with_tools(), and list_models() now catch HTTPError (auth 401, server 500) and URLError (network, timeout) gracefully - errors print in red, REPL continues
_chat_with_tools(): when no tools are used, now calls _stream_response() for live token output instead of dumping the full response at once
query_refine config system: when enabled, short web_search queries of up to query_refine_min_words words are auto-refined via a lightweight model call with query_refine_context recent messages as context. Configurable via query_refine, query_refine_min_words (default 3), query_refine_context (default 4)
tool_status_visible config option (default true): when false, hides dimmed [tool: args] status lines during tool execution
Mock test suite for tool calling (tests/test_tool_calling.py): 4 offline tests covering no-tools, single-tool, unknown-tool error, and force-search paths
Markdown streaming now off by default, the state machine was unreliable on real streaming output
Removed ... as thinking marker/closer (too many false positives in normal text)