Repository navigation
v0.3.11
Removed
- Office document export — the
pptx/docx/xlsxconverters (src/office/) and the?to=download target are gone, along with the four crates that existed only for them (docx-rs,rust_xlsxwriter,ppt-rsand the test-onlycalamine). The dependency graph is the point: it carried four copies ofzipand, throughppt-rs→pdfrs, a whole PDF-rendering subtree (syntect,subsetter,ttf-parser,aes,cbc) for one feature. It now carries twozipcopies — the direct one, still used by the plugin installer and the RAG loader, and the updater's — andppt-rs/pdfrsare gone.write_reportstill writes and previews markdown and HTML, and HTML now downloads as PDF (?to=pdf) rendered by a real browser: the gateway holds oneBrowserPoolfor export, shared with the debugging bridge when it is enabled, and each export gets a fresh tab closed whether or not the render succeeds, with a 30-second ceiling. A browser is the honest engine here — the document is arbitrary agent-authored HTML, and a partial CSS renderer would not fail but produce a plausible-looking PDF with a broken layout. Without thebrowserfeature the export answers 501; with it and no pool yet, 503.
Added
-
video_generate, as the fourth media kind — the gap the hermes comparison actually records ("完全没有视频"). It rides the samegenerate_mediaabstraction as image, speech and transcription, with the configured provider's credential. Video is the first kind that is not one request: the vendor accepts a job, reports progress and serves the result when it is done, so the submit → poll → fetch loop lives in the provider, with a five-second interval and a ten-minute deadline (a job that never finishes must not hold an agent turn open for ever). The paths are the OpenRouter-style contract (POST /videos,GET /videos/{id},GET /videos/{id}/content), which is a real, already-configured syscity preset.duration,aspect_ratioandresolutiontravel as vendor hints, the way the image and speech knobs do. Two limits stated rather than implied: the path is not yet per-preset (a vendor whose media endpoint sits elsewhere needs that field before it works), andvideo_analyzeis deliberately absent — watching a video is a vision capability, not a generation one, and folding it in here would put two unrelated things behind one request type. The tool waits synchronously, so a turn that generates a video blocks for as long as the job takes. -
A media capability on the provider layer — the vocabulary, not yet the implementations.
image_generate,ttsandstteach grew their own path: a hardcodedhttps://api.openai.com/...endpoint and their own read ofOPENAI_API_KEY, which means media work ignores the configured provider, its credential and its fallback chain.src/providers/media.rsnow defines the kinds (Image,Speech,Transcription,Video), one request shape, and the three ways a vendor actually answers — inline bytes, a URL, or text — because modelling those as oneVec<u8>would make every caller guess.Provider::generate_mediais defaulted to a refusal in words ("this provider does not support speech generation"): most providers do not do media, and that is a better answer than a missing method or an opaque failure. The OpenAI provider now implements image, speech and transcription on it, through the same gateway client chat uses — so media gets the configured credential, retry, timeout and rate limiting instead of a second read ofOPENAI_API_KEY. Speech reads bytes frompost_json_streaming, which already existed; no new client method was needed. Transcription now goes through the same path too, via a multipart method on the gateway client whose parts are described rather than built — the retry loop takesFn, so a one-shot form could not be replayed, and the form is rebuilt from owned bytes on every attempt. The three tools now go through it.image_generate,ttsandsttno longer create their ownreqwest::Client, post to a hardcodedhttps://api.openai.com/..., or readOPENAI_API_KEY/DALL·E_API_KEYat all: they take the configured provider from the tool context and callgenerate_media, so media uses the same credential, retry, timeout and rate limiting as chat. Two limits stated plainly: it uses the active provider, not the router's fallback chain, and only the OpenAI provider implements media so far — so a deployment whose chat provider is, say, Gemini now gets "gemini does not support image generation" where it previously worked by readingOPENAI_API_KEYregardless of the configured provider. That is the honest consequence of making media follow the configuration: the old behaviour was a second, unconfigured path to one vendor. The vendor-specific knobs the tools already exposed — imagesize/style, speechvoice/speed, transcriptionlanguage/temperature/prompt — travel as documented vendor hints rather than being silently dropped.stt'sresponse_formatargument is gone: the abstraction always returns text, so the parameter no longer selected anything. With no provider on the context a tool says so instead of falling back to an environment variable, andttsstill falls back to the localsay/espeakbinary, which is not a vendor. The three tools are not re-plumbed yet; until they are, they still reach OpenAI directly. -
Named execution backends, reachable from the
shelltool. A deployment declares them under[security.execution_backends.<name>]—type = "docker"with animage, ortype = "ssh"with ahost— and the agent asks for one by name, never by image or host, so what is reachable is the deployment's decision and a call only chooses among those. The name travels to the tool through the same per-call context the fence posture uses. A name that is not configured is an error, not a quiet fallback to local: a command that meant to run elsewhere and ran here instead would have answered a different question, and the message names what is available so the next call can be right. A backend that cannot honour the fence (ssh) fails the call with its reason. The same argument and the same rule are onshell,processandcode_exec, so the three cannot disagree about what a missing backend means. A broken backend now says what is broken: a missing client program is reported as the backend being unusable ("thedockerbackend cannot run: thedockercommand was not found — is it installed and on PATH?") rather than as the local runner failing to spawn a program the caller never named, while a permission error keeps its own meaning; and a container is refused a working directory the fence did not grant, since it can only see what was mounted — the alternative is docker answering "no such file or directory", which reads like a typo. Thesshoptions are built in one place shared withcomputer::remote_control, pinned by a test, so the two clients cannot drift. -
Remote execution: a
dockerand ansshbackend behind the existingProcessRunnerseam (src/tools/process_runner/remote.rs). A request can now name where it should run —ExecutionBackend, defaulting toLocal, so nothing that does not ask changes — and selection is per request rather than a property of the process. The two backends answer the write-fence question differently, and that difference is the whole design. Docker translates it: granted roots become read-write bind mounts,deny_networkbecomes--network none, and the.git/.syscitycarve-outs become read-only mounts emitted after the roots that contain them, because a nested mount is what shadows an outer one. The host filesystem outside those mounts is not read-only but absent, which is stronger than the local fence can say — and an absent carve-out is shadowed with an empty read-only tmpfs, so a.gitthat does not exist yet still cannot be created. That last case is one Linux's own posture does not cover, since Landlock cannot subtract a subtree. SSH refuses it: there is no far-side kernel a fence could be applied to, so a fenced request is rejected with the reason and told to run locally or on docker — never run unfenced, because quietly dropping the fence would turn the one guarantee this project is built around into a suggestion. An unfenced request runs with every argument quoted, since the command is reassembled into one string and parsed again by the remote shell. What a remote backend cannot do is documented rather than left to be discovered: the seccomp escape-vector deny list,pre_execrlimits (an opaque closure, so there is nothing a container flag could be built from — dropped with a warning rather than refused, because the shell tool sets one unconditionally), process-group kill at timeout, and the escalation path keyed on the local fence's stderr. -
SimpleX Chat, as a channel — the fourth with no webhook route, and the one that is a client of a local daemon rather than of the platform: the gateway holds a WebSocket to
simplex-chat, and what travels on it is SimpleX's own command language wrapped in JSON ({"corrId": …, "cmd": "/_send @<id> json […]"}), with the message JSON-encoded inside the command string. Addressing is always by id — the display-name forms resolve at the daemon and silently drop when they do not, which is not something a reply should be able to do. Contact requests are accepted automatically, since otherwise nobody could ever reach the bot; that is not the access control, which runs on every message afterwards. SimpleX has no mention — in a group a sender cannot address the bot, so group messages arrive marked unmentioned, which makesrequire_mentionmean "say nothing in groups" (off by default, so a group the bot is in works). Only text is routed; images, files and voice notes are separate events carrying no text and are skipped, as are the file-transfer offers, which are not fetched. The daemon is application-silent for long stretches, so idle time is not mistaken for a dead socket — the keepalive is the WebSocket's own ping. Configure withSIMPLEX_WS_URL(defaultws://127.0.0.1:5225), thensyscity channels add simplex. -
IRC, as a channel — the third with no webhook route and the first that is not a WebSocket: IRC is a plain line protocol over a TCP socket, spoken directly. TLS is on by default on port 6697. In a channel the bot answers when it is named (
nick:,nick,ornick), and the name is removed before the agent sees the text; a message that does not name it is still delivered but marked unmentioned, sorequire_mentiondecides rather than the channel — and this is the one channel whose default for that setting istrue, because a bot sitting in a channel answering everything said in it is the wrong default. A private message is answered to its sender. The nickname asked for is not always the one handed back — a server that already has it returnsnick_, thennick_1— and it is the confirmed name that a channel message must be addressed to, so that is what the bot watches for. The message limit is bytes, not characters: IRC caps a whole line at 510 bytes including the command that carries it, so a long channel name leaves less room, and replies are split on that budget at character boundaries. A reply is itself a protocol line, so newlines and NULs are neutralised before sending — otherwise an agent's answer could carry a second IRC command — and incoming colour codes are stripped. Configure withIRC_SERVER,IRC_CHANNELandIRC_NICKNAME(plusIRC_NICKSERV_PASSWORDfor a registered nickname), thensyscity channels add irc. -
DingTalk (钉钉), as a channel, over Stream Mode — the second one with no webhook route, and its own shape again. Stream Mode opens no socket until an HTTP call hands one over: the gateway asks for a connection, gets an endpoint and a one-shot ticket, and only then connects — so a reconnect starts with that handshake rather than with the socket. Every delivery has to be acknowledged, before it becomes a turn and on the same connection, because an unacknowledged message is redelivered; a redelivery is acknowledged again without becoming a second turn. And a reply does not go back over that socket at all — it is an HTTP POST to a
sessionWebhookURL that arrives inside the message, which means the sender influences a URL the gateway then sends a request to, so it is checked against DingTalk's own hosts before anything is sent. That URL expires, which is the platform's own limit rather than this channel's: a bot can only answer a conversation it has heard from recently, and a reply after the window closes is refused with that reason. A group message's mention comes from the platform's own field rather than being scraped out of the text, sorequire_mentionworks here. Only text messages are routed, and replies over 20,000 characters go out as consecutive messages. Configure withDINGTALK_CLIENT_IDandDINGTALK_CLIENT_SECRET— with the app's robot set to Stream Mode — thensyscity channels add dingtalk. -
WeCom (企业微信), as a channel — and the first one with no webhook route at all. The gateway dials out to WeCom's AI Bot gateway and holds a WebSocket open; the same socket carries messages both ways, so there is nothing to expose publicly and nothing to point at the gateway. Inbound still goes through the tail every other direct-message channel uses, so access control,
/newand the session mapping that reply addressing depends on are shared rather than reimplemented — which is also why that tail grew a mention state: a group message is exactly one that is not a direct message, and the inbound stage reads that to decide whether an unmentioned group message should be suppressed. Without it a WeCom group's bot would answer every message in it. A reply is a frame that echoes the message it answers: every inbound message carries areq_idand answering means sending it back, which is what puts the answer in the right thread. That window closes, so the lastreq_idper chat is remembered and an expired one falls back to starting a message instead — a fallback a group does not have, because an AI Bot cannot start a conversation in a group, so such a reply is refused with that reason rather than sent into the void. The same rule means the channel can only answer a conversation it has heard from. The platform allows one connection per bot and a reconnect is a fresh authentication rather than a resumption, so the channel reconnects on a 2/5/10/30/60-second backoff and re-subscribes; if another connection takes the bot's place it stands down rather than fighting for the socket. Only text messages are routed — images, files and voice notes arrive over the same socket and are acknowledged and skipped — replies over 4000 characters go out as consecutive messages, and there is no typing indicator, because the bubble on this platform is the first frame of a streamed reply and this channel does not stream. Configure withWECOM_BOT_IDandWECOM_SECRET, thensyscity channels add wecom. -
Microsoft Teams, as a channel: a Bot Framework activity in, the Bot Framework connector out. It is the second bearer-token channel, and it needs a check Google Chat's does not. The token has to verify against the issuer's published keys and name your app as its audience — that is what says Microsoft minted it for this bot — and then its
serviceurlclaim — one word, lower case, which is the name both Bot Framework SDKs look up and the one the token actually carries — has to match the activity's, which is what says it was minted for this conversation surface. A token Microsoft legitimately signed for another tenant's connector passes the first check and fails the second, which is why both are there. The reply address is two values rather than one: a reply goes to{serviceUrl}v3/conversations/{conversationId}/activities, and the service URL is per-tenant (the regions differ by path —/amer/,/emea/,/apac/), so the pair travels joined in the conversation id. The service URL is held to an allowlist of Bot Framework hosts before a freshly minted token is attached to it, because that host is what decides where this bot's credentials go. Conversation updates, reactions, invokes and typing all arrive on the same URL and the bot's own activity would loop if it were answered, so only messages from people become turns; Teams'<at>…</at>mention markup is removed but the name inside it is kept, so a mention of a colleague does not lose their name on the way to the agent. Replies past 28,000 characters go out as consecutive messages, and the outbound half is configured separately from the inbound one, so a missing app secret warns without taking the verification down with it. Configure withTEAMS_APP_ID,TEAMS_TENANT_IDandTEAMS_CLIENT_SECRET(TEAMS_ISSUER/TEAMS_JWKS_URLdefault to the public cloud), thensyscity channels add teams. -
Google Chat, as a channel — the first whose webhook is authenticated by a bearer token rather than a signature, which is why it arrives with a shared verifier (
gateway::jwt_verify) and an outbound OAuth token source (channels::oauth_token) rather than a signature helper of its own. The token is verified against the issuer's published keys and then its identity is checked: Google signs these for its own service accounts, so a valid signature proves Google issued the token, not that it was issued for this app — theemailclaim is held to a configured allowlist, and an empty list refuses everything.issuer,audienceandjwks_urlare required rather than defaulted, because they depend on the app's Authentication Audience setting and a wrong guess would reject every delivery. Only user messages are routed: a bot's own reply arrives through the same webhook and answering it would make the bot talk to itself, and Chat's non-message events are acknowledged and skipped. Replies past 4096 characters go out as consecutive messages. The outbound half is configured separately from the inbound half — they use different credentials — and a missing service account warns without taking the inbound check down with it. -
Twilio SMS, as a channel: a form-encoded webhook in, the Messages API out. Its signature differs from every other channel's in one way that shapes the whole config:
X-Twilio-Signatureisbase64(HMAC-SHA1(auth_token, url + Σ key+value))over the URL Twilio dialled plus the parameters in key order — and that URL cannot be read back off the request, because behind a tunnel or a proxy the gateway sees127.0.0.1:<port>while Twilio signedhttps://…. SoTWILIO_WEBHOOK_URLis required configuration rather than a convenience, and a delivery that cannot be checked is refused: there is deliberately no "skip validation" switch, because a channel whose authentication can be turned off is not authenticated. Both spellings of the scheme's default port verify, since Twilio has signed with and without it. Replies past 1600 characters go out as consecutive texts split on a word boundary — SMS has no editing, threading or reply-to, so that is the closest thing to one reply the medium has — and Twilio's status callbacks, which arrive on the same URL carrying no body, are acknowledged without becoming an empty turn. Configure withTWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_FROM_NUMBERandTWILIO_WEBHOOK_URL, thensyscity channels add twilio. -
LINE, as a channel: a signed webhook in, the Messaging API out. The signature is
base64(HMAC-SHA256(channel_secret, raw_body))inX-Line-Signature, computed over the bytes as sent — so the body is verified before it is parsed, since re-serialising the JSON would change the whitespace the signature covers and the check would stop matching. Inbound and outbound use different credentials (the channel secret verifies what LINE sends us, the channel access token authorises what we send to LINE), which is why they are two config fields rather than onetoken. Replies go out throughmessage/pushrather thanmessage/reply: a reply token is valid only for the delivery that carried it and expires within a minute, which an agent turn routinely outlives. Only text messages are routed — LINE reports follows, joins, leaves and postbacks through the same endpoint, and those are acknowledged and skipped. Markdown is stripped rather than sent literally, because the text message type renders none of it. Configure withLINE_CHANNEL_ACCESS_TOKENandLINE_CHANNEL_SECRET, thensyscity channels add line. -
/handoff [take <file>], oversessions.handoffandsessions.handoff_take: carry a session to another gateway. There is no transport between gateways, so a handoff is a file — the conversation plus the session state that is not in the messages (its name, its model, and the plan it was working through) — which the other gateway applies to a session of its own. Nothing about the sending session changes. The bundle is versioned, and a version this build does not read is refused rather than partly applied. Two limits are stated rather than implied: the plan travels but is not applied, because a plan is the receiving agent's own bookkeeping (it builds one from the conversation), and the file is confined to the transcripts directory, since a caller-supplied path that could name any file would make this "read whatever the gateway can". -
/snapshotand/rollback: a session-scoped checkpoint of the files its writes replace, and the way back to it. The content a write destroys cannot be reconstructed afterwards, so the pre-image is kept before the write — which means a hook on the three tools that name the file they are about to overwrite (file_write,file_edit,apply_patch) rather than anything reading the audit log afterwards. Nothing is recorded until/snapshotopens a checkpoint, so the feature costs nothing until it is asked for; a rollback restores those files, removes the ones the writes created, and drops that checkpoint and every newer one, since they describe a state that no longer applies. Recording survives a restart (a checkpoint left open stays open), because a rollback that silently covered only part of a session's writes would be worse than none.shellis deliberately not covered: it can overwrite any file without naming one, and a checkpoint that guessed would be worse than one that says what it covers. The conversation is untouched by a rollback —/undois what walks that back. -
/branch [name], over a newsessions.branchmethod: continue this conversation down a second path without disturbing the first. The branch is a new session holding what this one has so far — copied by the agent, which owns the turns and the message window, and written to the session store as it goes, so it is a conversation like any other: it appears in the session list,chat.historyshows it, and/resumeswitches to it. The copy carries the turns and the context and deliberately not the redo stack, which is a fact about the parent's history rather than about the conversation. A copy that fails takes its session row with it, so a failed branch does not leave an empty session that looks like a branch. -
/queuequeues prompts for the next turn, withlist,rm N,edit N <text>,move A Bandclearto change your mind before it runs. The name was already taken by the mode (what an arriving message does to a running turn — steer, interrupt, follow-up), and that moved to/queue mode; the bare/queue interruptspelling still sets it, so an existing habit does not start queuing the word. Routing follows hermes': a leading management verb whose arguments fit manages the queue, anything else is a prompt, and/queue add <prompt>forces the other reading. A released prompt goes back through the inbound pipeline rather than straight to the agent, so it is routed, written to the history and answered to the channel it came from — and it runs when the turn it was queued behind finishes, which is what makes the queue a queue rather than a second way to interrupt. -
/import <file>in the TUI, over a newsessions.importmethod: the other half of/save, and narrower on purpose — only the JSON rendering round-trips, because Markdown, HTML and text are documents written for a person and re-reading a conversation out of one would be guesswork. The messages are appended to the session's history, where the next turn and the history view find them. The file is resolved inside the transcripts directory and nowhere else: a caller-supplied name that could name any file would make this "read whatever the gateway can read". -
/save [md|html|json|text]in the TUI, over a newsessions.exportmethod — and/export-sessiontakes the same format argument. The two export paths disagreed: the command wrote HTML and nothing else, from the transcript buffer this process happened to hold, so a session untouched since the last start could not be exported at all and the formats one path offered the other did not. Both now read the session store, which is the durable record, and render through one writer (gateway::session_export);/export-sessionwith no argument still writes HTML, which is what it always did./saveprints the path it wrote. -
/planin the TUI, over a newsessions.planmethod: the goal, each step of the agent's plan marked done / current / pending with the current step's expected outcome, and a count. The plan is the agent's own (built by the planner, advanced by theupdate_plantool), so the method asks the agent rather than tracking anything separately; a conversation with no plan answers "none", which is its ordinary state rather than an error. -
/undo [n]in the TUI, and thesessions.undomethod behind it: the conversation's last turn — ornof them — is dropped from the agent's context and its turn log. The machinery was already there and merely unreachable (Thread::undo_last_turnwith its redo stack and SQLite cleanup, and anAgentQuery::UndoLastTurnthat nothing sent); what is new is a way in, and a loop that stops when there is nothing left rather than asking for turns that do not exist. The count reported is the agent's answer, not the one asked for./titleis/renameunder the name hermes uses. -
The OAuth device flow (RFC 8628), for logging a provider in with no browser on the gateway's host — over SSH, in a container, on a headless box. A provider config gains an optional
device_auth_url;providers.auth_startthen takesmode: "device"(and the CLI,syscity provider auth <id> --device) and answers with a code to type at the provider's verification URI instead of a redirect. There is no callback to complete it:providers.auth_statuspolls the provider on each call, so the client that is waiting is what advances the login, and the answer carries the code, the interval the provider asked for, and the reason for a refusal. A provider configured withdevice_auth_urland noauth_urlis device-only and is started in device mode without being asked;auth_urlis now optional to allow exactly that. -
The preset list is seven, down from a high of 35:
openai,anthropic,deepseek,kimi,ollama,qwenandopenrouter. A preset buys convenience, not capability — any OpenAI-compatible endpoint was always reachable throughbase_url— and each of the other twenty-eight was one more fact (base URL, model ids, auth shape) that could rot without anyone noticing. Nothing was deleted from the code:gemini,azureand the rest are still protocols a custom provider can name, and adding a vendor back is one TOML table plus a resolver test. The alternative entry, kept below, records the breadth pass that this narrows. -
Twenty more built-in providers, taking the registry from 15 to 35: the aggregators and gateways
openrouter,ai-gateway(Vercel),huggingface,opencode(Zen) andkilocode; the inference platformsnvidia,fireworks,novita,gmi,nebius,deepinfra,arceeandupstage; the vendor endpointsgroq,zai(Z.AI's international GLM),stepfun,xiaomi,tencent-tokenhub; and the local pairollama-cloudandlmstudio. Each is one table inpresets.toml— display name, suggested models, and the endpoint's protocol, base URL, auth and context window — so it appears insyscity setup, in the WebUI's provider picker and inmodels.presetswithout any code naming it. Providers whose model ids carry a vendor prefix (OpenRouter, the Vercel gateway, Hugging Face) are listed by the gateway's own spelling. -
--profile <name>(andSYSCITY_PROFILE) runs against an isolated home under<base>/profiles/<name>— its own config, database, skills, memories, sessions and secrets. A profile is a path root, and a path root is a process: two profiles never share a process, a database or a cache, which is what makes them isolation rather than a namespace.syscity profile list|create|pathmanages them, and a name that is not one ordinary path segment is refused with the reason rather than sanitised. -
@pathcompletion in the TUI. Typing@at a word boundary completes a path in the agent's workspace: the list shows one directory at a time, fuzzy-ranks the names, and accepting a directory inserts@src/so Tab keeps drilling while accepting a file closes the token with a space. Lists come from the gateway's existingworkspace.list(the same call the WebUI file tree uses), fetched lazily — one request per directory actually entered, never one per keystroke — and completion itself stays a synchronous read of what is cached, so Tab never waits on the network and works offline (it simply offers nothing). A directory the gateway refuses is remembered as unavailable; the cache is dropped on a session switch or a lost connection. -
Code blocks are syntax-highlighted in the TUI. A fenced block is highlighted as a whole — one parse pass over the block, so a multi-line string or comment keeps its colour across the break — using the language tag off the opening fence (
rust,rs,py, …), and rendered per line. The theme is built from the TUI's own palette rather than a stock colour scheme, so on a 16-colour terminal the highlighting stays inside the colours that terminal actually has instead of degrading into arbitrary collisions. Blocks are cached by(language, body); a growing block is simply a new entry, so streaming does not re-highlight on every frame. -
[security] guardian(defaultfalse) has a second model review every tool call that reaches human approval. It is deny-only: a rejection refuses the call without asking anyone, a passing review adds the reviewer's reason to the approval a human sees, and an unavailable reviewer leaves the approval exactly as it was — the model can stop a call but never grant one in the human's place.guardian_modelpicks the reviewer; the default is the configured provider's own. -
[security] shell_safetyand[security] sandbox_interceptor(both defaultfalse) install two policy hooks that were written but never plugged in.shell_safetyclassifies every segment of a shell command — a safe binary runs without prompting, a risky one asks, a dangerous one (rm -rf /, fork bombs) is refused;shell_safety_unknown_binary = "ask" | "allow"decides what an unrecognised binary means, since the built-in safe list is ~90 names and ordinary work runs plenty outside it.sandbox_interceptorapplies a command/path blocklist and flags credential-shaped arguments for approval. Both are gateway-wide and read at startup. Off by default: turning either on changes which calls reach a human. -
[security] browser_allow_private(defaultfalse). The browser tool now runs the same SSRF guardweb_fetchhas always run, so private and loopback addresses need this switch to be reachable again — for a deployment driving its own dev server onlocalhost. The scheme rule is unaffected:file:stays refused either way.
Changed
syscity channel listshows every channel. It read a hand-written list that had drifted: WeChat MP was missing, so a channel the gateway was actually running could not appear inchannel listat all, and the same pattern inchannel status --agentwas missing two more. The channel list, the compile-time feature table and theChannels:hint are now all derived from the channel enum, so a channel added later shows up without also being added to four places — andchannel add/stop/remove/testread one mapping rather than seven copies of it.- One provider registry, and a preset is authoritative for its vendor. Vendors were listed twice — once in
presets.toml, once in a hand-rolled table in the model router that carried a second copy of the display names, base URLs and model lists — and the two disagreed, so a vendor could resolve while never appearing in a picker. The second table is now derived from the first: adding a provider is one entry, and a test holds the two in step. Alongside it, a provider's config key now names the vendor at runtime: a provider keyedgrokgets the xAI preset's context window and stream family rather than whichever preset itsprovider_typespelling pointed at, andprovider_typekeeps its other job — choosing which endpoint of a dual-protocol vendor to use, like Kimi's/v1or its/anthropic. Also: the Ollama preset handed outhttp://localhost:11434as a base URL while the resolver appends/chat/completions, so the/v1path the preset itself uses is what it now gives out;models.presetsreturns its vendors sorted rather than in hash order, so the picker's buttons stop moving between opens; and a preset's first suggested model is now its default model, where the two used to disagree. - The main turn loop compacts context before asking, not after failing. Compaction was proactive only on the non-streaming path; the loop that runs ordinary turns waited for a provider to reject the request for length. It now consults the same budget test, and records one decision per turn carrying both the token figures and the cost guard's, so the two budget levers read in one place.
- The browser tool refuses private, loopback and non-web navigation targets. Every
Navigatenow goes through the SSRF guard before Chrome is pointed anywhere: private ranges (literal and DNS-resolved) and the defaultlocalhostblocklist are refused, and so isfile://, which used to open — handing a local file's contents to the page and to whatever the model can read back. Set[security] browser_allow_private = trueto re-open private targets. What this does not cover: link clicks, form submits,window.location, meta-refresh,window.open, new tabs,Back/Forward/Reloadand page subresources — closing those needs a CDP-level navigation subscription, not a check on the URL an action names.