Roadmap
Paths. Entries written before 2026-09-27 use the layout of the time (
cmd/blitz,cmd/blitz-desktop,internal/…,web/desktop,api/). The repository is now a monorepo: see the map in Development;internal/appbecamepkg/engine, the other engine packagespkg/engine/…, the shared onespkg/…, the REPLapps/cli/internal/tui, the serviceapps/service(blitzd).
Status key: ✅ done · 🔜 next · 📋 planned · 🔍 needs investigation first
Items 10–22 were added after the first plan and appear before item 9, which stays last because it needs a person. Open work and resume notes: Where to pick up.
Each item lists the problem, the approach, where the change lands, how it is tested, and a rough size (S ≤ half a day, M ≈ 1–2 days, L ≈ 3+ days).
Problem. llm.provider = "anthropic" was accepted by config but runtime.NewModel had no Anthropic branch, so startup failed. The ADK ships no Anthropic model.
Approach. A model.LLM adapter over the official anthropic-sdk-go (internal/runtime/anthropic.go):
- genai ⇄ Messages API translation: system instruction → cached
systemblock; function declarations → tools (JSON Schema, including conversion of genaiSchemaenums);FunctionCall/FunctionResponse⇄tool_use/tool_result(parallel results merged into one user turn); thinking blocks round-tripped unchanged (text + signature inThought/ThoughtSignature). - Streaming (partial text events, then one aggregated final event, matching the Gemini path) and non-streaming.
- Usage mapped to genai usage metadata (cache reads counted as cached input) so
/costworks. - Sampling parameters only sent to models that accept them (current models reject
temperaturewith a 400). - Refusals surfaced as a safety finish reason; server-side refusal fallback enabled by default on
claude-opus-5/claude-fable-5-1(llm.anthropic.fallbacks:default, a model ID, oroff). - Credentials:
api_key, else the SDK’s own resolution (ANTHROPIC_API_KEY,ANTHROPIC_AUTH_TOKEN,ant auth loginprofile). Optionalbase_urlfor gateways.
Also fixed: the model name now resolves per provider (blitz.default_model if set, else llm.<provider>.model), which also affected OpenAI/Ollama.
Tests. Translation unit tests + an httptest fake of the Messages API covering tool loops, streaming SSE, thinking round trip, usage, refusal, and API errors. No paid calls in the suite.
Problem. --continue, --resume (no ID), /session list and /resume completion used the most recent session from any project.
Approach. Sessions record their canonical workspace path. --continue/--resume pick the latest session for the current workspace; /session list shows this workspace (/session list --all shows everything with its workspace). An explicit --resume <id> from another workspace still works but warns. Sessions saved before this change have no workspace and only appear under --all.
Tests. Storage filtering, legacy records, selectSession scoping, REPL listing.
Problem. Compaction only runs when a prompt crosses context.token_threshold.
Approach. Investigate whether the ADK exposes a compaction entry point outside the runner. If not: run compaction.LLMSummarizer over the session’s events and append an event carrying EventActions.Compaction via the session service (the runner already honours these on replay). Add /compact [focus text] and a --compact flag for resumed sessions.
Files. internal/runtime/engine.go (new Engine.Compact), internal/tui/commands_extra.go.
Tests. Mock LLM summarizer; assert the next request’s contents shrink and contain the summary; persisted sessions replay the compaction.
Outcome. The ADK only honours compaction events when the runner has a compaction config, so one is now always set (with an unreachable threshold when automatic compaction is off). The cut is made at a user-turn boundary so a tool call is never separated from its result, and earlier summaries are fed to the summarizer so rolling compactions don’t lose history. No --compact flag was added; /compact covers resumed sessions.
Problem. MCP tools are only offered to the primary agent, and a server’s tool names can’t be namespaced (collisions are skipped with a warning).
Approach.
- Per-server
agents = ["blitz", "qa"](default: primary agent only) — pass the matching toolsets tonewLLMAgentfor sub-agents andInvokeSubagent. - Per-server
prefix = "gh": wrap each MCP tool in a delegating tool that renames it (gh__create_issue). Needs the ADK’s function-tool interfaces (Declaration(),Run()); confirm they’re implementable outside the module, otherwise build the declaration from the MCP tool schema and call the MCP client directly.
Files. internal/tools/mcp.go, internal/runtime/engine.go, internal/config/features.go.
Tests. In-memory MCP servers: sub-agent sees tools; prefixed names route to the right server; approvals keyed by the original name.
Problem. Tools are written to ~/.blitz/uc_tools but the registry of names/languages/descriptions is in memory, so they vanish on restart.
Approach. Write a <name>.json manifest beside each script; load manifests at startup (validate the name pattern, language allow-list, and that the script is inside the tools dir). Add action: "delete".
Tests. Create → new registry instance lists and runs it; tampered manifest (path escape, bad language) is ignored.
Problem. Only web_fetch exists.
Approach. A web_search tool behind a provider interface. Options: (a) Anthropic’s server-side web_search tool when the provider is Anthropic (no extra key, results cited); (b) a pluggable HTTP backend (Brave/Tavily/SearXNG) with web.search_provider + key for other providers. Results go through the same approval-per-host and SSRF rules for any follow-up fetch.
Tests. Fake backends via httptest; approval and deny-domain behaviour.
Outcome. Implemented option (b) — Brave, Tavily and SearXNG — which works with every provider. Anthropic’s server-side search (option a) was later dropped as not needed; Google search came instead (item 19).
Problem. Built-in prices are estimates for a handful of models and go stale.
Approach. Keep config overrides as the source of truth; show “estimated” in /cost; account for cache-write premiums (cache_write_per_mtok, reported by the Anthropic adapter); price by the model that actually served the response (refusal fallbacks); add a blitz doctor warning when the active model has no price.
Outcome. Done. Defaults cover the three default Gemini/OpenAI models plus Claude Opus 5, Sonnet 5 and Haiku 4.5; other models need [pricing] entries rather than guessed prices.
COMPARISON.md predates the sandbox, approvals, sessions and release work — rewrite it against the Python version; keep README’s config snippets in sync with config init.
Problem. No application log (warnings only reached stderr) and no tracing. The ADK already creates OpenTelemetry spans for agents, model calls and tools, but no provider was installed, so they were discarded.
Approach. internal/observability:
- Log:
slogJSON lines to~/.blitz/logs.Writenever blocks: records go into a bounded channel, and a single writer goroutine appends them, flushing once per batch. Drops are counted and reported. Secrets are masked, and trace and span IDs are added. It is the process’sslogdefault and is forwarded to OTel when telemetry is on. - Telemetry: off by default;
[telemetry]orBLITZ_TELEMETRY=1. Blitz builds the OTLP/HTTP trace and log providers itself rather than callingadk/telemetry.New, which would add its own unfiltered exporter fromOTEL_*variables. The ADK attaches tool arguments and results to everyexecute_toolspan even with content capture off, so a filtering exporter removes content attributes (or masks secrets in them withcapture_content) on the batch goroutine before export.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENTis forced off unless content capture is on. The default endpoint ishttp://localhost:4318(the Go exporter would use https). Shutdown is bounded to 3 s. - Spans:
turn(inEngine.Execute: agent, model, tokens, cost),approval(separates user wait from tool time),hook <event>,compact. - Sessions: each turn is its own trace root. One trace per session would fail: spans only export when they end, sessions last days, and
--resumecontinues in another process. Turns carrygen_ai.conversation.id(the key the ADK uses) andturn.index, plus a span link to the previous turn. That turn’straceparentis saved in the session’s metadata (last_turn), so the chain survives resume. Nothing is saved with telemetry off.
Constraint. Install telemetry once per process: the ADK binds its tracer to the first global provider.
Tests. Log: concurrency, masking, drops without blocking, pruning, off, trace IDs. Exporter: content dropped or masked, log records masked and linked. Real OTLP/HTTP export to an httptest collector. An ADK run whose tool span nests under turn without file content; three turns chained across a simulated resume. Also smoke-tested with the binary against a local collector.
Cost. About 1 MB of binary; the stripped darwin/arm64 build is 59.4 MB.
Problem. afterTool ran post_tool hooks inline, so each tool call waited for them (up to 30 s per hook) even though they can’t change the result.
Approach. PostTool encodes the event right away (a snapshot, so later changes to the result maps can’t race with the hook), then queues it for one worker goroutine. The worker starts only when a hook first matches, and a single worker keeps events in order. The queued job drops the turn’s cancellation but keeps its trace, so hooks run after the turn ends or is interrupted and their spans still sit under the tool. The queue is bounded: when full, events are dropped (one warning, logged and audited) rather than blocking the agent. Registry.Close drains the queue for up to 5 s, then kills what is still running. pre_tool and prompt_submit stay synchronous because they can block.
Tests. Returns immediately with a slow hook; order and snapshot over 20 events; runs after the turn context is cancelled; worker only for matching tools; Close drains, stops hung hooks, and is idempotent; a full queue drops without blocking and warns once.
Findings.
- The Gemini SDK doesn’t retry unless configured, and it wasn’t: one 429 or 503 failed the turn. The Anthropic and OpenAI SDKs retry twice by default.
- No provider had timeouts, so a hung connection or stalled stream blocked a turn forever. In CI there’s no Ctrl+C to recover.
- A crashed MCP stdio server could never reconnect. The SDK’s
CommandTransportwraps oneexec.Cmd, which can only be started once (exec: Stdout already set). It also bypassed the process guard’s startup step. - A down MCP server was contacted, with no timeout, before every model call, and printed a warning each time.
- The ADK runs all tool calls of a response at once, with no upper limit.
Changes.
- Model requests (
internal/runtime/httpclient.go): one retry budget for all providers (llm.max_retries, default 3). Gemini getsHTTPRetryOptions(backoff from 1 s up to 30 s); the other two SDKs keep their own backoff andRetry-Afterhandling. A shared HTTP client sets 30 s dial and 15 s TLS timeouts, and a stall timeout (llm.stall_timeout_seconds, default 600) on response headers and on gaps between body reads, so steady streams are never cut off. - MCP: a stdio transport that starts a fresh guarded process on each connect and kills the previous one. A circuit breaker per server: it opens after 2 consecutive failures, skips the server for 15 s doubling to 5 min, then allows one trial call. Tool errors reported by the server don’t count against it. Listing times out after 30 s, calls after
timeout_seconds(default 300). Warnings go out on the first failure, on pause and on recovery. - Parallel tool cap: an ADK
TaskRunnercaps each batch of tool calls (tools.max_parallel, default 8). The cap is per batch, so a sub-agent’s calls running inside a parent’s call can’t deadlock. - Hook worker: a panic is contained to one event and logged.
Not changed. A stream that fails mid-response isn’t retried (the text is already on screen). There is no model fallback (Python’s round-robin), and session files aren’t fsynced (a crash loses nothing the OS already has; a power loss can lose the last event).
Tests. Stall, steady stream and header timeouts. For each provider: a server that fails twice with 529/503/429, then 3 attempts with max_retries = 2, 1 attempt with 0, and no retry on 400. Breaker state machine on a fake clock. A down server is skipped after 2 attempts and recovers after the cooldown. The test binary runs as a real stdio MCP server: a crash is followed by a new process, a slow call times out, and a tool error keeps the server healthy. The restart test was confirmed to fail with the old transport. Runner: concurrency cap, every task runs, nested batches don’t deadlock, and the engine really serialises with max_parallel = 1. Worker survives a panic.
Problem. A long turn couldn’t be corrected without Ctrl+C, which throws the turn’s work away. Python lets you press Ctrl+T and type a message that the model sees before its next call.
Approach.
- Delivery (
runtime.Engine.Steer): messages are queued per session and attached to the next tool result asmessage_from_user. The obvious alternative, adding a user message in a before-model callback, doesn’t work: ADK callback contexts return nil fromSession(), so the message would reach one request and then vanish from history. Tool results are recorded by the ADK in order, so a steer is seen by every later call, survives compaction and--resume, and avoids provider rules about message order. A failed tool’s error is kept. Sub-agent tools (other sessions) don’t take the parent’s messages. Anything left when the turn ends is returned byTakeSteers. - Keyboard (
internal/tui/keywatch*.go): the line editor can’t abandon a read, so a key watcher owns the terminal between prompts during a turn. It switches off line buffering and echo but keeps signals, so Ctrl+C works as before, and disables macOS’s Ctrl+T STATUS key. It usesselect(2)with a 50 ms timeout, sincepoll(2)doesn’t work on macOS terminals. Typing or Ctrl+T opens a steer prompt pre-filled with what was typed, with printer output held back until Enter. The spinner returns only if it was showing. Approval and question prompts pause the watcher, and an open steer prompt finishes first. The editor only reads stdin when a prompt asks, so the two never race. Other platforms: the watcher exits and turns behave as before. - REPL: steer messages pass
prompt_submithooks and are audited and recorded like prompts. A message that arrives after the model’s last tool call is sent as the next prompt, or reported as not sent if the turn was interrupted.
Not done. The shared event stream proposed alongside this: steering didn’t need it, and audit, hooks and traces are already fed from engine callbacks.
Tests. Engine: delivery on the next tool result, history across turns and in session events, failed-tool errors kept, leftovers returned, scoping to the session and away from sub-agents. Watcher (a channel-backed fake terminal): triggers, ignored keys (Enter, arrow keys), pausing for prompts with no lost keys, pause waiting for an open steer prompt or giving up on its context, and the terminal mode restored. Printer: output held and flushed in order; spinner rule. REPL with a real engine and a fake keyboard: mid-turn delivery, a late message becoming the next prompt (recorded once), and a hook blocking a message. Also driven for real through a pseudo-terminal (script) against a local fake OpenAI server: the second request carried the message.
Done.
!<command>runs a command as the user: bash (or sh;cmdon Windows) in the workspace, with the terminal attached and the full environment. It isn’t an agent action, so the sandbox, command policy and approvals don’t apply. It’s recorded in the audit log (user_shell) and the diagnostic log, and isn’t shown to the agent (as in Python). Ctrl+C reaches the command; the REPL swallows its own copy of the signal so it doesn’t count as “exit” at the next prompt./plan <goal>and--plan(one-shot). Python only asks the model not to use tools. Hereruntime.WithPlanOnlyrefuses every tool outside a read-only list (read, list, grep, view_image, web_fetch, web_search, list_agents, invoke_agent, skills, ask_user_question). Sub-agents run within the same run state, so they’re restricted too; MCP tools are refused because their effects are unknown. The transcript records/plan <goal>, and a goal beginning with/is sent to the agent, not run as a command./toolslists the active agent’s tools (● marks those allowed in/plan) and the MCP servers offered to it./showis an alias for/setwith no arguments.
Not ported, and why.
/cd: in Go the workspace is the sandbox root (os.Root, Seatbelt/bubblewrap profile, blocked paths), plus project memory and session scoping. Changing it mid-session means rebuilding all of those;blitz -d <dir>does it safely at start. Revised 2026-09-26: sinceapp.Openbuilds everything per workspace,/cdis planned as close-and-reopen (spec_parity_027 §4.5)./truncate N: deletes history./compact [focus]shrinks the context without losing what was decided./dump_context,/load_context: added later as/session saveand/session load <name>(item 18)./tutorial,/add_model,/refresh_models: belong with a model catalog, which isn’t planned. (/pin_modeland/unpincame with item 16,/model_settingswith item 17.)
Tests. Plan mode refuses edits and still reads, covers sub-agents (the sub-agent’s refused create_file is checked, not just the missing file), and leaves normal turns alone. /plan usage, goal recording, a goal that starts with /, --plan in JSON output and its flag validation. ! runs in the workspace, reports exit codes, and isn’t recorded as a prompt. A stale Ctrl+C after ! must not end the session; this test was confirmed to fail without the fix once input arrives with realistic timing. /tools marking and MCP scoping; /show equals /set.
Config. llm.fallback_models = ["provider/model", …], tried in order. A bare name uses the primary’s provider. The prefix must be a known provider (gemini, anthropic, openai, ollama), so OpenRouter names are written openai/anthropic/…. It’s named fallback_models so it isn’t confused with llm.anthropic.fallbacks, Anthropic’s server-side refusal fallback.
Approach. NewModel builds the primary and wraps it in a fallbackModel when fallbacks are configured, so /model <name> keeps the chain. A fallback that can’t be built (e.g. no credentials) is logged and left out; doctor shows why.
- When to switch: only on an error before any output, and never on cancellation. Errors aren’t classified further, because SDK error types differ and failures such as unknown-model 404s or schema quirks are provider-specific. An error after output was yielded ends the call as before.
- Breakers: each model gets a circuit breaker (threshold 1, since SDK retries already ran). It’s moved from
internal/toolstointernal/breakerso MCP and models share it, withAbandonadded for cancelled trials.Allowis asked just before each attempt, because asking all breakers up front would reserve and strand a half-open trial on a model never tried. If every breaker is open, all models are tried rather than none. - Responses: a fallback’s responses carry
ModelVersionandblitz_fallback_from. The engine prices usage by the answering model and sends one notice on switching and one on recovery (WithNotice). doctor: checks the primary and each fallback on its own, since the chain would make a dead primary look healthy. A broken fallback is a warning.
Also fixed. provider = "ollama" without base_url sent requests to https://api.openai.com/v1, the [llm.openai] default, with the key “ollama”. Ollama now uses localhost unless a different base_url is set, and never receives the OpenAI key.
Not done. Per-agent model pinning (/pin_model) and per-model settings (/model_settings), from Python; done later as items 16 and 17.
Tests. Takeover, cooldown, trial and recovery on a fake clock. No switch after output or on cancellation. Everything failing, and a retry with every breaker open. Two regression tests, each confirmed to fail against the bug it guards: a cancelled trial is released, and an untried backup’s trial isn’t stranded. ParseModelRef, including OpenRouter and Ollama tags. The Ollama endpoint fix. A real NewModel chain from a failing OpenAI server to an Anthropic one. Engine notices once per change.
Approach.
- Precedence: a pin in
[agent_models](agent →"provider/model") wins over the agent’s owndefault_model(frontmatter, which was parsed but never used before), and both win over the configured model. Each model is built byNewModel, so pinned agents keep the fallback chain. A pin naming an unknown agent, or a model that can’t be set up, is warned about and ignored. - Engine:
WithAgentModel/PinModel/Unpinkeep a model per agent, andnewLLMAgentuses it for the root agent, sub-agents andinvoke_agent.ModelNamereports the active agent’s model. Usage without a reported model version is priced by the calling agent’s model (ctx.AgentName()), not the active agent’s, so a pinned sub-agent’s tokens are billed right. - Provider-aware references everywhere:
NewModelparses"provider/model"for/model,default_modeland pins, so/model anthropic/claude-sonnet-5switches provider. - REPL:
/pin_model [<agent> <model>](no arguments lists pins),/unpin <agent>(returns to the agent’sdefault_modelif it has one), a 📌 marker in/agents, a note from/modelwhen the active agent is pinned, and completion for both commands. Pins are saved withconfig.SaveAgentModel, which shares the new comment-preservingeditConfigFilewithSaveUILocaleand can remove keys.doctorchecks each pin.
Not done. Python’s /model_settings; added as item 17.
Tests. Pin, replace, unpin and quoted names in the config file, with comments and mode kept. Engine: a pinned sub-agent runs on its model, the main model isn’t called for it, and its cost uses its own price (the old pricing path would have used Gemini’s). Pin and unpin the active agent. Provider-qualified /model and default_model. REPL: pin, list, /agents marker, unpin saved, unknown agent, usage, the /model note, unpin back to default_model. Startup precedence and unknown-agent warning.
Approach.
- Config:
[model_settings."<model>"]withtemperature,max_tokens,top_pandseed, keyed by model name (aprovider/prefix is dropped, by the same rule asParseModelRef). A set value wins over the globalblitz.temperature/max_tokens; an unset one inherits them. - Where it’s applied: not in
newLLMAgent, as first suggested. That would give every model in a fallback chain the primary’s settings. InsteadnewProviderModelwraps each model it builds (settingsModel), so each chain member, pinned agent and/modelswitch applies its own entry on every call. The wrapper copies the request before changing it, because the fallback chain passes the same request to each model. The engine keeps the settings behind a mutex and puts a lookup in the context ofExecute,CompactandInvokeSubagent, so/model_settingschanges apply from the next call with no runner rebuild. Without a lookup (doctor) or an entry, the request passes through unchanged. - Provider limits: the ADK’s OpenAI adapter fails the whole request when
Seedis set, and current Anthropic models reject sampling parameters.SettingSupporteddrops those per provider (OpenAI/Ollama: no seed; Anthropic:max_tokens, plustemperatureon modelssupportsSamplingaccepts;top_pisn’t mapped because some Claude models reject it alongside temperature). The provider is taken from the built model’s type, soprovider = ""works too. Dropped settings are logged at debug level, and/model_settingswarns when they’re set. - REPL:
/model_settingslists models with settings./model_settings <model>shows each key, or where its value comes from (global, provider default)./model_settings <model> k=v …sets several at once and changes nothing if one is invalid;k=clears one key andresetclears them all. Saved withconfig.SaveModelSettings, which useseditConfigFileand removes the table when it’s empty. Completion offers the models in use and those with settings.
Not done. Python’s reasoning and thinking settings (reasoning_effort, extended_thinking, budget_tokens, …) and per-model retry strategies. There’s no per-model way to unset a global value (e.g. to send no temperature to one model while others get 0.2).
Tests. Validation ranges, and nothing changes on error. Save, replace and remove with comments and file mode kept, quoted names (gpt-4.1, qwen2.5-coder:7b), an empty table removed, and a real Load through modenv. A fallback chain where each model gets its own settings, the backup keeps the global temperature, and the shared request config is unchanged. That test was confirmed to fail when the wrapper edits the request in place. Passthrough without a lookup or entry. SettingSupported per provider. The engine applies startup settings (including a provider/ key), picks up a change on the next call, and removes settings. A sub-agent called with no run context gets its settings; confirmed to fail without the lookup in InvokeSubagent. The real OpenAI adapter sends temperature, top_p and max_output_tokens to an httptest server and no seed. REPL: list, set several, provider warning, atomic failure, bad pair, clear one, show with inherited values, reset saved. A hand-written "openai/gpt-5" table is edited rather than duplicated, and when both a bare and a prefixed key exist the bare one always wins. Also smoke-tested with the binary against a local fake OpenAI server: the model’s temperature replaced the global one, and the seed wasn’t sent.
Approach.
- Save:
/session save <name> [--force]copies the active session’s transcript (<id>.jsonl), metadata and the model’s event log (<id>.events.jsonl) to a new session ID. The new metadata carriesnameandfrom(the source ID). Data files are written first (owner-only,O_EXCL) and the metadata last, since the metadata is what makes a session visible, so an interrupted copy is never listed. The source stays active and unchanged. Names follow the ID character rules, are unique across workspaces, and can’t start withsession-or belatest, so a name is never mistaken for an ID or for--resume’s default. An existing name is refused unless--force, which deletes the old snapshot once the new one is written. An empty session is refused. Sessions in the old single-file format can be saved too. - Load:
Storage.Opentakes an ID or a name. A snapshot, whether given by name or by its ID, is never continued in place. Opening one copies it to a new, unnamed session (from= the snapshot) in the current workspace and makes that active. This follows Python, where/load_contextrotates to a fresh session so the snapshot stays a fixed point. The ADK session service replays the copied event log on first access, so the model sees the snapshot’s history, including tool calls and compaction summaries. An ordinary ID resumes in place, as before./session load,/resumeand--resume=<name>all useOpen. --continueskips snapshots. Found in the binary smoke test: a snapshot is the newest session in its workspace, so--continueresumed it and appended to it.- REPL:
/session listmarks snapshots with 📸 and their name./resumecompletion offers snapshot names.
Not done. No /session delete; --force replaces a snapshot, and files can be removed from the sessions directory. Usage and cost (/cost) start at zero in a session loaded from a snapshot, as they do after --resume.
Tests. Copying: files and permissions, name and from, the source stays active and keeps growing on its own. Unique names, and --force replacing (the old files are gone). Bad names, empty and missing sessions. Opening by name starts a new session with the transcript, and a new PersistentService replays the copied events. Continuing that session doesn’t touch the snapshot, and a second load starts another session from the same point. A snapshot opened by ID starts a new session; an ordinary ID resumes in place. A legacy source. That the event log copy matters was confirmed by breaking it (the replay test fails). REPL, with a real engine on a persistent session service: the model’s request after /session load contains the snapshot’s turn and not a turn added to the original afterwards; save errors (no session, empty, usage, taken, bad name); list marker; /resume <name>; /resume <id> in place; --force. CLI: --resume <name> starts a new session, and --continue skips a newer snapshot. Both regression tests were confirmed to fail without their fixes. Binary smoke test against a local fake OpenAI server: save from a piped REPL, continue the original, then --resume=fruit twice. Both requests contained only the snapshot’s history.
Backend. Google’s Custom Search JSON API is closed to new customers and shuts down on 2027-01-01, so search_provider = "google" uses Gemini’s grounding with Google Search instead. It calls generateContent with the google_search tool over REST (like the other providers, and testable against a fake server), using the Gemini key (search_api_key, [llm.gemini] api_key, GEMINI_API_KEY) and search_model. Results are the grounding chunks. Their links are Google redirects (/grounding-api-redirect/…), which web_fetch won’t follow to another host, so each is resolved in parallel by reading its Location without following it. One that can’t be resolved is kept, and /search web skips it. A chunk’s snippet is the answer text it supports, and Gemini’s answer (thoughts left out) comes back as answer. Open alternatives were weighed: SearXNG (already supported) can include Google results without a key; Marginalia and YaCy were left out.
/search web <terms>. Runs the configured provider as the user, with no approval prompt, since the user typed the query; it goes to the audit log as user_search. It asks for 10 results and keeps the first five readable ones: http(s), not a document or binary by extension, not an unresolved redirect, no duplicates (fragment ignored). The list is shown, then a turn starts with a prompt listing them (and the engine’s summary, marked unverified). tools.WithFetchGrants puts exactly those URLs in the turn’s context, so web_fetch reads them without asking (audited as user-selected). Any other URL, including another page on the same host, still asks. The transcript records the command, not the prompt.
/search session <terms>. session.Search matches words and “quoted phrases” case-insensitively in the stored transcript, which keeps what compaction dropped from the model’s context. Earlier /search lines are skipped. It ranks by distinct terms, then recency, keeps 12, and sends them oldest first with ±300-character excerpts cut at rune boundaries. With no match, the agent is still asked, with that stated.
Both run as read-only turns (runtime.WithReadOnly("search"): plan mode’s tool list, with its own refusal message). doctor reports the provider, or why it can’t be used, and --online runs a test search. Prompt builders live in internal/runtime (WebSearchPrompt, SessionSearchPrompt) beside PlanPrompt.
Not done. Google search charges aren’t in /cost. Search Suggestions (Google’s HTML widget) aren’t shown in the terminal. Vertex AI (project/location, no API key) isn’t supported for search.
Tests. Google: request path, key header, google_search tool, answer without thoughts, redirect resolved, a denied target dropped after resolution, an unresolvable link kept, snippets from supports; key and model config. User search with no approver, and unconfigured. Fetch grants: the exact URL (fragment ignored) passes without asking, another page on the host asks, and no grant asks. Transcript search: terms and phrases, ranking, skipping earlier searches, excerpts on UTF-8. Viable links. REPL with a real engine and no auto-approval: usage; five links shown and sent (no PDF, no duplicate, not the sixth); the handed-over page is fetched with no approver while another is refused; create_file is refused as read-only; the command is recorded. Session search with and without matches. The grant test was confirmed to fail without the grant. Binary smoke test against a fake SearXNG and OpenAI server: both commands reach the model with the expected prompts.
From the Antigravity CLI review (below): ask something without adding it to the conversation.
Approach. Engine.Aside copies the session’s events (through JSON, so nothing is shared) into a new in-memory session service and runs the question there with the same agent tree, through a separate runner. Compaction summaries in the copy are honoured, but the copy never compacts. The copy is dropped afterwards, so neither the persistent event log nor the next turn sees the question. The run state carries the real session ID, so usage is billed to it and hooks see it. It is read-only (btw is read-only refusals, via plan mode’s tool list). In the REPL, runTurn with aside skips the transcript, checkpoints and steering, and leaves /attach images for the next real prompt. prompt_submit hooks and the audit log still see the question.
Not done. /btw can’t be asked while a turn is running (steering covers that case), and answers can’t be kept afterwards; ask again normally to keep one.
Tests. Engine, with a persistent session service: the side question’s request has the history and the question; create_file is refused as read-only and writes nothing; the saved event log is byte-for-byte unchanged; usage counts the side calls; the next turn’s request has the history but not the question or answer. That test was confirmed to fail when the side question runs in the real session. A side question on a session with no turns. REPL: usage, the notice, the request, the next prompt, and a transcript without the side question (confirmed to fail when it is recorded).
Also from the Antigravity review. Every interactive session used to be titled “Interactive Session”, so /session list was hard to scan.
Approach. CreateSession with an empty title leaves it empty, and AddMessage names the session after the first user prompt (session.TitleFrom: its first non-empty line, whitespace collapsed, at most 60 characters). This applies to the REPL, /session new and one-shot runs; an explicit title is kept. /rename <name> sets it (Storage.Rename, saved to the metadata). Unnamed sessions show as “(untitled)”. With ui.terminal_title (default on, TTY only), the REPL sets the window title to “🐶 blitz --resume=<id>. The unused session.interactive_title / session.new_title strings and firstLine are gone.
Tests. TitleFrom (blank lines, whitespace, long UTF-8). Naming from the first user prompt only, an explicit title kept, rename saved and reloaded, an empty name refused. REPL: “(untitled)”, the prompt-derived name with escape characters removed, /rename usage and result, the window-title sequences and their reset, and the resume hint; none of them when the title is off and the session is empty.
Goal. Skills that ship Python scripts, and forged Python tools, get their own dependencies (requirements.txt / pyproject.toml) without touching the system Python. Where available, installs and runs happen inside gVisor, whose user-space kernel protects against kernel exploits, which bubblewrap and Seatbelt don’t.
Spike (2026-09-25, gVisor release-20260921.0, Ubuntu 24.04 arm64 VM, rootless).
- Install: releases are now a tarball (
gvisor.tar.bz2/.zstdplus.sha512) holdingrunscand agvisor-bin/directory that must stay beside it. The old per-binary URLs return 404. runsc dois unusable: it’s documented as for testing only. It wraps every mount, volumes included, in an in-memory overlay (Overlay: all:memory), so workspace writes never reach the host. A volume’s destination must already exist on the host, and mounting over a secret directory doesn’t hide it.runsc --rootless runwith a generated OCI config works (rootlesscreateisn’t supported, butrunis). The working layout:- The root is an empty per-run skeleton directory, with
--overlay2=root:memory./usr,/etc,/bin,/lib,/lib64,/sbinand the environment are bound read-only;/tmpis a tmpfs; only the workspace (run) or the environment anduvcache (install) are bound read-write. - Home directories and secrets aren’t mounted at all, so they don’t exist inside.
/etc/shadowis refused, and nothing written elsewhere reached the host. - Using the host’s
/as the root doesn’t work: a read-only root made the workspace bind read-only too, and a writable root let writes escape to the host.
- The root is an empty per-run skeleton directory, with
- Network: rootless allows only
noneorhost; gVisor’s isolated network needs root. Withhost, the config must drop thenetworknamespace thatrunsc specadds, or the sandbox gets an empty namespace (“Network is unreachable”). - Timings:
- Sandbox start: 40–70 ms.
uv venvplus a wheels-only install of numpy and requests: 2.6 s from a cold cache, 0.31 s warm (0.06 s without a sandbox).- Running a script that imports numpy: about 280 ms versus about 90 ms bare. Almost all of it is file access during imports (185 ms versus 68 ms); computation runs at native speed.
uvdownloaded a managed Python 3.13 inside the sandbox in 3.2 s, leaving the system Python untouched.
- Enforced at run time: the environment was read-only (“Read-only file system”) and the network was off.
- Cleanup: state doesn’t accumulate after normal exits, and killing
runsc runstops its sandbox, leaving only a “stopped” record untilrunsc delete. But the sandbox processes aren’t children ofrunsc: when only the parent that startedrunscdied, the sandbox kept running. The process guard must reach them (their process group or a cgroup), or userunsc kill/deleteon exit and timeout. runsc bwrap(bubblewrap-compatible CLI) exists, but it lacks--dev-bind,--remount-roand--die-with-parent, and rootless it needsnewuidmap(theuidmappackage). It’s not a drop-in for Blitz’s bubblewrap arguments.runsc sandboxexectakes aSandboxOptionsproto, and there’s a Go package (gvisor.dev/gvisor/sandboxexec/sandbox, untagged, still needsrunsc). Not tried; the OCI route is enough.
Plan.
- Environments in
~/.blitz/envs/<hash of requirements and Python version>viauv(managed Python forrequires-python), falling back topython3 -m venvandpip. - Installs are wheels-only by default (
--only-binary :all:, so no package code runs at install), with an approval that shows the package list, and hash-checked when requirements are pinned. - A sandbox backend interface with gVisor (Linux, when
runscis found and a test run passes) and the existing Seatbelt/bubblewrap, selected bypython.sandbox = auto | gvisor | os.doctorreports which is active. run_skill_script(skill, script, args)for skills, and arequirementsfield for forged tools./envsand/envs prune.- CI installs
runscon the Linux job.
The same gVisor backend could later run run_shell_command and stdio MCP servers (sandbox.shell = "gvisor"). macOS and Windows keep the OS sandbox.
Still to check: an x86_64 GitHub runner (the spike was arm64); how the process guard and cgroups interact with runsc’s sandbox processes; Python versions with no wheels for a package.
Design direction (2026-09-25): Castor skill definitions and host guardrails.
gVisor driven from Go. Use gvisor.dev/gvisor/sandboxexec/sandbox (New / Exec / Close). Each script run is a goroutine that owns its sandbox, with a context tied to the turn, a timeout, and a deferred Close on success, error, Ctrl+C or timeout. That fixes the orphaned-sandbox problem the spike found. Caveats: the package still starts the runsc binary (gVisor’s kernel is its own process), so the process guard must still cover it if Blitz is killed. The package is untagged, so pin a commit and keep the proven OCI-config route as the fallback.
Skills declare, the config caps. Skill frontmatter follows Castor’s castor.skills.v1.SkillDefinition (https://github.com/retail-cortex/castor/blob/main/docs/content/architecture/skill.proto.md). For any setting, the stricter of the skill’s request and the host config applies; a skill can never widen what the config permits.
| Castor field | Declared by the skill | Capped by the host config |
|---|---|---|
scripts[] (language, relative_path, entry_point, dependencies, timeout_seconds, environment_variables) |
what runs, with which packages | allowed languages; package allow/deny and index URL; wheels only; required hashes; maximum timeout |
tool_requirements[] (name, scopes like git:*, rationale) |
tools and command patterns needed | global allow/deny on tools and scopes, fed into the command policy |
execution_hints.environment_variables |
variables it wants | passthrough allowlist; nothing reaches a sandbox unless listed (the scrubbed-variable rules still apply) |
execution_hints.hitl_tier, allow_hitl_bypass |
how risky it says it is | a minimum tier; bypass never allowed by default |
| network (not in the proto yet) | whether scripts need it | off by default; allowlist of skills |
compiled_reference.sha256_hash |
hash of its contents | trust and pin skills by hash; refuse ones changed since approval |
The approval tiers map onto existing mechanisms:
- Tier 1 (read-only): runs automatically and is logged (plan mode’s tool list).
- Tier 2 (audited write): runs automatically with a checkpoint (
/undo) and an audit entry. - Tier 3 (mandatory approval): asks every time, with no “always allow”.
- Tier 0 (bypass): only honoured when the config allows it; otherwise treated as tier 3.
Config sketch (names illustrative):
[skills.policy]
min_hitl_tier = 2
allow_hitl_bypass = false
languages = ["python"]
sandbox = "auto" # gvisor | os | auto
network = "none" # none | allowlist
network_allow = ["gh-issues"]
env_passthrough = ["GITHUB_TOKEN"]
max_timeout_seconds = 300
trusted_hashes = []
[skills.policy.packages]
index = "https://pypi.org/simple"
wheels_only = true
require_hashes = false
deny = ["*-nightly"]
Frontmatter compatibility. Keep the Agent Skills spec fields unchanged (name, description, license, compatibility, allowed-tools, metadata); today’s parser only reads name, description, tags, version and author. Castor’s fields sit alongside under the proto’s JSON names. github.com/retail-cortex/castor is a public Go module (v1.0.0, v1.1.0), so frontmatter can be decoded into the generated types (YAML to JSON, then protojson), keeping Castor the single source of truth. Still to confirm: the generated package is in the v1.1.0 tag, and what its module pulls in.
Out of scope at first: remote storage_uri (gs://…), Gemini Files API resources, TypeScript scripts (they can reuse the environment design later).
Order.
- ✅ Frontmatter parsing, including the spec fields, and
[skills.policy]with its merge rules, fully unit-tested (S–M). Done 2026-09-26; see “Step 1 outcome” below. - ✅ The gVisor backend through the Go package, with the goroutine lifecycle and cleanup, plus the OS-sandbox fallback (M). Done 2026-09-26; see “Step 2 outcome” below.
- ✅ Environments,
run_skill_script,/envs(M). Done 2026-09-26; see “Step 3 outcome” below.
Step 3 outcome.
-
Scripts never write the workspace.
Checkpointsonly records changes made through the file tools, so direct writes would bypass diffs, approvals and/undo. Instead a script reads the workspace (mounted read-only) and, from tier 2 up, writes to.blitz/skill-output/<skill>/<script>-<time>/($SKILL_OUTPUT); the agent applies changes with the file tools. Tiers:- Tier 1: runs automatically and is audited; no writable path.
- Tier 2: the same, plus the output directory.
- Tier 3:
Approvewith no key, so it asks every time. - Tier 0: a granted bypass, audited as such.
A mode where scripts write the workspace directly would need workspace-wide snapshots; it’s left for later.
-
PyEnvs(internal/tools/pyenv.go). The key is a hash of the interpreter’s real path, the sorted and deduplicated requirements, the index and wheels-only. The environment is built inside theScriptBox: network on, writable only the environment and a shared cache.- With
uv:uv venvanduv pip install --index-url … [--only-binary :all:] -- <deps>, withUV_NO_CONFIG=1(the user’suv.tomlcan’t redirect it) andUV_PYTHON_DOWNLOADS=never. Withoutuv:venvandpip. - Markers. A marker file, written atomically and last, marks an environment usable; one without a marker is deleted and rebuilt. The marker also records the skills using the environment and when it was last used.
- Mounts.
MountsForadds the interpreter’s prefix when it lives outside/usr(e.g. Homebrew, a uv-managed Python).
- With
-
run_skill_script(internal/tools/skillscript.go):- Order of checks: the policy verdict, then the tier approval, then a separate install approval (
ActionNetwork, keypyenv:<key>, showing the exact install command), then the run. - Where the script comes from. A script on disk is located through
Skill.ScriptPath(os.Root, so no..or symlink escapes) and its skill directory is mounted, so it can import its neighbours. Built-in and inline scripts are copied to a temporary directory. - Environment:
PYTHONDONTWRITEBYTECODE=1,PYTHONNOUSERSITE=1,$SKILL_DIR,$SKILL_OUTPUT, the passed host variables and the script’s own variables.entry_pointruns the module under__skill__throughrunpyand calls the function; its return value is the exit code. - Output: stdout and stderr are capped at 64 KB each, and up to 50 output files are listed.
activate_skillnow lists the scripts, whether each is allowed, and why not.
- Order of checks: the policy verdict, then the tier approval, then a separate install approval (
-
The gVisor backend now hides blocked paths inside mounted directories: files are covered with an empty read-only file and directories with an empty tmpfs, reusing
expandBlocked. Before, a workspace.envwould have been readable by a script under gVisor. A test guards it and was confirmed to fail without the masking. -
The OS backend lists a read-only path only if it’s inside a writable one. Seatbelt lets read-only win over nested writable paths, which blocked the output directory inside the read-only workspace; the tests caught it.
-
/envslists environments (size, packages, users, last use, incomplete ones), prunes those no allowed script needs, and removes one by key. -
Tests:
- Tier 2: arguments, reading the workspace, and the environment (passed, withheld and host secrets); the workspace write is refused and the output file lands.
- Tier 1 and entry points: tier 1 has no output directory; an entry point’s return value is the exit code; inline scripts run.
- Refusals and tier 3: tier 3 asks with no key; TypeScript, missing scripts and unknown skills are refused; the install approval asks separately; timeouts stop the script.
/envsandScriptPath: list, prune and remove;ScriptPathrefuses a symlink escape.
Run on macOS (Seatbelt), in a Linux container (bubblewrap) and on an arm64 VM (gVisor). The opt-in tests (
BLITZ_PYENV_TESTS=1, which need the network) build a real environment from PyPI and run a script that imports it, on Seatbelt and gVisor. They aren’t in CI. -
Follow-ups:
- TypeScript scripts;
- scripts that write the workspace, with snapshots;
requires-pythonwith uv-managed interpreters (the proto has no field for it);storage_uriand resources;- running the opt-in tests in CI (they depend on PyPI).
Step 2 outcome.
-
tools.ScriptBoxruns one command in isolation from aScriptRequest: read-only and writable host paths (mounted at the same path), network on/off, environment, timeout, stdio.NewScriptBoxpicksgvisor,osorauto; there’s no unsandboxed fallback (ErrNoScriptBox). -
Environment. Both backends give a script only
req.EnvplusPATH, a privateHOME/TMPDIRandLANG. That’s stricter than shell commands, which inherit a scrubbed copy of Blitz’s environment. -
gVisor backend (
scriptbox_gvisor_linux.go,gvisor.dev/gvisor/sandboxexec/sandboxpinned atv0.0.0-20260926034703-66ed76bcb72f; the package only builds on Linux, so there’s a stub elsewhere):- Layout. Each run gets its own sandbox:
New, thenExec, then a deferredCloseoncontext.WithoutCancelwith a 15 s bound, so cleanup runs on success, error, timeout or cancel. The root is empty, the host’s binaries and libraries are mounted by sandboxexec, and/etcread-only,/tmptmpfs, and the request’s paths are added. Rootless comes from sandboxexec’s single-UID user namespace, with nonewuidmap. - Timeouts needed a kill. Killing the
runsc execclient left the command running inside and its output pipes open, so a timed-outExecwaited for the command to finish (30 s in the test). On timeout or cancel, the sandbox is now killed withrunsc kill. - Orphans. Sandboxes are named
cp-<pid>-<n>in a shared state directory (~/.blitz/sandboxes), and setup sweeps ones whose process is gone. This covers a Blitz killed beforeCloseran. - Setup checks.
runscis found viaRUNSC_PATH(which must exist),PATHor~/.blitz/bin. A/bin/truetest run, cached perrunscpath, decides whether gVisor is usable.
- Layout. Each run gets its own sandbox:
-
OS backend builds a Seatbelt/bubblewrap sandbox per request, with the request’s writable paths plus a private temp directory, and the blocked paths hidden.
-
Tests. The same behaviour for both backends:
- read the read-only path, write the writable one;
- writes refused to the read-only path and to a path outside;
- no inherited secrets, and a private
HOME/TMPDIR; - timeouts and cancellation return promptly.
gVisor also: unmounted paths (including
$HOME) don’t exist, it runs a gVisor kernel, no sandbox or bundle is left behind, and the sweep removes a dead process’s sandbox but not a live one. Selection is tested too. -
Where tested. gVisor tests ran on an arm64 Ubuntu 24.04 VM. The OS backend ran on macOS and in a Linux container with bubblewrap. CI’s Linux job now installs a pinned gVisor (20260921.0, SHA-512 in the workflow) and fails if the gVisor tests are skipped. Mutation checks: without the kill, the timeout test failed; without
Close, ten sandboxes were left behind; and the OS backend’s cancellation bug (a killed process read as a normal exit) was found by the tests. -
doctorreports the script sandbox and, inauto, why gVisor isn’t used. -
Not yet: anything that uses it. That’s step 3.
Step 1 outcome.
- Types, not a dependency. Castor publishes only
.protosource. Its Go code is generated by Bazel and not committed; its v1.1.0 tag declares the old module path (github.com/retail-cortex/skills); and the module pulls in gin, gorm and Postgres. Sointernal/skills/definition.gomirrorsskill.proto(Castor commit1ce880f5) with the proto field names as YAML keys. - Tiers use names, not numbers. The proto numbers its tiers one higher (tier 2 is enum value 3), so bare numbers are refused as ambiguous. The Go constants match the proto values, so an omitted tier is
TierUnspecified, never a bypass. An early draft got this wrong; a test now guards it. - Validation. A problem (a
relative_pathescaping the skill directory, not exactly one source, a duplicate or invalid name, an unsupportedstorage_uri, a missing language, a bad environment variable name) keeps the skill’s instructions loaded but blocks its scripts. A skill whose frontmatter doesn’t parse is now reported instead of silently skipped. - Content hash.
ContentHash: SHA-256 over every regular file in the skill’s directory (path, size, content, in order); symbolic links skipped; 64 MB cap; built-in skills included. Castor’s declaredcompiled_reference.sha256_hashis shown but not enforced, since its algorithm isn’t specified. skills.Evaluateapplies[skills.policy]as designed:- tier and bypass consent;
- network only for allowlisted skills that ask (
custom_hints.network, since the proto has no network field); - environment passthrough by glob, with withheld names reported;
- timeouts capped;
- languages,
deny_toolson tool ortool:scope, andtrusted_hashes; - dependencies: plain PEP 508 requirements only, names normalized per PEP 503, allow/deny globs, and
==pins whenrequire_hashesis set.
- Where it shows.
SkillPolicy.Problemscatches invalid settings (indoctorand at startup)./skills listsummarizes each skill’s scripts,/skills showgives every decision and the hash, anddoctorreports blocked skills. - Tests. Parsing, tier names, validation, the path containment check, the hash (stable, symlinks ignored, sensitive to edits), reporting unparseable skills, each policy rule, and
doctorand/skills. Mutation-checked: a bypass without the policy’s consent, and the network allowlist.
Goal. A Wails desktop app with a tab per workspace, alongside the CLI, without a second copy of the program’s logic.
Since v0.7.0 the desktop app is Electron (spec_electron_038); the Wails app below is removed from the repository (restorable from the v0.6.5 tag).
Decisions (2026-09-26).
- One engine service per user (revised the same day; it replaces “a process per tab”). A single
blitz servedaemon hosts every open workspace, because scheduled workers across workspaces are planned (item 24). Each workspace’s state must therefore be per workspace, not per process (see phase 5b). - The CLI attaches when a service is available, and otherwise runs the engine in-process as today.
tuiprograms against a backend interface with two implementations:*app.Workspace(local) and a Connect client (remote). A workspace lock keeps a local CLI and the service from owning the same workspace at once. - API: protos in
./api, Connect + buf.connectrpc.com/connectovernet/httpserves gRPC, gRPC-Web and JSON from one handler;buflints and checks breaking changes in CI; Go and TypeScript (protobuf-es, for the Wails frontend) are generated and committed, with a CI check that they’re current. A turn is a server-streaming call; approvals and questions arrive as stream events with an ID and are answered with a separate unary call, so no bidirectional streaming is needed. Typed errors map to Connect codes with details; clients localise. - The service listens on a Unix socket (
~/.blitz/run/, mode 0600), no TCP by default: it can run shell commands. - Layout per golang-standards/project-layout:
cmd/blitz(pure Go, cross-compiled) andcmd/blitz-desktop(Wails, cgo, built on each OS); code ininternal/; protos inapi/; frontend inweb/desktop; packaging inbuild/. - No Bazel. Wails already orchestrates the frontend and Go builds, and it needs host cgo libraries (WebKit, webkit2gtk) that Bazel can’t make hermetic. Pinned tools, lockfiles and the cross-host reproducibility check in CI cover the rest.
- Typed operations, not a command dispatcher. Slash-command syntax is a terminal concern and stays in
tui;internal/appexposes methods that return data and never print, which RPC handlers call.
Found. The engine was already UI-independent (runtime.EventHandler, tools.Approver, tools.UserPromptFunc; the shell tool resolves its directory against the workspace root). The logic that wasn’t lived in cmd/blitz/setup.go (wiring), tui/repl.go runTurn (the turn lifecycle: hooks, audit, checkpoint, recording, leftover steers) and tui/commands*.go (about 30 commands that act and print in the same place). The workspace is also the process’s working directory (-d uses os.Chdir; WorkspaceDir is "."; config.Load and ./agents read the working directory), which is fine for a process per tab.
Phases.
- (Superseded by 5b.)
config.Loadtakes the workspace directory instead of relying on the working directory. - ✅
app.Open.buildEnvisapp.Openandenvisapp.Workspace, withOpenSession(selection plus audit context),LoadAttachments, model pins and settings, memory reload and locale.appknows nothing of exit codes: an unresumable session is a*ResumeError, whichcmdmaps to exit code 2.Options.Modelinjects a model;cmd’s tests now open real workspaces around a mock.cmdkeeps flags,--dir, observability and output modes. - ✅
Workspace.RunandSteer. The turn lifecycle, once in bothtui/repl.gorunTurnandcmd’srunOneShot, isapp.Workspace.Run(ctx, sessionID, Turn, handler):prompt_submithooks (a refusal is a*BlockedError), audit, the checkpoint, recording both sides, plan/read-only/aside turns, and the steer messages the agent never read (TurnResult.Leftover).appcollects the model’s output itself, so printers no longer keep a transcript.Turn.OnAcceptedruns once hooks pass, which is where the REPL prints attachments and starts its spinner and steering watch;Turn.OnFinishedruns when the agent stops and before unread steers are collected, which is where the REPL stops the watch, so a message still being typed is sent next rather than left queued. Methods take a session ID rather than returning aSessionhandle, because/sessioncommands switch the active session under a running REPL. Small behavior changes: one-shot checkpoints are labelled like the REPL’s (60 characters), one-shot transcript save failures are now reported, and an@imagethat fails to load stops the prompt before hooks and audit rather than after.tui.Appgains aWorkspacefield alongside its individual fields; phase 4 removes those. - ✅ Typed commands in groups: agents and models; sessions; checkpoints and approvals; skills, envs and MCP; memory, locale and cost.
HandleCommandbecomes parsing plus rendering. Operations return plain structs and typed errors (never localised text), so each can become an RPC with a proto message later;tuiwords and localises the results. Names don’t collide with the accessorsOpenalready exposes (ListAgents, notAgents).- ✅ Agents and models (
internal/app/models.go):ListAgents,ActiveAgent,SetAgent,Model,SetModel,PinModel,Unpin,ModelSettings,AllModelSettings,UpdateModelSettings,Settings,Set. Errors:*UnknownAgentError,ErrBadModelRef,*InvalidSettingError,*UnknownSettingError,ErrInvalidAgency. Saving to the config file is part of the operation and reported asSaved{Path, Err}: the change applies even when saving fails.Options.NewModelreplacestui.App.NewModel;App.SaveAgentModelandSaveModelSettingsare gone, and tests read the saved file back instead of faking the save. - ✅ Sessions (
internal/app/sessions.go):ListSessions,ActiveSession,NewSession,LoadSession,SaveSnapshot,RenameSession,Dir;OpenSessionmoved here. They returnSessionInfoandMessage, not the storage’s*session.SessionRecord, so its fields (trace links, JSON tags) stay private. Errors:ErrNoActiveSession,ErrSnapshotNameTaken. The REPL’s own session handling (creating the first session, the window title, the resume hint,/btwand/searchtargets) goes through them too. - ✅ Checkpoints and approvals (
internal/app/changes.go):ListCheckpoints,Undo(which now audits the undo itself),SessionDiff,GitDiff(ctx, color),ListApprovals,RevokeApprovals,ClearApprovals. AnApprovalis structured (Kind,Subject,Dir,Always,Added) instead of the internal key format (cmd:<dir>\x00<command>); the key stays as an opaque ID for revoking.ErrUndoConflictre-exports the tools error. - ✅ Skills, environments, MCP and tools (
internal/app/extensions.go):ListSkills,Skill,SearchSkills,ListEnvs,RemoveEnv,PruneEnvs,ListMCPServers,ActiveAgentTools. ASkillInfocarries what the skill declares and the policy’s verdict on it and each script (tier, network, environment variables passed or withheld, reasons a script is blocked), so no client has to evaluate the policy itself.ErrScriptsDisabledwhen skill scripts are off. - ✅ Usage, context, compaction, memory, locale, images, search (
internal/app/context.go):SessionUsage,Context,Compact,MemoryFiles,ReloadMemory,AddMemory,AvailableLocales,SetLocale(ctx, input)(resolves the language itself),SandboxSummary,LoadImage,AddImage,SearchProvider,SearchWeb,SearchSession./search web’s fetch grants are aTurn.FetchGrantsfield instead of a context value, so any client can run a search turn. The attachment queue stays client state intui;!cmdstays client-side (it runs the user’s shell on their terminal). tui.Appis now just the workspace plus terminal state (input, printer, attachment queue, window title, interrupts). ItsCfg,Engine,Agents,Skills,Storage,Tools,Processes,SandboxSummary,ReloadMemory,LocalesandSetLocalefields are gone; tests reach internals through the workspace’s accessors. Messages for cases that can no longer happen (*.unavailable,session.disabled,common.not_available) are removed from the catalogs.- Left for phases 5–6:
tuistill uses three accessors (the images-enabled flag for/paste, the audit log for!cmd, the process manager for the exit prompt), andcmdwires approvals, questions and the completer through accessors. A service needs these as operations or callbacks: approvals and questions become requests the server sends to the client.
- ✅ Agents and models (
- ✅
app.Event.Runpassesfunc(app.Event)events instead of ADK events: one per text part (Partial,Thought, andRepeatfor final text that repeats streamed chunks), tool call (Partialwhen streamed) or tool result, each with itsAuthor. Deduplicating streamed text moved from the printer intoapp(relay), which still sees ADK event boundaries, so every client shows text once by showing partial chunks and non-Repeatfinal text. The printer and both JSON output formats consumeapp.Event; their output is unchanged. Approval requests and questions join the event stream with the service (phase 7), where they stop being callbacks. 5b. ✅ Per-workspace state. Nothing depends on the process’s working directory any more:-dnames the workspace instead of callingos.Chdir;app.Openresolvestools.workspace_dirto an absolute path first; trusted./agentsand./skills(AgentSearchPaths(dir),SkillSearchPaths(dir)), relativeallowed_paths,read_only_pathsandshell_writable_pathsresolve against the workspace;ExecEnv.Dirruns MCP servers and forged tools in the workspace instead of the inherited directory. The reply language is per workspace (SetLocaleno longer touches the process’s interface language, which belongs to the client; the REPL switches its own).Openowns theConfigit’s given, so each workspace needs its own copy. Left process-wide on purpose: the config directory (per user, never per workspace, for the security reason inconfig.Load;config.Loadstill setsMODENV_PREFIXbecause modenv reads only the environment, which is harmless with one config directory per process), telemetry and the diagnostic log, the gVisorrunscpath. A test opens two workspaces in one process from an unrelated directory and checks their agents and reply languages stay separate. - ✅
api/blitz/v1protos. Four files:turn.proto(theTurnrequest;TurnEvent, a oneof ofaccepted,text,tool_call,tool_result,approval_request,question,finished;Usage;ErrorInfo),session.proto(SessionService: sessions,RunTurnas a server stream,Steer,Approve,Answer, usage, compaction, transcript search),workspace.proto(WorkspaceService: phase 4’s other operations, plusListWorkspacesandCloseWorkspace),worker.proto(WorkerService, item 24). Every request names its workspace by absolute directory. Images travel by ID (their SHA-256) afterLoadImageorAddImage. Errors carry anErrorInfodetail (reasonsuch asUNKNOWN_AGENT,NO_ACTIVE_SESSION,PROMPT_BLOCKED,RESUME_FAILED,SNAPSHOT_NAME_TAKEN,UNDO_CONFLICT,HASH_MISMATCH;metadata; an Englishmessage), which clients word and localise. Tooling:buflint (STANDARD) and breaking (FILE);buf,protoc-gen-goandprotoc-gen-connect-gopinned in a separatetools/go.modand run withgo tool, so the main module gains only theconnectrpc.com/connectruntime; generated Go committed ininternal/gen;make protoandmake proto-check; CI runsproto-checkandbuf breakingagainst the previous commit. TypeScript generation (protobuf-es) waits forweb/desktop, where a pnpm lockfile can pin it. - ✅
internal/serverandblitz serve. 7a ✅:internal/serverserves all three services over Connect (Server.Handler), opening workspaces lazily through anOpener(oneConfigeach) and keeping them by symlink-resolved directory; handlers only translate between protos andinternal/app. Typed errors map to Connect codes with anErrorInfodetail (toAPI); errors inside responses (a failed turn, a partial undo) use the same reasons.RunTurnstreamsaccepted, text and tool events, thenfinished; images are kept per workspace by ID afterLoadImage/AddImage. Tests drive a real HTTP server through the generated clients. 7b ✅: a broker installs the server’s approver and question callback on each workspace;RunTurnputs the turn’s event sender in its context (ADK passes it through to tool calls), so a request becomes anapproval_requestorquestionevent with a random ID and waits forApproveorAnswer, or for the turn to end (a client that disconnects ends the turn and denies). A request made outside a turn, where nobody can answer (a future worker), is refused at once. 7c ✅:blitz servelistens on~/.blitz/run/blitz.sock(--socket), created inside a 0700 directory and chmod 0600; it refuses to start when a service already answers there and replaces a stale socket; HTTP/1.1 plus unencrypted HTTP/2 for gRPC clients; SIGINT/SIGTERM stop it gracefully (turns get 10 s), close every workspace and remove the socket. Each workspace it opens gets a freshly loadedConfig.app.Opennow takes an exclusive OS lock per workspace (flock,LockFileExon Windows;~/.blitz/locks/<hash of the canonical directory>.lock), released byCloseor the process’s death, so the CLI and the service never own the same workspace; the CLI says so (ErrWorkspaceBusy, exit code 2).server.Client,BaseURL,RunningandDefaultSocketare there for phase 8’s attach. Checked by hand as well:curl --unix-socketspeaks Connect’s JSON to it. Originally: Connect handlers overapp.Workspace, workspaces opened on demand by directory, the Unix socket, a workspace lock, approvals and questions over the turn stream. - ✅ Attach. 8a ✅:
app.Backendis the interface front ends drive: every operation they use, plusSetUI(approver and question callback),Processes(nil when the processes aren’t the front end’s),AuditShell(for!cmd),ImagesEnabled.*app.Workspaceimplements it;tui.App.Workspace, the one-shot runner and the completer take aBackend, and no front end reaches internal accessors any more. The interface language’s catalogs aretui.App.Locales(client state); the one-shot theme comes from the CLI’s own config. 8b ✅:internal/client.Remoteimplementsapp.Backendover the service (Attach(ctx, socket, dir, warn), orAttachHTTP). Failed calls come back asapp’s typed errors, rebuilt from theErrorInforeason (errors.Is/Aswork unchanged; sentinels keep the service’s message); callsBackendgives no error to report go towarnand return empty results.Runanswers the stream’s approval requests and questions through the UI set withSetUI. Images stay in the service; aRemoteimage carries its metadata andimages.Image.Size. The protos gained, additively:GetModelResponse.unavailable(forModelErr),GetSettingsResponse.images_enabled, andGetSearchProvider; the server now reports a busy workspace asWORKSPACE_BUSY. Known gaps, remote only:Processesis nil (background processes belong to the service and outlive the client),AuditShelldoes nothing (!cmdruns on the client), andOnFinishedruns when the service’s last event arrives, after it collected unread steer messages, so a message sent at that moment waits for the next turn. Tests run the client against a real server, and the REPL’s commands and a turn against a remote workspace. 8c ✅: the CLI (REPL and one-shot) attaches when the service answers on its socket (openBackendincmd/blitz/setup.go), and otherwise opens the workspace in-process as before;--localalways opens it in-process. An attached REPL says so. The socket is$BLITZ_SOCKETor~/.blitz/run/blitz.sock, forserveand the CLI alike. The interface’s catalogs are always the CLI’s own. Checked with the real binaries too: a piped REPL session attached toserveand ran/model,/pin_model,/agents,/session listin the service. Originally: a backend interface intui, implemented by*app.Workspaceand by a client adapter; the CLI attaches when the socket answers. Also moves the last accessor uses (the process list at exit,!cmd’s audit entry, the images flag, approval and question wiring incmd) behind operations. - ✅ Desktop app (first version). Decided (2026-09-26): Wails v2.14 (stable) with React and TypeScript; when the service isn’t running, the app offers to install it (
blitz service install) rather than running its own. 9a ✅:cmd/blitz-desktopis its own Go module (with areplaceto the main module; Go’sinternalrule is by import path, so it can useinternal/server): Wails’ cgo and WebKit needs stay out of the CLI’s module, build, tests and reproducibility check. The page isweb/desktop(Vite, React 19, TypeScript 5.9; pnpm lockfile), built intocmd/blitz-desktop/distbecausego:embedonly reaches below its package (a Vite plugin keeps the committed placeholder).wails.jsonpointsfrontend:diratweb/desktopandbuild:diratbuild/desktop(icons, Info.plist with bundle IDdev.blitz.desktop). A web view can’t open a Unix socket, so the app’s asset server forwards the page’s/blitz.v1.*requests to the service socket (serviceProxy, streaming withFlushInterval: -1) and the page uses the generated Connect client (@bufbuild/protoc-gen-es, pinned in the lockfile, generated intoweb/desktop/src/genbymake protoand checked byproto-check). The app’s bound Go methods cover what the service can’t:ServiceStatus,InstallService(runs theblitzfound next to the app or onPATH),ChooseWorkspace. First screen: the install offer, workspace tabs opened with a folder dialog, each showing its agent and model through the API. The Wails CLI is pinned intools/go.mod;make desktopbuildsbuild/desktop/bin/Blitz.app(macOS minimum 13, matching Go),make desktop-checkbuilds the page and vets and tests the module; CI builds it on macOS. The proxy is tested against a real service over a socket (a streamed turn with an approval). 9b ✅: each workspace tab has a session list (new session, open an earlier one) and a conversation: saved messages, then the live turn from theRunTurnstream; the composer steers while a turn runs (and the unread leftovers are sent as the next turn), Stop cancels it; approval requests show the diff and allow once, for the session, always, or deny; questions offer their options or free text; each turn ends with a usage line. How events become what’s shown (streamed text once, thoughts and streamed tool-call copies left out, results matched to calls) is a pure module,web/desktop/src/turns.ts, with Vitest tests run bymake desktop-checkand CI. Not yet in the app: slash commands (model, agents, settings, snapshots,/undo…), Markdown rendering. 9c ✅: each workspace tab switches between Conversation and Workers: the workers with their state and schedule; a worker’s page shows its schedule as understood, next run, permissions and limits after policy, content hash and problems, with “Enable as shown” (enables that hash), Disable and “Run now”, which streams the run live (the sameturns.tslogic) and then lists it with its refusals.make desktopbundles the CLI into the app (Contents/MacOS/blitz, where the app looks for it to install the service) and signs the app again ad hoc. Still to do: notarization (needs an Apple Developer account), releases of the app (a macOS job inrelease.yml), slash commands and Markdown in the app. Note:service installfrom the bundled CLI points the login item at the CLI inside the app, so moving or updating the app means installing again. Originally: a thin Wails shell that talks to the service, one tab per workspace. Needs notarization on macOS. Fixed 2026-09-26: the bundle isBlitz.app(Wailsname) with the executableblitz-desktop(outputfilename); with the executable namedBlitz, the bundledblitzCLI overwrote it on macOS’s case-insensitive file system (earlier the Makefile’s bundle path didn’t match, so the CLI wasn’t bundled at all).make desktopnow checks both executables are present;LSMinimumSystemVersionis 13.0 to match the build. - Workers (scheduled runs, ROADMAP item 24), run by the service. Their API is part of phase 6’s protos.
Goal. A workspace defines workflows the service runs on a schedule, unattended: dependency reports, nightly checks, triage.
Decisions (2026-09-26).
- Files:
workers/<name>/WORKER.mdin the workspace, a directory per worker so it can carry supporting files (likeSKILL.md); search paths configurable like[skills] paths. A workspace can have several. The service registers workers by scanning, and rescans on change (fsnotify). - Frontmatter:
name,description,schedule, optionaltimezone(default: the machine’s),agent,model,permissions,limits(max_turns,max_cost_usd,timeout),overlap(defaultskip),catch_up(default none). The body is the workflow, sent as the prompt. - Schedules: a cron expression, a descriptor (
@daily,@every 2h), or plain text (“Every two hours”, “Daily at 6 AM”, “Weekdays at 9:30”) parsed by a small deterministic parser into cron. Never interpreted by a model; unparseable text is an error. Listings show the resolved cron and the next run. Cron viarobfig/cron/v3(time zones,@every). - Trust: a worker runs only in a trusted workspace and only once enabled explicitly, pinned to the file’s content hash (like
[skills.policy] trusted_hashes). Any edit disables it until it is re-enabled; the service reports that. Cloning a repository never schedules anything. - Unattended actions: a worker gets what its
permissionsask for, capped by a host[workers.policy]. Anything else is refused and recorded, never left waiting: the agent is told why, and the run’s record lists what was refused. - Runs: each run is a new session named after the worker and its start time (visible, resumable, audited), with a run record: status, start, duration, cost, refusals, the session ID. Limits are required (or come from policy defaults), so a schedule can’t spend without bound. A run still going when the next is due is skipped by default; runs missed while the service was down are skipped unless
catch_up: once(once, when it’s back; a worker that never ran has none to catch up), and ticks missed while the machine slept collapse into at most one run; a global cap limits concurrent runs. - Service: workers run only while the per-user service runs, so it gets a launchd/systemd user unit to start at login, and a registry of workspaces with enabled workers to watch even when no client has them open.
- API (in phase 6’s protos):
WorkerServicewithListWorkers(schedule, next run, enabled, hash),EnableWorker(hash),DisableWorker,RunNow,ListRuns,GetRun, and watching a run over the turn event stream.
Steps.
- ✅ 24a, definitions (
internal/workers).Load(dir)readsworkers/<name>/WORKER.md(YAML frontmatter with unknown keys rejected, the body as the workflow); the directory name is the worker’s name.ParseScheduleaccepts five-field cron, descriptors (@daily,@every 90m) and fixed plain-text forms (“every N minutes/hours/days” with words or digits, “hourly”, “daily/weekdays/weekends/everyat - ✅ 24b, enabling and policy.
app.WorkspacegainsListWorkers,EnableWorker(name, hash)andDisableWorker, returningWorkerInfo(state, schedule as written and as cron, next run when enabled, effective permissions and limits, problems). Workers are found under[workers] paths(defaultworkers, relative to the workspace).workers.Storekeeps which workers are enabled at which hash in~/.blitz/workers.json(atomic writes; one store per process,app.Options.Workers); states are new, enabled, disabled, changed (edited since enabling: suspended) and invalid. Enabling requires the hash the caller reviewed to be current (ErrHashMismatch). Trust: explicitly enabling a worker at a hash is the trust decision; there is no separate per-directory trust setting, and scanning alone never schedules anything.[workers.policy]:allow(permission kinds; default all five), default and maximummax_turns(50/200),max_cost_usd(1/10) and timeout (30m/2h), andmax_concurrent(2);workers.Applydrops what the policy doesn’t allow and caps the rest, and says what it changed. - ✅ 24c, running workers. 24c1 ✅:
app.Workspace.RunWorker(ctx, name, manual, on)runs an enabled worker once: in a session of its own (its ownsession.Storage, so the workspace’s active session and anyone using it are untouched;Runbecame a wrapper overrun(..., storage)), with the workflow prefixed by a note that the run is unattended, its effective permissions enforced throughtools.Unattended(ctx, decide)(which bypasses auto-approval, remembered and saved rules and the interactive approver, and refuses questions), refusals recorded, and its limits applied (max_turnsthrough the turn,timeoutthrough the context,max_cost_usdchecked as events arrive). A second run of a running worker isErrRunInProgress. Finished runs go to~/.blitz/worker-runs/<workspace hash>-<worker>.jsonl(workers.RunLog): status (succeeded, failed, limited), duration, cost, model calls, session, refusals, error. A worker’sagentandmodelapply to its runs only (2026-09-26):runtime.WithAgentandWithModelbuild a runner for that run, leaving the workspace’s active agent and model alone; the model runs the worker’s agent even if pinned, and every unpinned agent; an undefined agent or unbuildable model fails the run, and listings report an undefined agent. Gaps: a worker’s file changes join the workspace’s/undostack; with telemetry on, the workspace’s and the run’s storage may both write the run session’s metadata. 24c2 ✅:server.WithScheduler(SchedulerConfig{Store, Runs, MaxConcurrent, Rescan})andStartScheduler(ctx)run enabled workers on their schedules withrobfig/cron, each in its own time zone. Workers are rescanned every minute (and after enabling or disabling) across the workspaces the store lists, so new, edited (now “changed”, so unscheduled) and removedWORKER.mdfiles are picked up; periodic rescans rather than file watching, for now. A run takes one ofmax_concurrentslots (a scheduled run waits, a manual one fails withTOO_MANY_RUNS); a scheduled run while one is going is recorded as skipped;catch_up: onceruns once on registration when a scheduled time passed since the last run.WorkerServiceis served:ListWorkers(one workspace, or every registered one),EnableWorker,DisableWorker,RunWorker(returns as the run starts),ListWorkerRuns(running ones first),GetWorkerRun, andWatchWorkerRun, which replays a running run’s events from its start (events aren’t kept after a run).serveshares one store and run log across its workspaces and starts the scheduler unless[workers] enabled = false.app.RunWorkertakesRunOptions(Manual,OnStart,OnEvent). - ✅ 24d, the CLI. 24d1 ✅:
blitz workers(list),workers enable <name>(shows the schedule as understood, the permissions and limits after policy, and the content hash, then asks;--yesskips the question; the hash shown is the one enabled, so an edit in between fails),workers disable,workers run <name>(runs now and prints the run as it goes, then its record),workers runs <name>. They go through the service when it’s running (client.Workers) and otherwise work in-process, as the rest of the CLI does. Worker errors that are the request’s fault exit with code 2. Found while testing:cmdtests left the developer’s API keys visible, so a local test run could call the real provider;isolatenow hides them. 24d2 ✅:blitz service install|uninstall|status: a launchd agent (~/Library/LaunchAgents/dev.blitz.service.plist,launchctl bootstrap gui/<uid>, restarted if it exits with an error, output in~/.blitz/logs/service.log) on macOS, a systemd user unit (~/.config/systemd/user/blitz.service,systemctl --user enable --now) on Linux, unsupported elsewhere. It runs the resolved path of the current binary, so it’s re-run after upgrades. A login item doesn’t inherit the shell’s environment, soinstallwarns about API keys set only there (it loads the config without them to tell), instead of writing secrets into the unit file.
The gaps and the owner’s decisions are in specs/spec_parity_027.md; this item records what is built, in §14’s order. Each part moves its requirements into the spec it extends.
-
✅
globtool (PAR-TOOL-01, S). Finds files by path pattern (**,*,?,{a,b}, nested braces capped at 64 alternatives) under the workspace or a given directory, newest first, 200 by default (max 1000). It walks through the file sandbox, so blocked paths and symlinks out of the roots never appear; dot and dependency directories are skipped unless the pattern names them (.github/**). Read-only, so plan mode allows it; every built-in agent has it. Tests:internal/tools/glob_test.go. -
✅ Cost and time limits for one-shot runs (PAR-CLI-01, S).
--max-cost-usdand--timeoutstop a one-shot run at the limit with exit code 3, like--max-turns; all three now require a one-shot prompt (they used to be ignored silently in a session). The limits live onapp.Turn(MaxCostUSD,Timeout) and are enforced in the one turn path, withapp.ErrCostLimit/ErrTimeLimit(andErrMaxTurns) as reasons; workers’max_cost_usdandtimeoutnow use the same code instead of their own. They travel over the API (Turn.max_cost_usd,Turn.timeout) and come back asCOST_LIMIT/TIME_LIMIT. Found on the way: an attached one-shot--max-turnsexited 1, becauseErrMaxTurnsreached the client asINTERNAL; it is nowMAX_TURNS(regression test ininternal/client). The cost check needs a priced model; the CLI warns when there is none. -
✅ Instruction-file compatibility, imports, rules and
/init(PAR-MEM-01–04, S–M).CLAUDE.mdandGEMINI.mdload likeAGENTS.md(the same content once),*.local.mdfiles are personal (warned about when git tracks them),@pathimports load files relative to the importing one (≤ 5 deep, never outside the repository or~/.blitz, never a blocked path like.env), and rule files in.blitz/rules,.agents/rules,.claude/rulesand~/.blitz/rulesload too. A rule withpaths:frontmatter isn’t in the prompt: the engine hands it out with the first successful tool call on a matching file, asproject_rulesin the tool result (the steering channel, ROADMAP 16), once per session./initandblitz inithave the agent write or updateBLITZ.md, then reload memory. Found while testing: rule roots came from the canonical workspace path but tool paths from the configured one, so on macOS (/var→/private/var) or any symlinked workspace no rule ever matched; both sides are now resolved (regression test confirmed failing before). The glob matcher moved tointernal/textutilfor sharing. -
✅ Permission modes (PAR-PERM-01/03/05/06, M).
default,accept-edits,plan,dont-askandbypass, per workspace, applied in the single approval gate (tools.Hooks.Approve) after unattended runs, which ignore the mode. Plan mode plans every prompt inWorkspace.Run(not the internal path workers use) and records the text as typed.bypassruns only while the OS sandbox is active, at start and when switching;auto_approve = truenow meansbypass, so without a sandbox it no longer skips approvals (Blitz starts indefaultand says why, anddoctorreports it) — the owner’s decision in spec_parity_027 §12.1.--permission-mode,[blitz] permission_mode,/mode, a[mode]tag in the prompt,SetPermissionModeandGetSettings.permission_modein the API. A test that ran shell commands with the sandbox off relied on auto-approve; it now approves explicitly. Shift+Tab cycling comes with the keyboard work (PAR-UI-01). -
✅ Permission rules (PAR-PERM-10–12, M).
[permissions] allow/ask/denywithkind(pattern)rules (shell,read,write,delete,web,search,mcp,skill,agent, bare tool names; Claude Code’sBash(…)/Edit(…)accepted), deny > ask > allow, in every mode. Enforced where each action already is: the command policy forshell(every sub-command, through wrappers; ask sets a newMustAskon the request), the approval gate for paths, hosts, providers and MCP tools (targets),web_fetchbeforeallow_domains,ApproveMCPbeforeauto_approve, the engine before a tool runs for tool/skill/agent denies, and blocked paths forreaddenies. Unattended worker runs get denies and asks as refusals, and allow rules never widen them./permissionslists and edits rules live (--saveedits[permissions]in place),--allow/--denyadd them for one run,doctorcounts them, and three RPCs serve them. Correction during testing: a test expectedwrite(docs/**)to matchdocsitself;docs/**means what’s under it (as in Claude Code), andwrite(docs/)covers both. -
✅ Custom slash commands, skills by name, bundled commands (PAR-SK-01/02, S–M).
internal/commandsreads Markdown command files from~/.blitz/commandsand the workspace’s.blitz/commands,.claude/commandsand.agents/workflows(namespaced by subdirectory), withdescription,argument-hint,agent,model,allowed-toolsandmode: planfrontmatter and$ARGUMENTS/$1–$9. A command runs as a turn (Turn.Command, expanded by the workspace so it works attached too), with the per-run agent/model overrides from 24’s worker work and a newruntime.WithAllowedTools. Every skill is also/<skill-name>;/review,/security-review,/simplifyand/verifyare bundled. Caught in testing: the test forallowed-toolsaccepted two outcomes, hiding thatBash(git *)was split at the space; the splitter now respects parentheses and the test asserts the one right answer. Command-file problems are warned about once, not on every lookup. -
✅ More hook events (PAR-HK-01/02, M).
session_start,session_end,stop,post_tool_failure,subagent_start/stop,pre_compact/post_compact,notification,permission_request, andprompt_id,transcript_path,permission_mode,agent,cwdon every event. The hook runner now returns anOutcome(block, decision, continue, context) instead of a reason, and the background queue carries any observe-only event, not justpost_tool.session_startandprompt_submitoutput goes to the agent with the prompt;stopcan keep the agent going (at most 5 times);permission_requestcan answer an approval after rules and modes. Not done:config_change(nothing watches the config file) and hooks on automatic compaction (no hook point in the ADK);doctornow checks every event’s hooks. -
✅ Reasoning effort and thinking budget (PAR-MOD-01, BL-ENG-01–03, S).
[model_settings."<model>"]takesreasoning_effort(minimal…max) andthinking_budget; the settings wrapper turns them into genai’sThinkingConfig(which the ADK’s OpenAI adapter maps toreasoning.effort), and the Anthropic adapter sendsoutput_config.effortandthinkingitself, raisingmax_tokensand droppingtemperaturefor a budget./effort,--effortandSetSetting effortset a session-wide effort over the models’ own. Caught in review:Engine.ModelSettingsread through the same lookup as requests, so the session effort showed up as the model’s saved setting (and/model_settingswould have saved it); Gemini 3 rejects a level with a budget and Gemini 2.5 takes no level, so both are now handled. -
✅ Esc, Shift+Tab, Ctrl+G (PAR-UI-01/02, completes PAR-PERM-02/05 in the REPL, S). The line editor (
ergochat/readline) blocks after a lone Esc waiting for an escape sequence, so its stdin now turns a loneESCread into a private rune; key handlers that must change or end the line inject bytes the editor reads next. Esc interrupts a turn (through the steering key watcher), is Ctrl+C at approvals and drops a steer message; Esc Esc clears the line (it opens/rewindonce that exists). Shift+Tab cycles modes and redraws the prompt, reachingbypassonly if the REPL started in it. Ctrl+G edits the line in$VISUAL/$EDITOR. Tested with the real line editor on a pipe that answers its cursor-position queries. Caught in testing: the editor tells the listener a read is starting with an empty line, which reset the tracked line, so Ctrl+G in a pre-filled steer prompt edited nothing. Also fixed a flaky hook test (it waited for 6 of 7 events). -
✅ Inline pickers (PAR-UI-11, S). A menu drawn under the prompt with the terminal’s normal flow (no full-screen framework): arrow keys, type-to-filter, Enter, Esc, and per-item keys, clipped to the width so it redraws in place.
/agent,/model,/resumewithout arguments, approvals and multiple-choice questions use it on a terminal; piped input keeps the line prompts, so scripts read the same. Ctrl+C is read as a key while picking (the signal key is off), so it can cancel the picker and interrupt a turn without a stray SIGINT reaching the next prompt. Caught in review: choosing the active agent re-opened the picker in a loop; the fallback asked on the line editor while holding its lock; a long question or a label with a newline would wrap and break erasing; the hard-coded “Answer:” is now translated. -
✅ Persisted checkpoints (PAR-SES-01, M). Checkpoints are kept per workspace under
~/.blitz/checkpoints/<hash>/(an index and content-addressed blobs, owner-only), within the size budget and 30 days, so/undoand/diffwork after--resume. Each turn records its session and its prompt’s index in the transcript, for/rewind;Rewindrestores all of a session’s changes from a prompt on as one checked operation, andDetachkeeps later rewinds right after the conversation alone is rewound./diffis now the active session’s changes across runs. Caught in testing: the runtime tests built registries on the real home directory and would have filled~/.blitz/checkpoints; they now run with a home of their own. -
✅
/rewind(PAR-SES-02/03, completes PAR-UI-01, M). Pick any earlier prompt (Esc Esc on an empty line, or/rewind), then restore code and conversation, either one, or summarize from or up to it. Prompts are recorded with how many events the conversation had, so the ADK event log can be cut there (atomically on disk, then rebuilt in memory) along with the transcript; steer messages and stop-hook requests are marked and aren’t rewind points. Files are checked and restored first, so a conflict changes nothing; the rewound prompt comes back in the input line. Refused while a turn runs in the session. Also in the API (ListRewindPoints,Rewind). Caught in testing: without re-tagging the checkpoints of a conversation-only rewind, rewinding the next new prompt would also have undone the kept changes; the test now covers that case. -
✅ Plans for approval and task lists (PAR-SES-20–22, PAR-PERM-04, M).
exit_plan_modepresents the agent’s plan through the question channel (so it works attached and in the desktop app): carry it out, carry it out accepting edits, keep planning, or type feedback to revise. An approved plan is saved to.blitz/plans/and carried out in the same turn, leaving plan mode.enter_plan_modelets the agent choose to plan first ([blitz] plan_review = agent-decides, the default;alwaysplans every prompt;never).todokeeps the agent’s task list, shown as a live checklist and sent asTurnEvent.tasks. The workflow tools go to every primary agent whatever its tool list. Commands withmode: plan(like/review) now run read-only with their own instructions instead of being wrapped as plans. Caught in testing: the planning and carrying-out runs’ text ran together (“Plan approved.done”); they’re now separated, as are stop-hook continuations. PAR-SES-23 (walkthrough view) is left for the desktop backlog. -
✅ Desktop app redesign (BL-DSK-01/02/20/21, the desktop parts of PAR-PERM-02, PAR-SES-20 and PAR-SES-23; spec_desktop_024 second version). A Material 3 interface with AI Studio’s layout: a navigation drawer (folding to a rail) with closable workspaces, their activity and a Recent list; a centred conversation with Markdown, folded thinking, grouped tool calls, a live task list, approval and plan cards, rewind and edit from any prompt, and a composer with permission-mode, effort and “plan first” chips; a run settings panel (agent, model, effort, generation settings, mode, agency, reply language, permission rules, standing approvals, context); a Changes view (diff per file beside the agent’s summary, the turns that changed files, undo); restyled workers; a Settings dialog (theme System/Light/Dark — System by default — density, thinking, the service, known workspaces); and editable workspace names, descriptions and colours kept with the open workspaces in
~/.blitz/desktop.json. The window reconnects by itself when the service returns, and keeps each workspace’s turn streaming while another is shown. Along the way: the API didn’t carryreasoning_effort/thinking_budgetin model settings or a message’s kind;CloseWorkspacecould hang on a turn waiting for approval and now refuses (TURN_RUNNING). Checked in WebKit with Playwright against a fake service (every state, both themes, narrow windows) and the real service (a turn, reconnecting, closing with undo).
The engine had bled into every front end: the REPL, the client and the desktop app imported internal/app, which builds the whole engine, for its types, and the desktop app linked the service’s code to find its socket. The owner decided on a monorepo of independent apps over shared packages, built entirely by Bazel (spec_monorepo_028); it reverses “No Bazel” of the day before.
- The contract (
pkg/api):Backendand the 117 values, events and errors that cross it, moved out ofapp,tools,runtime,sessionandworkersby a type-checked rewrite; engine errors re-exported byappare defined once.Backend.Processesreturns an interface, not the engine’s process manager. The socket’s rules went topkg/socket. - The layout:
apps/cli(blitz),apps/service(blitzd, its own program;blitz servestays as a hidden command running it, for login items installed before),apps/desktop(withweb/andpackaging/),pkg/engine(the facade, wasinternal/app) and the engine below it, the shared packages inpkg/, the protos inproto/. One Go module. - Bazel for Go: rules_go and Gazelle; the protos’ Go and Connect code generated (one package;
blitzv1connectmerged intoblitzv1), never committed; visibility and//tools:check_depsenforce the dependency rules; the macOS sandbox tests run outside Bazel’s sandbox. - Bazel for the page: rules_js builds and tests it and generates its TypeScript from the same protos; the desktop binary embeds it.
- Packages and releases: stamped versions; per-platform binaries (cgo for both Mac architectures);
Blitz.app(universal), the.deband the release archives (now withblz) built by Bazel; nogo (vet and staticcheck), rules_buf, govulncheck on binaries; CI and the release workflow rewritten on Bazel; Make, GoReleaser, the tools module andwails.jsonremoved. The archives are byte for byte the same from macOS and Linux: output directories are named after the target platform, since generated files’ paths reach the binaries.
Verified: every test on macOS and Linux (gVisor included), the desktop app on both, the .deb installed and removed on Ubuntu 24.04, the archives compared across the two. Not yet: a GitHub run of either workflow (MANUAL_VERIFICATION §39).
The owner’s review found the project not ready to release (spec_release_readiness_030). Done:
-
Licensing: the Apache header on every source file (checked in CI),
NOTICEunder Blitz’s name with Code Puppy’s MIT notice, generatedTHIRD_PARTY_NOTICES, and the license texts in every program (blitz license,/license,blitzd --license, the desktop app’s About) and package. -
Comments: every package and exported name documented, enforced by the
doccommentanalyzer, a lint test for the page, and buf’s comment rules. -
Specs brought up to date and checked in CI (
//tools/specs); Go tests moved to Testify, table-driven cases as subtests. -
The docs site: every document moved into
docs/, built by Hugo (rules_hugo, the Geekdoc theme) through Bazel and published to GitHub Pages: products, guide, architecture, shared packages, development, and about (performance, roadmap, history, specs). A link to a missing page fails the build. -
README, contributing and owners: the README short (what, why, install, quick start, building from source on each OS, common problems),
docs/CONTRIBUTING.md(also on the site),OWNERS.txtwithCODEOWNERSgenerated from it. -
Test coverage: measured in CI on every push (78.6% at first, with a floor of 78%; 94.9% after the coverage work of 2026-09-30, with the floor raised to 94% and the page’s TypeScript measured too), with a floor that fails the build if it drops, and a coverage page on the site.
Next: raise coverage where it’s lowest (the client, the desktop app’s Go side, secrets, the login item).
Google’s Antigravity CLI (agy) was compared feature by feature with Blitz Go, from a feature summary of its docs (https://antigravity.google/docs/cli/features/). The summary read as AI-generated, so its descriptions were treated as approximate.
Already covered.
| Antigravity | Blitz Go |
|---|---|
-c, --conversation <id>, /resume |
-C, -r/--resume, /resume, /session load |
-p (run a prompt, stay open) |
-p "…" -i |
--agent, /agents, /skills, /mcp, /plan |
same commands |
/diff, /context (overlays) |
/diff, /context (text) |
/codesearch |
the grep tool |
/config, /permissions |
/set, /approvals, .env.toml |
/rewind for files |
/undo, /checkpoints |
/goal (full autonomy) |
/set agency=extreme |
/clear//new, /quit |
/session new, /exit |
| terminal sandbox | Seatbelt / bubblewrap (sandbox.shell) |
| routing across Gemini/Claude/GPT | fallback_models, per-agent pins |
Adopted. /btw (item 20); /rename, the terminal title (/title) and the resume command printed on exit (item 21).
Candidates, in order of value.
/copy: the last reply to the clipboard (pbcopy,wl-copy,xclip,clip.exe, or OSC 52, which also works over SSH). S.--add-dir <path>: an extra writable directory for one run, at startup only. Adding one mid-session has the same problem as/cd: the file roots and OS sandbox profile are built once. S./grill-me <task>: a prompt mode like/planin which the agent asks clarifying questions (ask_user_question) before writing anything. S./fork [n]: a new session branched from an earlier turn, the non-destructive form of a conversation rewind;/session saveonly branches from the current point. M.
Rejected.
- Full-screen overlays (
/config,/keybindings,/statusline, interactive/diffand/contextgraphs): the REPL is line-based, and these need a different UI framework. Revised 2026-09-26: inline pickers are planned in the REPL and full-screen views in the desktop app (specs/spec_parity_027.md PAR-UI-11). /boost,/teamwork-preview: vaguely described multi-agent modes;invoke_agentcovers delegation. Revised 2026-09-26: deferred until background sub-agents and worktrees exist (spec_parity_027 §8, §12.5).--dangerously-skip-permissions:blitz.auto_approvedoes this deliberately; a one-word flag makes it too easy to enable by accident. Revised 2026-09-26: abypasspermission mode that runs only inside an active OS sandbox replaces both (spec_parity_027 PAR-PERM-05/06).- Automatic model routing by task complexity: hard to predict and to cost; ordered fallbacks and pins are explicit.
/open <path>:!$EDITOR <path>does it./usage(built-in manual):/helpand the README.- Not applicable: desktop app sync and export,
/credits,/logout, Gemini CLI migration, SSH sign-in tunnelling.
9. Real-environment verification — 🔜 needs a person; see Manual verification
Not automatable here; run once and record results in this file:
| Check | How |
|---|---|
| Gemini end-to-end | Real key: streaming, tool loop, /cost numbers, compaction at a low token_threshold |
| Anthropic end-to-end | Same, plus a thinking + tool-use turn and --resume of that session |
| OpenAI / Ollama | Tool calls, including Ollama’s text-encoded calls |
| Terminal UX | History, Ctrl+R, Tab (/, @path), multi-line, Ctrl+C at prompt/approval/turn, Markdown, spinner, resize |
| Real MCP server | npx @modelcontextprotocol/server-github (or filesystem) via stdio, sandboxed |
| Windows | Build runs; shell tools need Git Bash or WSL; document what’s unsupported (no OS sandbox / process guard) |
| First tagged release | Keyless cosign verify, SBOM attached, draft published |
- The command allow/deny lists are a guardrail; the OS sandbox is the boundary.
- Shell commands can read anything outside
blocked_paths. - On Linux, a blocked-name file created by a command is visible to that same command (bubblewrap masks paths that exist at start).
- File-tool symlink checks are resolved at call time; a concurrent swap between check and open is theoretically possible (
os.Rootstill prevents escaping the roots). /search webpre-approves the five URLs it hands over for one turn; a page that redirects to another host is refused, and the agent must request the target (with approval)./search webjudges readability from the URL; a document or script-rendered page without a telling extension fails at fetch time and is skipped by the agent./search sessionis a literal, case-insensitive match over the current session’s transcript, which holds prompts and replies but not tool calls or their output.- Google search (Gemini grounding) returns the pages Gemini cited, not Google’s ranked list; its per-query charges aren’t in
/cost; Search Suggestions aren’t rendered; Vertex AI credentials aren’t supported for search.