Changelog
[1.3.0] - 2026-08-11
Added
- Settings → Providers: a Mute control on every connection row. Muting keeps the provider connected and remembers which models you had enabled, but stops its models from answering in parallel until you unmute — one click each way. Muting the provider that holds the primary is allowed and now actually works: its other models stop fanning out while the primary still writes the answer.
- Settings → Providers: an estimated cost per exchange, computed from each participating model’s published pricing under documented token assumptions (“how is this estimated?” shows them). Models without pricing metadata are called out rather than silently priced at zero, and no estimate is shown when nothing can be priced.
- Settings → Providers: a “Manage models” drawer replaces the drill-in detail page — the same model list, filters, custom-model add, and advanced options, in a side panel over the tab. Replace API key and Disconnect live in the drawer’s header menu.
- Settings → MCP: an Edit action on every server row — rename a server or change its read-only setting without removing and re-adding it. The server’s URL stays fixed (it identifies the server; remove and re-add to change it).
- Settings → MCP: each enabled server row now shows who receives its tools — “All models” for read-only servers, “Primary only” otherwise — so a server whose tools are withheld from parallel models is visible at a glance.
- A Help window (Help → Polycode Documentation, or ⌘?). Documentation renders inside the app — native sidebar, search, and navigation over the full guide and reference set — instead of bouncing you to a browser. Articles are downloaded as a whole set and cached locally, so Help reads offline and opening an article never issues a request that identifies it; the backup / restore / migrate article additionally ships in the app itself, readable on a fresh install that has never been online. Links between articles stay in-app; external links open in your browser; “Learn more” links in Settings now land on the right section of the right article without leaving the app. The menu keeps a “Polycode Documentation on the Web” item and Email Support… ([email protected] — also in Settings → About in plain text), and everything stays enabled with no window open, which is a state you may well be in when you go looking for help.
Changed
-
Settings → Providers is redesigned consensus-first: the page now leads with what happens when you hit Send — the primary model (with its role and per-1M pricing), a strip of every model that answers in parallel (removable chips, plus an Add model menu listing each provider’s full roster), and a solo warning when the primary would run alone. Provider accounts are demoted to a Connections list underneath. A provider whose model list has not synced shows a warning chip instead of being silently dispatched to fail.
-
Muting a provider that holds the primary now genuinely skips its sibling models on every default send path (main window, modes that inherit the panel, and the Ask Polycode shortcut). Previously the app quietly re-enabled those siblings even when the provider was switched off for fan-out.
-
The tool budget now counts the whole answer across models: a multi-model answer checks in at the configured number of tool calls (Standard: 75) instead of that number per model, and stops at the configured ceiling in total. While the check-in banner is up, no model may spend further tool calls, and choosing Stop now reliably ends every model’s tool use — the check-in banner and its notification no longer attribute the count to a single model.
-
MCP tools now work in sessions without a project: connected MCP servers’ tools are offered to the models even when no project folder is active, with the same approval flow and per-session “allow for this session” memory. Previously a no-project session silently sent no tools at all, so models truthfully reported having no MCP tools despite a healthy connection. Project file tools still require an active project.
-
Renaming an MCP server or changing its read-only setting no longer drops the server’s connection and tool list: the change applies in place, and a read-only change is reflected the next time you send. Previously any edit rebuilt the connection and briefly showed zero tools while rediscovering.
-
Marking an MCP server read-only no longer skips approval prompts. The switch now controls only which models receive the server’s tools (“Offer tools to every model”); approvals follow each tool’s Always / Ask / Deny setting in Settings → Tools, like every other tool. Tools that previously ran silently on read-only servers now follow their per-tool setting — set a tool to Always to keep it prompt-free.
Removed
- The per-provider fan-out toggle on provider cards (replaced by Mute on Connections rows — same stored preference, no migration needed), the pinned-models footer (the peers strip and Add model menu absorb it), and the provider detail subpage (replaced by the Manage models drawer).
Fixed
- Stale navigation paths from before the consensus-first Providers redesign no longer appear in user-facing copy. The cloud-gateway provider captions no longer point at the retired “Settings → Providers → Cloud Gateways” section; the mode editor now sends you to Manage models on a provider’s Connections row for per-model system-prompt overrides instead of the retired provider detail page; and the data-lifecycle support article names the Manage models drawer, the Connections list, and Add provider → Cloud gateway… in place of the retired subpage and section.
- The data-lifecycle article no longer claims backups migrate forward across schema versions. Polycode ships a migration plan with no stages declared, so a schema mismatch rebuilds the store rather than upgrading it — the article now says so, and says to keep your own copy of the backup folder.
- Models answering in parallel no longer receive tools from MCP servers that are not marked read-only; those servers’ tools are now reserved for the primary model, matching the read-only fan-out rule the MCP settings describe. Previously every parallel model was offered the full MCP tool list, including tools that could write through a non-read-only server.
- The read-only setting on MCP servers now says what it actually does. The old caption claimed it would “disable tool invocation” — false, and it steered people away from the setting that lets every model use a server’s tools. The control is now framed as what it governs: whether tools are offered to every model answering in parallel or stay with the primary model, with per-tool approval settings applying either way.
- A per-tool Deny in Settings → Tools now actually blocks tools on servers marked read-only. Previously the read-only flag silently overrode per-tool approval settings, so a Deny had no effect on those servers’ tools.
- The main window’s titlebar no longer shows a floating vertical hairline to the right of the session pill. The line was the toolbar’s glass capsule rendered around an invisible 1-pt layout stub the pill-centering machinery keeps in the toolbar; the stub now opts out of the shared background.
- The Settings window no longer stacks the system titlebar above its own: one unified bar now carries the traffic lights and the centered “Settings” label in the title wash, replacing the double-header with the redundant “Polycode Settings” text.
- Tool summary no longer over-counts deduped multi-peer reads: when several models call the same tool with identical arguments in one answer, the execution already ran once, and the tool section’s summary now counts it once (per-model attribution lines are unchanged). Identical deduped tool results are stored once instead of once per model, and the inspector shows a repeat call served from the same answer’s execution cache as “cached” rather than a meaningless 0ms latency.
- The titlebar session pill now centers on the window itself instead of sitting half the sidebar’s width to the right (an artifact of the toolbar’s sidebar-tracked sections), and it no longer jumps sideways when the sidebar is toggled or resized. The pill draws its own capsule, which hugs the visible title instead of stretching to a fixed minimum width, and the session title’s rename affordance now carries a proper accessibility label.
- Regenerating an answer now streams into an empty bubble. Previously the regenerated text appended below the answer it was replacing: while the new answer streamed you saw the old one still above it, and regenerating again stacked a third copy under both. The bubble corrected itself once the answer finished — the stored answer was always the new one — so this was a mid-stream display artifact, not lost or duplicated data.
[1.2.0] - 2026-08-05
Changed
-
Muted text is legible now — every small grey string in the app changed tone, on purpose.
contentTertiary— the least-prominent text step, used at roughly 209 places at 11–13px for sidebar timestamps, message counts, the composer placeholder, the token estimate, and provider descriptions — failed the WCAG AA contrast bar on every surface in both appearances, scoring 2.75:1 to 3.39:1 against a 4.5:1 requirement. Four of those eight combinations sat below even the 3:1 large-text floor. It is now 4.55:1 or better everywhere. Because darkening it alone would have collapsed it ontocontentSecondary— leaving three named text tokens rendering as two visible steps —contentSecondarydarkens as well (#6B6359→#585046), keeping the ramp three distinguishable levels deep. That second move is larger than first intended: the initial pick barely shiftedcontentSecondaryat all, and rendering the result showed the two muted steps sitting closer together in light mode than they do in dark, which would have shipped a three-step ramp whose middle step was the hardest one to see. Two long-deferred decisions are settled in the same pass: warning text (#B17A16→#8F6410) was failing at 3.71:1 as chip foreground and is now 5.25:1, which also lifts the approval-picker’s selected-segment ring from a marginal 3.01:1 to 4.26:1; and the accent (#C55A1F→#B0511C) was filed as decorative while the conversation column renders inline citation links in it — links are text, so it is now audited as text and clears 4.90:1. Nothing moved in dark mode exceptcontentTertiary. Why it went unnoticed: the contrast requirement namedcontentTertiaryas required-to-audit, but the reference document had no row for it and the executable test had no pair for it — a token required to be audited that nothing audited, while a green suite certified the contract. That is now structurally impossible: the test fixture is the single authoritative pair list,Contrast.mdis checked against it (which immediately caught two provider ratios the document had wrong), a new guard fails the build when the requirement names a pairing the fixture lacks, and demoting a failing text pair to the weaker non-text bar to force a green run is now itself a build failure. The machine-readable token artifact also gained the body size and heading ramp it had been silently omitting, sopolycode.mecan mirror Polycode’s body type instead of guessing it. (See openspec changedesign-token-contrast-regeneration.) -
The setup wizard no longer says your data “stays on your Mac” — and its privacy bullet is finally the one we wrote. Two separate problems, one surface. First, the wording: the Privacy page was titled “Your data stays on your Mac” and taglined “Conversations are local”, and the intro bullet said “Conversations stay on your Mac”. What actually stays on your Mac is the stored record — the content of every prompt, attachment, and injected project context is sent to each provider you’ve configured, every time you submit. That is the whole point of consensus, and it is what the Settings privacy card, the diagnostics disclosure, and the data-lifecycle article have always said. Only the wizard still led with the stronger claim. The title is now “Stored on your Mac, sent to your providers”, the tagline reads “Conversations are stored locally”, and the intro bullet reads “Conversations are stored on your Mac” — every locality claim is now scoped to storage, which is true, and the direct-to-provider half is stated alongside it rather than left to a bullet further down the page. Second, and worse: the corrected intro bullet shipped in 1.1.0 was never actually visible. The 2026-06-20 fix that rewrote it updated only the Swift fallback text and never touched the string catalog, and the catalog is what the app reads — so 1.1.0 rendered the superseded “SwiftData on-disk, keys in Keychain, no account.”, with the “prompts go straight to the provider you pick — never through a Polycode server” disclosure missing entirely. The catalog now carries the real text, and a new test compares every onboarding string’s catalog value against its Swift fallback, so a fix that lands in one and not the other fails the build instead of silently shipping. Nothing about how Polycode stores or transmits your data changed — only what the wizard says about it, and whether you actually see it. (See openspec change
reconcile-onboarding-privacy-copy.) -
Polycode interrupts long tool-using answers far less, and you can now set the budget. A heavily tool-using answer used to stop and ask “Continue 25 more?” — per model, every 25 tool calls. Three models 60 calls deep meant six interruptions for one question, each asking something you’d already answered. Now Polycode asks at most once per message: answering it releases every model working on that message, and none of them ask again for the rest of that answer. Ask a new question and the check-in returns — the decision is per-message, not a permanent opt-out. The budget itself is configurable in Settings → Tools → Tool budget: Focused (checks in at 25 calls, stops at 100), Standard (75/250, the new default), or Extended (200/600). The default is up from 25/100. To be clear, the check-in has not been removed — this raises and consolidates it, and it remains the signal that an answer is spending heavily on your behalf; the hard cap still stops a runaway loop. The banner has also been restyled to the app’s own buttons and type, so it no longer renders in system blue. (See openspec change
tool-budget-controls.) -
Model pickers now offer only the models you’ve enabled. Settings → Providers → “Default primary model” listed every model a provider exposes regardless of which ones you’d enabled — with OpenRouter connected that was a 336-row menu for 3 enabled models. It now offers your enabled models per provider (plus whatever is currently selected, so a selection can never vanish from its own picker), and a provider with nothing enabled no longer contributes an empty section. The “Show all” toggles are gone from both the pinned-models footer and the composer’s model picker. To enable a model, open Settings → Providers and tap a provider — that detail view lists its full roster with a search filter and per-model toggles, and is now the single place models are enabled. Sidebar and composer behavior is otherwise unchanged. (See openspec change
single-model-enablement-surface.)
Removed
- The Appearance settings no longer advertise custom accent colors. Settings → Appearance carried an “Accent preview” section — a swatch of the app’s gradient plus the caption “Polycode uses a fixed accent gradient in v1. Custom accent is post-v1.” Custom accent colors are not planned, so the section and its promise are gone; the accent gradient itself is unchanged everywhere it actually renders. Alongside it, an unreachable “coming soon” placeholder view in the cloud-gateways settings was deleted. Sidebar project dot colors are unaffected — each project still gets its own stable color derived from its identifier, exactly as before. (Internally this also removes the never-populated per-project tint field that existed only to serve the same abandoned feature; no data was stored in it and nothing migrates. See openspec change
retire-custom-accent.)
Fixed
-
Opening a chat with a very long answer in it no longer locks the app up. Switching to a session whose transcript contained a large completed message could freeze the window for seconds — the “spinning beach ball” a few of you hit on long sessions. Each completed message parses its markdown when it scrolls into view, and that parse was quadratic in the message’s length: a 21 KB answer cost roughly 20 seconds, while a 2 KB answer cost almost nothing. Fixed upstream in the markdown renderer (
markdowndelta-swift0.3.0) and picked up here; the same 21 KB message now mounts in 0.64 s, a 31× improvement, with byte-identical output. What this does not change: everyday session switching was already fast — 0.29–0.38 s, at the floor of what the measurement itself can resolve — and it still is. The stall was specific to unusually large messages, and the multi-second version of it was worst in development builds. Streaming benefits too, since the same per-character work runs on every chunk. Three guards ship with it so this cannot regress silently: mounting is pinned to materialize only the messages the viewport can actually show, the rendered output is checked at the text-attribute level (so a mis-placed link or emphasis fails rather than merely looking wrong), and a recorded Release benchmark over a fixed 22 KB corpus makes the cost of the next renderer upgrade visible before it ships. (See openspec changemarkdown-parse-cost-and-switch-latency.) -
Closing a window now stops the answer it was streaming. Closing a window mid-answer used to deregister it and nothing else: the stream kept running — kept calling providers and kept spending tokens — with no window, no banner, and no way for you to reach or stop it. If it was paused waiting on you (a tool-budget check-in, an approval), it waited forever. Closing a window now stops its answer the same way the Stop button does, and the exchange lands in an explicit cancelled state instead of a blank, never-completing row. Other windows are untouched — closing one window has never had any business stopping another’s work, and now provably doesn’t. Deleting a session does the same thing first: previously the delete removed the rows and left any in-flight answer running against them, in any window, spending tokens with nowhere to land. Both closing and deleting are still immediate — nothing new asks you to confirm.
-
An answer you replaced no longer leaves work hanging, and says it was replaced. Sending a new message or hitting Regenerate while an answer was still streaming abandoned that answer rather than ending it: anything it was waiting on stayed suspended forever, and its row sat mid-stream with no completion, indefinitely. Quick Prompt made this easy to hit without noticing, since it sends regardless of what is already running. A replaced answer is now properly ended and marked replaced — deliberately not “cancelled”, because you didn’t cancel it, you replaced it. Replaced, cancelled, and failed answers also stop being replayed as conversation history: an answer that streamed a few words before being stopped used to be sent to every later message as though it were a complete reply. The partial text stays on screen, where it is the only record of what you saw.
-
A cancelled or failed Regenerate no longer leaves an empty bubble. Regenerate clears the old answer before streaming the new one, so anything that interrupted it — cancelling, closing the window, sending a new message, or the provider failing — left the exchange permanently blank with no explanation. Interrupting a regenerate now leaves an explicitly marked row: cancelled when you stopped it or closed the window, replaced when a new request took over, and an error state on a provider failure (a failure is never labelled as something you cancelled). The previous answer is still not recoverable — Regenerate deletes it up front, as it always has; what changes is that the row now says what happened instead of showing nothing. Cancelling before the deletion commits still leaves the existing answer fully intact, and cancelling during it now actually stops the work rather than letting it run to completion untracked. The inspector’s per-answer status card also stops reporting a cancelled or failed answer as a success. (See openspec change
lifecycle-teardown-coherence.) -
Regenerate can read your files again. Regenerating an exchange inside a project silently shipped the request with no tools at all — every regenerate, regardless of project — because the regenerate path never recorded which project the stream was scoped to. The project description still went out, so models were handed a map of your repository (file tree,
AGENTS.mdconventions) and no way to open a single file in it: they narrated reads they couldn’t perform, and the synthesis honestly reported it had nothing to work with and asked you to paste file contents by hand. Tools, tool-call chips, and inspector tool traces now work on regenerate exactly as they do on send. Regenerating a tool-using exchange also no longer destroys its tool history — the purge-then-rewrite that regenerate performs could previously only purge. Note that a regenerate now spends provider tokens on the tool round-trips it used to skip, so its cost matches an equivalent send; the earlier saving bought an unusable answer. Also closes a pre-existing hole where a regenerate cancelled or superseded mid-setup could still open a provider stream and spend tokens. (See openspec changeregenerate-agent-loop-parity.) -
Late attribution — tables at the end of an answer stopped being cut off (shipped in 1.1.0, undocumented at the time). A completed assistant message whose response ended with a table could render short: you’d see the table’s header row and nothing after it, sometimes losing a trailing line of text as well. The content was never lost — it was laid out and readable to VoiceOver — but the bubble’s measured height excluded the final block, so it was clipped until moving the pointer over the message forced a re-layout that healed it. The cause was in the markdown renderer, which reused cached table-row layout fragments across restyles and corrupted its own height accounting, so a bubble’s height decayed each time it was restyled. Fixed upstream in
markdowndelta-swift0.2.1 (fix-restyle-table-geometry-height-degradation-v1) and picked up by the same dependency bump 1.1.0 already shipped — the 1.1.0 notes named that release’s code-block-card fix but not this one. Recorded here rather than by amending a released entry. (See openspec changefix-bubble-first-measure-clip, which added the regression probe guarding it.)
[1.1.0] - 2026-07-29
Added
-
Consensus answers can no longer gaslight you about file writes. Three mechanisms make tool executions authoritative shared facts: the synthesis prompt now carries an
EXECUTED THIS TURNrecord of the primary lane’s mutating executions (applied / failed / denied / unrecorded — framed by the temporal fact that fan-out responses predate them), every later turn sees prior native writes as a compact engine-generated execution-ledger footer on replayed assistant messages (any provider, any!!-targeted primary, for as long as the turn stays in the context window), and tool-capable dispatches get a one-line grounding instruction to verify current file/workspace state with a read tool instead of answering from conversation memory. Fixes the 2026-07-28 field defect where a successfully applied.gitignoreedit was doubted by its own synthesis and then flatly denied one turn later. The ledger derives entirely from already-persisted tool records (no schema change); model-imitated ledger fences are neutralized at assembly, and tool-result payloads never enter the record. (See openspec changeexecution-record-truth.) -
Polycode has its real app icon. The placeholder icon set is replaced by the
{★}brand mark — three provider streams converging into a brace-wrapped star on an ink tile — authored as a layered Icon Composer document (Polycode.icon) generated from the app’s ownPolycodeIconSwiftUI geometry, so the Dock icon and the in-app mark can never drift. The system renders every size and appearance from the single document: a dark variant (deeper tile, warmed braces), the monochrome/tinted treatments, and the legacy fallback for macOS 15. The mark deliberately ships flat (Liquid Glass layer effects off). (See openspec changeapp-store-readiness§5.) -
Refresh an MCP server from its row menu. The
…menu on a Settings → MCP Servers row gained a Refresh action that tears down the connection and re-runs discovery on a fresh session — the recovery path when a server restarted (invalidating its session id) or changed its tool roster without announcing it. Disabled rows don’t offer it (there’s nothing live to refresh). (See openspec changemcp-lifecycle-coherence.) -
Desktop widgets: Active Session, Token Spend, and Recent Sessions. A new
PolycodeWidgetsextension ships inside the app with three widgets for the macOS widget gallery: Active Session (small/medium — your most recent session’s name, latest prompt snippet, and last-updated time), Token Spend (small — today’s total tokens and USD cost across every model, zeroing out when the day rolls over), and Recent Sessions (medium — your three most recent unarchived sessions). Tapping any of them deep-links straight into the session. The host app refreshes the shared snapshot on session create/rename/archive/delete and on every completed query. (See openspec changeapp-store-readiness§18.) -
Share to Polycode from any app. A new Share Extension registers Polycode in the macOS Share menu for selected text: sharing opens Polycode with the text prefilled in the composer as a new prompt. (See openspec change
app-store-readiness§19.) -
Quick Look previews for
.polycodebackupfiles. Pressing space on a Polycode backup in Finder now shows its metadata — schema version, export date, session count (for newly exported backups), label, and file size — plus a pointer to the restore guide, without opening the backup’s database. The backup header format gained an optional session-count field for this preview (the preview shows ”—” for backups exported before the field existed, and the count stamps in once the backup-export UI lands). (See openspec changeapp-store-readiness§20.) -
Export diagnostic logs from Settings → Privacy → Diagnostics. A new button saves a ZIP of the last 7 days of Polycode’s own log output (the
com.izzo.polycode.*OSLog subsystems) plus a small manifest (app version, export date, entry count) to a location you choose — for attaching to a support request. Log lines never contain chat content, model responses, file contents, or API keys, and nothing is sent anywhere automatically. The Review Notes destructive-controls stanza and the data-lifecycle support article were re-reconciled to scope their “no in-app export” claim to user-data controls. (See openspec changeapp-store-readiness§11.) -
One-time paid-install receipt check (Release builds). On first launch, Release builds verify the App Store receipt via StoreKit 2’s
AppTransactionin the background. Deliberately non-blocking: every outcome only logs — no functionality is ever gated on it — and Debug builds skip the check entirely. (See openspec changeapp-store-readiness§8.) -
The inspector now flags peer traces that ended with a question. A finalized fan-out trace card whose response ends in a question directed at you (for example “Want me to dig deeper?”) renders a neutral “asked a question” chip beside its status chip, with a tooltip explaining that peer questions are not relayed to the synthesized answer — so a trace that asks and a chat bubble that doesn’t no longer read as a discrepancy. (See openspec change
peer-self-containment.) -
Onboarding now asks which model is your consensus primary. The first-run wizard gained a “Primary” step (between “Pick models” and “Privacy”) that lists exactly the models you enabled and designates one as the primary — the model that writes the final synthesized answer, weighing the other models’ drafts. The first enabled model is pre-selected so the fast path costs one click, the choice persists like the composer/Settings primary pickers, and un-checking the designated model on a Back edit re-seeds a valid default. Previously the wizard promised “the primary you pick” but never asked: the primary silently defaulted to your first-configured provider running its own default model — possibly one you never enabled. (See openspec change
onboarding-primary-step.) -
Re-grant access to a project whose folder moved or lost permission. Settings → Projects now shows a “Re-grant Access…” button beside a project whose security-scoped bookmark went stale — previously it showed only a warning icon with no way to fix it. It opens a folder picker near the prior root, refreshes the bookmark, and confirms success (or explains a failure) via a toast. (UX/UI surface audit, UX-014.)
-
VoiceOver labels for the custom model enable/primary controls. The enable checkboxes (Cards + table) and the primary radio in Settings → Providers →
now announce a name, role, and state — “Enable , On/Off” and “Primary model , Selected/Not selected” — instead of reading as bare unlabeled buttons. (UX/UI surface audit, UX-012.) -
!!per-message model targeting in the composer. Type!!in the message composer to target one or more of the models already participating in the current consensus run for a single message — an autocomplete popover lists exactly those participants (your effective primary plus the enabled, pinned fan-out peers), never the wider model catalog, and you cannot summon a model you haven’t enabled for consensus. Selecting multiple targets is supported (re-trigger!!to add or toggle one off; backspace or click a pill to remove it); each pick renders as an inline accent pill in the composer where you typed it — like an@-mention file pill — and is one-shot: it applies to the next send, then clears. The models a message targeted persist as a badge row under that message in the transcript, so a scoped run stays auditable in history. Targeting a single model bypasses synthesis and returns that model’s raw answer; targeting two or more runs a scoped consensus over exactly that subset. (See openspec changescomposer-model-targetingandcomposer-target-inline-pill.) -
Per-model token pricing for OpenRouter and other OpenAI-compatible endpoints. Cost cells in the Provider Trace inspector and the Settings → Usage tab now show a real per-call cost for OpenRouter models (and any OpenAI-compatible endpoint whose
/v1/modelsresponse carries apricingobject), instead of$0.0000. The OpenAI-shape models decoder ingests OpenRouter’s per-tokenprompt/completionpricing, andModelRegistry.mergeprefers that live pricing over the LiteLLM fallback (curatedmodels.jsonstill wins). (See openspec changeremote-pricing-and-honest-cost.)
Changed
-
MCP servers are discovered at launch, and status reads availability instead of connection state. Enabled MCP servers are now connected and their tools listed when the app starts (only servers you explicitly added — failures degrade quietly and never block launch), so MCP tools are in your conversations from the first prompt instead of after a visit to Settings. Status surfaces — the sidebar strip and the dashboard rows — now report availability (“MCP · N available”): a server whose tools are known counts as available even while its connection is idled out (idling after 5 quiet minutes is deliberate; the next tool call reconnects transparently), and a red dot now means the last connection attempt actually failed, with the error in the row’s tooltip. (See openspec change
mcp-lifecycle-coherence.) -
Widgets and the backup Quick Look preview now speak Polycode’s design language. The three desktop widgets (Active Session, Token Spend, Recent Sessions) and the
.polycodebackupspacebar preview previously rendered with system-default styling — blue tint, white cards, system numerals. They now use the design-token catalog: warm token surfaces in light and dark, the{★}brand mark in headers, JetBrains Mono digits for token counts and costs, and the brand accent — with hierarchy carried by size and weight so the widgets stay legible in macOS’s monochrome widget mode. Under the hood the token core (colors, spacing, typography, fonts, brand mark) moved into a dependency-freeDesignTokensKitmodule that extensions can link without the markdown renderer;import DesignKitis unchanged for everything in-app. (See openspec changedesign-tokens-core-split.) -
In-app support and legal links now open polycode.me.
POLYCODE_SITE_LIVEflipped toYESnow that the polycode.me docs section is live, so “Learn more” links in Settings and onboarding open the rendered articles atpolycode.me/docs/…instead of the repo-hosted raw-markdown fallbacks. (See openspec changeapp-store-readiness§3.) -
Uncaught NSExceptions on macOS now report through the crash pipeline. macOS swallows uncaught NSExceptions instead of crashing, so they previously evaded crash reporting entirely; Sentry’s macOS uncaught-NSException reporting is now enabled inside the same disclosed, opt-out pipeline (the Settings → Privacy → Diagnostics crash toggle gates it, like all crash data). (See openspec change
app-store-readiness§9.) -
Fan-out models are now briefed as consensus contributors, and the synthesis prompt gained a question policy. Each fan-out peer (and, in tool-using runs, the primary’s own draft pass) now receives a consensus-role charter explaining that its response feeds a synthesizing model with no reply channel to you — so it should give a complete, self-contained answer, use its read-only tools instead of asking permission to read, and state its assumptions explicitly instead of ending with dead-letter questions like “Would you like me to check that file?”. The synthesis prompt in turn is instructed to never relay peer questions or engagement offers, and to consolidate any genuine information gap the panel exposed into at most one clarifying question in its own voice. Single-model runs are untouched — a lone model in direct dialogue may still ask you anything. (See openspec change
peer-self-containment.) -
Session menu commands disable when they can’t act. Rename / Archive / Delete / Find in Session now disable when there is no active session, and Cancel In-Flight Query is enabled only while a response is streaming — previously they appeared available whenever any window was open and silently did nothing. (UX/UI surface audit, UX-004.)
-
Quick Prompt’s footer shows your real global hotkey. The Quick Prompt panel previously always displayed a hard-coded
⌃⌥Space; it now renders the hotkey you actually configured in Settings → Appearance, and reads “global hotkey disabled” when none is set, so it can’t teach a stale shortcut. (UX/UI surface audit, UX-003.) -
The composer Send button now explains why it’s disabled. When a draft holds attachments or
!!model targets but no prompt text, Send stays disabled (a prompt is still required) but its tooltip and VoiceOver hint now say “Add a prompt to send your attachments” instead of looking inert. (UX/UI surface audit, UX-018.) -
“Learn more” in an unconfigured provider’s empty state now opens that provider’s docs/console (Anthropic, OpenAI, Gemini, Vertex AI, AWS Bedrock, Azure OpenAI) instead of doing nothing; it is hidden for providers with no canonical destination (user-defined OpenAI-compatible endpoints, on-device Foundation Models). (UX/UI surface audit, UX-007.)
-
A single-participant run now returns the model’s raw voice instead of a degenerate synthesis pass. When only one model participates in a run — a single-provider/single-model user, a chat mode whose panel resolves to just the primary, or a
!!single-target message — Polycode bypasses the consensus synthesis step entirely and surfaces that model’s answer directly, rather than feeding it through a synthesis prompt that framed it as reconciling phantom peers. Both engine paths are covered: the tool-less path runs the primary with a passthrough (empty) synthesis prompt, and the agent-loop (tool-using) path surfaces its primary-loop output directly instead of re-chewing it through a redundant second pass. Multi-model consensus is unchanged. The chat-mode editor’s single-model-panel explainer is reworded to match. (See openspec changecomposer-model-targeting.) -
High-fan-out streaming stays smooth — live model output is coalesced into batched UI updates instead of rendering every token. With several models streaming at once (worst with the Provider Trace inspector open), Polycode used to apply each streamed token to the UI the instant it arrived, so the redraw rate tracked the combined token rate of every model and the main thread starved — scrolling, the text caret, and typing in the composer could stutter during a busy fan-out. Live fan-out and primary output is now accumulated and flushed on a fixed ~30 Hz cadence, and each model’s trace card observes only its own live text, so one model’s token no longer invalidates the other cards or the per-peer status-pill row. First-token paint (still well under the 100 ms budget), scroll follow/pause, cancel/regenerate, and the exact final assistant text are all unchanged — only the intermediate repaint frequency drops. Additionally, with the inspector open on a long, fast fan-out, each live trace card now redraws only a bounded tail of its streaming text (a
…marks elided earlier output; the full text still renders once the response finalizes) and an expanded card no longer redraws its text twice — so per-card redraw cost stays flat as the transcript grows instead of climbing with it, fixing residual stutter in the inspector-open worst case. Scrolling the conversation while a response streams is smoother too: the auto-scroll-to-bottom no longer fires a fresh 0.6 s spring animation on every batched update (it follows instantly while pinned, and still animates only when a new message is added), and each streaming update now reaches only the single in-flight message rather than re-evaluating the whole transcript column — so a manual scroll during a busy fan-out no longer competes with per-update layout work. The auto-scroll is also rate-limited and pauses while you are actively scrolling, so it never fights a manual gesture, and still lands at the bottom when the response finishes. This reconciles thechat-experienceper-token live-rendering path to the coalesced model. (See openspec changecoalesce-fanout-stream-updates.) -
Onboarding intro privacy copy now distinguishes local storage from direct-to-provider transmission. The first-run Welcome screen’s privacy bullet previously read “Your data stays local” / “SwiftData on-disk, keys in Keychain, no account” — which, sitting directly beneath the list of cloud providers (Anthropic, OpenAI, Google, Vertex AI, AWS Bedrock, Azure OpenAI), could read as “your prompts never leave the Mac.” It now reads “Local storage, direct to provider” / “Conversations stay on your Mac, keys in your Keychain, no account. Prompts go straight to the provider you pick — never through a Polycode server,” naming both the on-device storage and the direct-to-provider transmission so the intro matches the Settings privacy card, the onboarding Privacy page, and the data-lifecycle support article. No storage or transmission behavior changed — only the copy. (See openspec change
reframe-onboarding-local-claim.) -
Streaming model requests now have an explicit transport-stall ceiling. Every networked provider adapter (Anthropic, OpenAI, Gemini, Azure OpenAI, AWS Bedrock, Vertex AI, and OpenAI-compatible / custom endpoints) now streams over a dedicated
URLSessionwith an explicit 180-second request (inactivity) timeout, instead of relying onURLSession.shared’s implicit 60-second default. This gives a wedged socket a defined ceiling independent of the consensus engine’s cooperative per-peer timeout, and — because 180s is more generous than the implicit 60s — it also stops a legitimately slow-to-first-token reasoning model from being failed as a spurious transport timeout. No effect on healthy streams. (Streaming/consensus audit §4.4.)
Removed
-
The pre-1.0 schema amend-mode rule — the schema cutover fired. The graduated pre-1.0 rule (“amend the models directly and accept a wipe, or freeze”) is replaced by the restored uniform freeze rule: every schema change now cuts a
SchemaV<N+1>with frozen prior models and a migration stage. The store was compacted to a freshSchemaV1— all eleven pre-1.0 schema versions, their frozen model snapshots, on-disk fixtures, fixture generators, and freeze tests were deleted, and the canonical models were reshaped to be CloudKit-mirror-compatible (uniqueness constraints moved to application-layer upserts; every property defaulted or optional) so a future sync change needs no schema work — sync itself remains off. One-time effect on the dogfooding store: the next launch detects the schema change, backs the old store up beside itself (.pre-rebuild-<timestamp>), and starts fresh; pre-cutover.polycodebackupexports are no longer restorable. (See openspec changespre-1-0-schema-cutoverandpre-1-0-schema-amend-mode.) -
The unused
com.apple.security.printentitlement. No print feature orNSPrintOperationcall site exists anywhere in the app; the entitlement had shipped speculatively with the original foundation commit. Removed in theapp-store-readinessentitlements audit (§1) — every remaining entitlement now has a justification inResources/Polycode.entitlements.notes.mdand exercising code; the sha256 baseline was regenerated.
Fixed
-
Context windows in the model pickers are no longer wrong for most models. On an OpenRouter endpoint, 69% of listed models (234 of 340 measured live) displayed an incorrect context window — 202 of them a fabricated “128K” — because the
/v1/modelsdecoder discarded OpenRouter’s per-modelcontext_length,top_provider.max_completion_tokens, andsupported_parameterswhile parsing pricing from the same payload. The decoder now carries all three (tolerantly — clone endpoints that omit or mangle them are unaffected), Gemini’soutputTokenLimitrides along, and the serving endpoint’s self-reported limits outrank the curated manifest and the LiteLLM catalog forcontextWindow/maxOutputTokens. Because those values are also the message-trim budget, threads stop being evicted ~8× too early on large-context models (a 1M-context primary was budgeted at 128K with an 8K reserve). Three follow-on corrections shipped with it:- Honest estimates. When no source reports a context window, the picker now shows “~128K” with an explanatory tooltip instead of presenting the made-up default as fact (user-entered custom-model values stay plain), and all three context-window surfaces share one rounding formatter (the pinned-model row’s “1048k context” becomes “1M context”; 65,536 rounds to “66K”, not “65K”).
- Curated catalog drift.
gemini-2.5-proclaimed a 2M window (a Gemini 1.5 number; actual 1,048,576); Claude Opus 4.7/4.8 and Sonnet 4.6 were listed at 200K though Anthropic now serves their 1M window by default (128K max output); Haiku 4.5’s max output rose to its documented 64K; the on-device Foundation Models row claimed 8,192 against Apple’s documented 4,096-per-session window. All re-verified against vendor docs (2026-07-06). - Degenerate trim budgets. Real metadata advertises max-output at or above the context window (live OpenRouter rows at 262144/262144; LiteLLM’s
grok-4at 256000/256000), which drove the trim budget to zero and evicted the whole thread. The trim reserve is now clamped to half the budget — display and wiremax_tokensstill see the advertised value.
(See openspec change
model-metadata-truth; precedence, decode guards, the estimated flag, and the reserve clamp are pinned by ProviderKit + PolycodeKit tests.) -
MCP no longer silently dies mid-session, and the sidebar status strip finally works. Three lifecycle defects, all found dogfooding the first live MCP connections:
- Tool counts decayed to zero. Five idle minutes after your last MCP call, the idle disconnect wiped the server’s discovered tool roster along with the connection — and since the roster is what offers tools to the models, no model could ever issue the call that would have transparently reconnected. MCP was dead for the rest of the app session (dashboard: “0 tools” beside a green dot, because the dot read request-health ratios rather than what actually happened). The roster now survives idle disconnects — only removing or disabling a server discards it — and a reconnect re-checks the roster in the background.
- The sidebar always said “No MCP servers”. The strip’s summary was never written by anything — it rendered its empty default forever, whatever your actual MCP state. It’s now driven live from the client’s lifecycle events (discovery completing, a server erroring, config changes), with no window reopening or Settings visit needed.
- Cold launch left MCP unconfigured. The client only learned which servers exist when Settings → MCP Servers was opened, once per app run — until then conversations had no MCP tools at all. Persisted servers now register and discover at launch.
(See openspec change
mcp-lifecycle-coherence; the durable-roster, status-stream, launch-bootstrap, and refresh behaviors are each pinned by MCPKit + app-target tests.) -
MCP servers now actually connect and list their tools when added. Two independent defects, both surfaced on the first live HTTP MCP connection ever attempted (the integration the
mcp-http-clientchange had deferred behind fixture-only tests), each of which alone pinned a server at an amber “0 tools” state forever:- No proactive discovery. Adding a server registered it but never called
connect()— and nothing else did either, so its tool cache stayed empty, which meant the tool roster never offered it, which meant it never connected: a cold-start deadlock.MCPClientnow kicks off a non-blocking connect +tools/listdiscovery when a server is added (cancelled cleanly if the config churns), publishing the refreshed roster to the dashboard and agent-loop tool set. - Dropped session header. Streamable-HTTP servers issue an
Mcp-Session-Idoninitializethat every subsequent request must echo; the transport never did, so servers like Context7 and DeepWiki rejected the follow-uptools/listwith a 400. The transport now captures and echoes the session id, and drops it when a freshinitializestarts a new session.
(See openspec change
app-store-readiness; MCPKit session + discovery tests pin both, plus an env-gated live smoke against Context7.) - No proactive discovery. Adding a server registered it but never called
-
A per-model system prompt on your primary no longer erases the consensus synthesis instructions. Setting a custom system prompt for the primary model (Settings → Providers → model advanced drawer) previously replaced the entire synthesis prompt at request time — the peer responses, citation rules, and synthesis instructions all silently vanished, so an overridden primary answered from the raw conversation as if no consensus had run. The override now replaces only your app-assembled base prompt (project context + mode append) and composes beneath the engine’s synthesis framing; empty overrides are ignored, matching the adapter rule. The same compose-don’t-erase guard covers the new fan-out consensus charter. Single-model runs keep the previous override behavior exactly. (Codex design-review finding; see openspec change
peer-self-containment.) -
The sidebar panel now floats below the titlebar instead of sitting flush against it. The sidebar’s glass panel gets the same visible window-background gap above its rounded top edge that the system already gives its bottom and leading edges, so the panel reads as symmetrically inset on all sides. macOS pins that panel flush under the toolbar with no supported override, so a small AppKit-level shim (
PlatterTopInsetFixer) retargets the panel’s top pin by one spacing token; if a future macOS re-plumbs the private pinning, the shim silently no-ops back to the system-flush look. -
Remediated the 2026-06-29 Chat + ConsensusKit functionality audit. Several provider-contract and stream-lifecycle defects are fixed: (1) current Anthropic-family models (Opus 4.7/4.8, Sonnet 4.6, Vertex Claude, Bedrock Anthropic) no longer 400 when you enable extended thinking, reasoning effort, or temperature in Settings — request building is now model-capability aware (adaptive thinking + effort, and sampling controls are hidden for models that reject them, with the advanced drawer’s copy matching what reaches the wire); (2) a network/proxy/provider stream that cuts mid-response is now surfaced as a truncated/failed turn instead of being silently saved as a clean answer (every adapter tracks a terminal finish; fan-out peers and synthesis can’t be marked succeeded/completed without one); (3) cancelling a query no longer leaves a blank, permanently “fan-out” assistant row — the exchange is finalized as an explicit cancelled state (matching truncated/error states), and persisted exchanges now distinguish natural stop, cancel, truncated stream, and provider error; (4) non-streaming custom endpoints (
supportsStreaming = false) are decoded as JSON instead of being mis-parsed as SSE (which produced empty answers); (5) Gemini malformed function calls and any “tool-use” finish with no actual tool call are surfaced as failures with a diagnostic rather than silently completing; (6) replaying a corrupt persisted tool-call no longer silently substitutes empty arguments — it fails loudly so the model never reasons over a fabricated call; (7) when the on-device Foundation Models provider drops project tools it can’t run, you now get a warning instead of a silent capability loss; (8) approving/denying a tool from an OS notification now resolves the exact pending call by id, so two same-named calls can’t cross-resolve; and (9) displayed costs are cache-aware (cache write/read pricing applied, with per-provider handling of whether input counts already include cached tokens). The curated Anthropic model catalog and validation probe were also refreshed to the current flagship. (Chat + ConsensusKit functionality audit, 2026-06-29; openspec changechat-consensus-audit-remediation.) -
Remediated the 2026-06-29 UX/UI audit findings. View → Toggle Sidebar now hides only the sidebar instead of blanking the workspace; Settings → Providers cards separate detail navigation from Toggle/Edit/Remove controls; Quick Prompt rows now perform their distinct promised actions (consensus send, primary-only send, new-session draft, or session search); project folder-grant failures surface as toasts; Privacy/About no longer nest same-axis Settings scroll views; the project picker commits and closes after selection/clear/grant; the composer resize cursor is balanced on unmount; Settings/onboarding and the composer better accommodate larger text; icon-only destructive controls have explicit accessibility names; and the approval “Remember for this session” checkbox now uses native toggle semantics. (UX/UI surface audit, 2026-06-29.)
-
The menu-bar “New Prompt…” works with no window open. Triggering Quick Prompt (from the menu-bar extra or the global hotkey) when every window was closed silently did nothing; it now opens or fronts a main window and presents the panel there. (UX/UI surface audit, UX-001.)
-
⌘↵ in Quick Prompt no longer submits an empty prompt. The hidden ⌘↵ “fan-out” shortcut bypassed the empty-input guard that ↵ and the suggestion rows enforce; all three submit paths now share the same guard. (UX/UI surface audit, UX-002.)
-
Onboarding can’t finish with no usable model. The model-picker Continue gate returned “ready” when every configured provider’s roster was empty/unsynced, letting setup complete with nothing to dispatch; it now requires at least one selectable model overall (empty/unsynced providers remain individually non-blocking). (UX/UI surface audit, UX-009.)
-
A primary model can no longer be disabled while still marked PRIMARY. Disabling the current primary in Settings → Providers →
is now blocked with an explanation (“Set another model as primary before disabling this one”), so the PRIMARY badge, the composer picker, and the persisted selection can’t point at a disabled model. (UX/UI surface audit, UX-010.) -
“Make Primary” on a provider card now selects a concrete model. It previously stored the provider with a nil model, leaving the Primary picker blank/unmatched; it now picks the provider’s first enabled (or first available) model so the picker shows a real selection. (UX/UI surface audit, UX-011.)
-
Removing a project asks first and reports failures. Settings → Projects “Remove” now shows a confirmation that names the project and clarifies it removes only Polycode’s record (not the folder on disk), and a failed removal surfaces an error toast instead of being silently swallowed. (UX/UI surface audit, UX-013.)
-
MCP “Add Server” rejects unreachable URLs. The base-URL field accepted schemeless/relative strings like
fooormcp.example.com/server(whichURL(string:)parses as valid); it now requires anhttp/httpsscheme and a host, and registry-derived servers use the same validator. (UX/UI surface audit, UX-015.) -
A failed MCP registry load no longer offers placeholder servers. On a registry fetch failure the browser populated two
mcp.example.comexample entries that looked addable but could never connect; it now shows an offline/error state with a retry / add-manually hint. (UX/UI surface audit, UX-008.) -
Sidebar search shows an explicit “no matching sessions” state. A search with no hits rendered a blank list (easily mistaken for a loading bug or lost sessions); it now shows a zero-results message with the query and a “Clear Search” action. (UX/UI surface audit, UX-006.)
-
Toggle Inspector (⌘⇧I) is registered exactly once. The shortcut was bound on both the toolbar button and the View-menu command; it now lives only on the menu command (the toolbar button keeps its action), preventing a double-toggle. (UX/UI surface audit, UX-005.)
-
Settings → Advanced no longer over-promises confirmation. The header claimed “Destructive actions confirm before running,” but the cache-clear buttons run immediately; the copy now states the cache actions apply immediately. (UX/UI surface audit, UX-016.)
-
Settings → Appearance no longer nests two scroll views. The tab wrapped its content in its own
ScrollViewinside the shared Settings scroll, causing wheel-focus and clipping quirks; it now relies on the shared outer scroll like the other tabs. (UX/UI surface audit, UX-017.) -
A multi-model run can no longer hang indefinitely on a stalled synthesis. The fan-out peers were each bounded by a timeout, but the final synthesis pass — the primary model’s turn that blends the peers into the answer you see, on any run with two or more participants — had none: if that stream stalled without closing, the whole exchange would hang forever (the single-participant
!!path was already bounded; every N≥2 run was not). Synthesis is now bounded by the same kind of timeout race the fan-out uses (a dedicated, configurablesynthesisTimeout, default 120s); on deadline it surfaces whatever partial answer streamed and emits an honest error-finish instead of hanging. (Streaming/consensus audit §4.3.) -
AWS Bedrock tool calls are now validated like every other provider’s. Bedrock’s ConverseStream decoder emitted tool calls without checking that the assembled arguments were valid JSON, never detected a reused tool-call id, and silently dropped a tool-use block the stream cut off mid-arguments. It now raises the same
toolCallInvaliderrors the OpenAI / Anthropic / Azure adapters already do (invalid_json,duplicate_callID,truncated_arguments), so a corrupt or truncated Bedrock tool call fails loudly instead of dispatching garbage arguments or vanishing. (Streaming/consensus audit §2.5.) -
Transport failures now surface a clear cause, and a corrupt streamed byte no longer drops a whole line. A network error on a provider request (offline, DNS failure, connection reset, transport timeout) is now classified as “network unavailable” in the trace instead of an opaque
The operation couldn't be completed…string. Separately, the SSE stream parser now decodes a malformed UTF-8 byte to the Unicode replacement character — so the corruption is visible and a downstream JSON decode fails loudly — rather than silently discarding the entire stream line it appeared in. (Streaming/consensus audit §3.3 / §2.3.) -
Public-facing docs reconciled with shipped behavior. The data-lifecycle support article and the privacy policy both referenced a “Settings → Privacy → Clear all data” control that does not exist; they now point to the real deletion paths (per-session delete in the sidebar, and container removal per Data lifecycle § Wipe). Also corrected in the data-lifecycle article: the retired
UIStateBlobname (preferences now live inSyncedPrefs/LocalUIStateunderpolycode.prefs.*/polycode.ui.*), the Usage reset-baseline key (polycode.ui.usage.baselineDate), the tool approval-session cache (in-memory, not an on-disk Caches artifact), the project-context recipient list (now naming the Vertex/Bedrock/Azure cloud gateways), and the Debug-build telemetry-gate wording (DEBUG configuration, not “running under Xcode”). Docs-only; no app behavior changed. -
Single-model
!!runs now show a real token/cost breakdown instead of a zeroed trace card. Targeting one model with!!— or any single-participant run (a single-peer mode, or a single-provider/single-model config) — used to render a Provider Trace card with everything zeroed (FIRST TOK —, TOTAL 0ms, IN/OUT 0/0, COST —) even though the model answered, plus a “0 in · 0 out · $0” bubble caption. The underlying gap: only fan-out peer responses persisted token/cost metering, while the “consensus lane” — the synthesis call on a multi-model run, or the lone model’s direct answer on a single-participant run — was never persisted. That also meant the per-turn cost total silently excluded the synthesis cost on every multi-model consensus run. The consensus lane’s tokens, cost, and first-token latency are now persisted on the exchange and surfaced consistently across the inspector primary card (live and after reload), the assistant bubble caption, the inspector Consensus Overview “Σ cost”, and Settings → Usage — so the headline total now reflects all billable model calls for the turn, and the figures agree across surfaces. Unpriceable models render—(“unknown”) rather than a misleading$0. (See openspec changedurable-primary-lane-metering.) -
Prior-message View trace actions now open the trace for the clicked exchange. Clicking View trace, a provider citation, or a Sources-row provider pill on an older assistant response no longer jumps the inspector to the latest completed exchange when a newer response has arrived; the trace focus request now carries the originating exchange and provider target. (See openspec change
select-inspector-trace-exchange.) -
Non-text files no longer render as a wall of replacement-character boxes — or get fed to the models. A file that is binary-ish but has no NUL byte (mojibake, a corrupted-and-resaved file, certain encodings) used to slip the binary check, decode “successfully,” and be returned verbatim by
grep_search/file_read/ attachment resolution — surfacing asU+FFFD“tofu” in the trace AND wasting every fan-out model’s context on garbage. The non-text gate now also rejects files dense with control /U+FFFDcharacters (checked on the decoded text, so a byte range starting mid-codepoint is never mis-flagged), while valid non-Latin source (CJK, emoji) is unchanged. Separately, the trace surfaces (tool-call results, model reasoning) now collapse any residual run ofU+FFFD/control characters into a compact[non-text …]marker, so upstream-corrupted streamed reasoning renders legibly instead of as a tofu wall. (See openspec changenon-text-content-robustness.) -
The menu-bar extra’s “New Session” command now creates a session instead of doing nothing. The dropdown row was wired to a no-op, so choosing “New Session” (or “New Session in
”) from the menu bar had no effect. It now starts a new session — in the active project when one is set — opening or fronting a main window first, so it works even when no window is on screen. (CODEX audit H1. See openspec change codex-audit-remediation.) -
The menu-bar extra’s “Open Main Window” now reopens a closed window. The command only activated the app, so with every window closed (menu-bar-only state) it brought Polycode forward without showing any window. It now fronts an existing main window — deminiaturizing it if needed — or creates one when none is open. (CODEX audit H2.)
-
The “New Window” command (⌘⇧N) now opens a window instead of erroring. It was sending the document-based-app
newDocument:action, which a non-documentWindowGroupapp rejects with a “No document could be created” alert. New Window — and the menu-bar extra’s create-a-window-when-none-is-open path — now use SwiftUI’sopenWindow(id:)against an identified main window group. (Dogfooding follow-up tocodex-audit-remediation’s H1/H2.) -
The Settings → Appearance keyboard-shortcut reference now matches the real bindings. The list showed Rename Session as
⌘⇧R(it is⌘S) and a nonexistent “Export Session” (⌘E), while omitting Find Across All Sessions, Delete Session, Toggle Sidebar, Switch Project, and Cancel In-Flight Query. It now renders from the same command catalog the menu commands bind from, so it lists every command with its true shortcut and cannot drift again. (CODEX audit L1. See openspec changecodex-audit-remediation.)
Security
-
The scoped-file tools no longer escape the granted project scope or exhaust memory on hostile input. A cluster of scoped-tools-hardening gaps closed: (1) a recursive
list_directoryfollowed directory symlinks — a link pointing outside the project root was recursively enumerated (name disclosure beyond the granted scope) and a symlink cycle recursed unboundedly; it now never descends a symbolic link and is depth-capped, and a link is listed without a/suffix so it can’t leak its target’s type. (2)file_readon a binary file spun O(n²) in UTF-8 boundary backoff and then emitted a pagination marker whose next offset didn’t advance — an infinite, CPU-burning model retry loop; the backoff is now bounded and binary / non-UTF-8 reads fail fast with a clear error instead of looping. (3)grep_searchslurped every candidate file fully into memory regardless of size; it now skips files over the read cap from their stat (no read) and skips binary files. (4) A scoped-file attachment’s per-file size cap is now enforced from the file’s stat before its bytes are read, so an oversized scoped file is rejected without first being loaded into memory, and a non-regular file (fifo/device) can no longer block the read. (FABLE cluster H13 + H14 +GrepSearchTool.swift:60+AttachmentResolver.swift:46. See openspec changescoped-tools-hardening.) -
The telemetry client’s shared singleton is no longer published through a data race.
TelemetryClient.shared/setSharedstored the process-global client in anonisolated(unsafe) var, so a launch-timesetSharedracing concurrentsharedreads was undefined behavior under Swift 6 strict concurrency (the publish happens during app startup precisely when other subsystems first read the accessor). The storage is now anOSAllocatedUnfairLock-guarded optional that serializes publish-and-read; the nil-before-resolve fallback to a Noop client is preserved. (FABLE auditTelemetryClient.swift:35. See openspec changetelemetry-truth-reconciliation.) -
“Clear all credentials” now removes every stored credential, not just five. The destructive control deleted only the base provider API keys, silently leaving custom-endpoint / OpenAI-compatible keys, MCP server tokens, cloud-gateway credentials, and OAuth sign-in tokens in the Keychain — contradicting the confirmation dialog. It now enumerates everything stored (via a new reserved-vs-credential classifier) and deletes all of it, surfacing any failures instead of swallowing them; your anonymous install identity is classified as app identity and is deliberately not removed. The confirmation dialog, the in-app privacy card, the data-lifecycle support article, and the privacy policy are reconciled with the complete scope (and the false “macOS wipes Keychain on uninstall” note was corrected). (FABLE audit H11.)
-
Touch ID credential gating now persists across relaunch and gates newly-added keys. The gating on/off state was in-memory only, so it silently reset to off on every launch (and Settings misreported it); it now persists in the Keychain. A credential added while gating is on is stored biometric-gated like the rest, instead of being silently readable without a prompt; internal sentinels and the install identity are never gated. The loss-proof accessibility rewrite shipped in the prior audit hotfix is now pinned by spec. (FABLE audit H9 +
SecretStore.swift:28; C2 spec-pin.) -
Session tool-approval grants no longer outlive their conversation. “Approve for session” grants are now cleared whenever a conversation boundary is crossed — a new session is created or any window switches to a different session — restoring the documented
scoped-files-and-toolscontract that had no wiring (the cache was only ever cleared by two manual Settings buttons, so grants persisted for the entire app run). Re-pointing a moved project at a different folder also clears that project’s grants, so an “approve writes undersrc/” decision made against the old folder cannot silently authorize writes undersrc/in the new one. The session-approval prefix check now lexically normalizes paths, so a crafted../-laden path can no longer prefix-match a narrower in-root grant. User-visible tightening: approvals that previously lasted the whole app run now reset per conversation; the per-tool “Always” default in Settings → Tools is the opt-out for prompt fatigue. (FABLE audit H16 +SessionApprovalCache.swift:37.) -
Path-escaping tool arguments are denied at approval time, not deduped. When approval-gate argument canonicalization detects a path that escapes the project root, the gate now synthesizes a rejection instead of falling back to hashing the raw argument bytes (which let escaping and non-escaping calls share an approval). (FABLE audit,
agent-loop-and-toolsdedupe contract.) -
The Gemini API key no longer travels in the model-list URL.
GeminiProvider.fetchRemoteModelsembedded the credential as a?key=query item — a failed request’sURLErrorcarries the full URL (key included) into error toasts and logs, and URLs are visible to proxies and OS network diagnostics. The key now travels in thex-goog-api-keyheader like every other call in the adapter, locked by a test that fails if a query item reappears. (FABLE audit M6.) -
Attachment-replay failure logs no longer expose file paths in Console. The skip log interpolated the underlying error description (which embeds user file paths and document names) with
privacy: .public, bypassing unified-logging redaction in Console.app and sysdiagnose captures. The error detail is now default-private; only the exchange UUID stays public. (FABLE audit,ThreadAssembly.swift:164.)
Fixed
-
An unresolved model price is no longer rendered as a misleading
$0.00. When no pricing can be resolved for a model (e.g. a brand-new OpenRouter SKU absent from the metadata fallback), cost cells now render"—"(“price unavailable”) — distinct from a genuinely free model ($0.00) and from on-device Foundation Models (“Free”). The distinction derives uniformly across the live Provider Trace card, the same card on session replay (which previously re-showed$0.0000), the Consensus Overview aggregate (annotated ”· N unpriced”), and the Usage tab (whose totals show the known sum plus an unpriced count). The descriptor lookup behind cost is now provider-kind-gated so a custom endpoint serving a bare model id (e.g.gpt-4o) cannot inherit another provider’s curated pricing, and OpenRouter’s negative sentinel prices (-1, used for auto-router models) are treated as unknown rather than a negative cost. (See openspec changeremote-pricing-and-honest-cost.) -
The inspector’s primary provider card now sorts to the top when replaying a tool-using exchange. On replay of an agent-loop (tool-using) exchange, the per-provider trace cards showed the crowned primary in its persisted position (last) instead of first: the primary-first ordering key was resolved from the live-snapshot dictionary, which is empty on replay, so no row was promoted. (Live streaming and non-tool replays were unaffected — they promote the primary by other means.) The ordering key is now resolved from the finalized rows actually being displayed, via an exact provider+model match so a same-provider sibling peer is never promoted by mistake. The crown badge itself was already correct; only the card’s position is fixed. (FABLE audit IV128; completes
primary-selection-coherenceP7.) -
Citations on the line that closes a multi-line inline-code span are no longer dropped. When an assistant message opened an inline-code span with a run of three or more backticks that continued onto the next line, the citation rewriter mistook the span’s closing line (which begins with those backticks) for a new fenced code block — copying the rest of the message verbatim and silently dropping every
[provider]citation from that line onward. The rewriter now opens a fence only when no inline-code span is still carried across the line break, so the closing line’s citations (and every line after it) render as tappable chips again. (FABLE audit, CitationMarkdownRewriter fence/inline-carry.) -
A code block’s “Copied” confirmation no longer clears early on a rapid second copy. Each press of a code block’s Copy button started a fresh 1.5s timer to reset the label without cancelling the previous one, so a second press within that window inherited the earlier timer and cleared “Copied” ahead of time (or immediately). Re-copying now cancels the prior reset and restarts the full window. (FABLE audit,
PolycodeCodeBlock.swift:79.) -
The Usage panel’s Custom date range now counts the full final day, survives reopen without drifting, and no longer strands the picker on “Custom”. Three defects in the custom-range popover: (1) the Start/End date pickers carried whatever time-of-day they were seeded with, so a range whose End fell at e.g. 9am silently excluded that final day’s afternoon exchanges from the totals; the range is now normalized to day boundaries (whole Start day through an exclusive start-of-next-day upper bound) before it’s applied, so the End date is fully counted. (2) reopening the popover over a saved range re-seeded the End picker from that stored exclusive (next-day) upper bound, so each reopen-and-apply silently ratcheted the range one day later; the End picker now walks back to the day actually chosen, making reopen-and-apply idempotent. (3) the segmented control stranded on “Custom” with no range applied — Cancel already restored the prior selection, but dismissing the popover by clicking outside it did not; an outside dismiss now restores the prior selection too. (FABLE audit H27.)
-
Per-peer time-to-first-token in the inspector now measures from fan-out dispatch, not first chunk. A fan-out peer’s live snapshot was created lazily on its first chunk with a
startedAtof “now”, so when the first chunk was a text delta the start and first-token instants were stamped microseconds apart and the inspector chip read “TTFT 0ms”. The dispatch instant is now captured once at submit (and regenerate) and adopted as each peer’s start anchor, matching the primary lane’s already-correct behavior. (FABLE audit, live-fanout-trace TTFT.) -
@mention “recent files” no longer bleed across projects. The mention controller cached the active project’s recent files and only refetched them when the cache was empty, so after switching projects the next @-mention served the previous project’s recents (and never fetched the new project’s). Re-attaching to a project now clears the cache so the new project’s recents are fetched — and cancels any in-flight search from the prior project, so a slow lookup that resolves after the switch can’t repopulate the cache or surface the old project’s results. (FABLE audit, MentionController recents.)
-
Accepting an @mention or inserting an attachment pill now lands the keyboard caret after the inserted pill. The composer only applied a programmatic caret move when the live text selection happened to be empty at the start of the field; accepting a mention replaces a non-empty
@queryrange, so the caret update was dropped and the caret stranded on the replaced query. The caret now syncs whenever the update actually rewrote the editor’s storage, while still leaving a user’s manual selection untouched on ordinary re-renders. (FABLE audit, composer caret sync.) -
The chat-mode editor no longer saves a mismatched pinned model or silently drops a saved one, and its model list populates for Foundation Models, Vertex, and Azure. Switching the mode’s Primary provider left the previously-picked model ID in place, so saving persisted a new-provider + old-provider-model mismatch that failed at query time; a model that isn’t served by the newly-chosen provider is now cleared to “Provider default”, while one that’s still valid is kept. The validity check (rather than an unconditional reset) also fixes a silent wipe of the saved model when merely opening an existing mode to edit — the reset fired on the editor’s restore pass and dropped the just-loaded model before Save. Separately, the model-list fallback compared a provider identity ID against a provider namespace (which never match for Foundation Models, Vertex, or Azure), yielding an empty list for curated/identity-less models; it now matches by provider kind. (FABLE audit, ModeEditorSheet.)
-
Editing a credential in the Add Bedrock Instance sheet now re-arms validation. After a successful Validate, changing the access key, secret, session token, or region left the green “validated” banner shown and Save enabled — letting an unverified credential be saved under a stale “validated” claim. Editing any of those fields now resets validation to idle, re-gating Save behind a fresh Validate. (FABLE audit, AddBedrockInstanceSheet.)
-
A cloud gateway added from the Providers “Add provider” menu now appears in the Cloud Gateways list immediately. The header-menu add path only refreshed the providers list, not the sibling Cloud Gateways section’s local state, so a newly-added Vertex / Bedrock / Azure gateway didn’t surface until the Settings tab was switched and the section rebuilt. The add path now notifies the section to reload. (FABLE audit, ProvidersTab cloud refresh.)
-
Fixed a project-switch race that could show one project’s folder tree under another in the sidebar explorer. An in-flight directory read held the previous project’s reader; if the active project switched while that read was suspended, the old result was written into the new project’s freshly-cleared cache — surfacing one project’s files under another. The read is now pinned to the project it started for and its result discarded if the active project changed in the meantime. (FABLE audit, SidebarProjectExplorer race.)
-
The integrity “Recheck now” spinner now appears while a recheck is running. The in-flight flag was a computed read of an observation-ignored slot, so SwiftUI never re-rendered when a recheck began and the spinner / loading-button state only updated once the round finished. The flag is now an observable property published at both the start and the end of the round. (FABLE audit, integrity recheck spinner.)
-
“Find in Session” (⌘F) and “Find Across All Sessions” (⌘⇧F) now do distinct things. Both menu commands invoked the identical no-arg search-focus action, making them a duplicated affordance with no scope distinction. A scope flag is now threaded through so ⌘F restricts the ranked session results to the active session while ⌘⇧F searches the whole corpus. Two follow-on defects in the scope plumbing are also fixed: clearing the search query now resets the active-session scope, so a later search defaults to corpus-wide rather than staying stuck on the previous ⌘F session; and a ⌘F scope paired with no active session now falls back to the whole corpus instead of filtering to the empty set and showing “no results” for a corpus that has matches. (FABLE audit, find-in-session scope.)
-
⌘K, a prompt-activation deep link, and inserting an attachment now actually move the keyboard caret into the composer. Each of these bumped a focus token that only lit the composer’s cosmetic focus halo — keyboard focus stayed wherever it was, because the AppKit-backed composer (an
NSTextViewinside anNSViewRepresentable) read the token nowhere and never calledmakeFirstResponder. The token is now threaded into the representable; when it advances, first-responder moves into the text view (retried after window-attach so a cold-launch prompt activation isn’t dropped, and a no-op for unrelated re-renders so it never yanks focus back from another control). The stale doc comment claiming@FocusStatewiring — which never existed — is corrected. (FABLE audit H28.) -
The Quick Prompt overlay’s blink caret is now visible. Both branches of its opacity ternary were
0, so the caret rectangle was always fully transparent and the blink rendered nothing; the visible half-cycle now shows it. (FABLE audit,QuickPromptWindow.swift:104.) -
The Quick Prompt “Send to consensus” row now reports the true number of configured models. It interpolated
max(providerCount, 2), so it always advertised at least “2 models” even with zero or one provider configured; it now shows the honest count with correct singular/plural (“1 model”, “0 models”). (FABLE audit,QuickPromptSuggestion.swift:24.) -
Opening Polycode from outside a window — a deep link, the Services menu, a tapped notification, or the “Open Polycode” Shortcuts/Spotlight action — now actually lands you on the session or prefilled composer it names. Every one of these entry points wrote its target to an App Group file that nothing in the app ever read, so a
polycode://session/{uuid}link, a “New Polycode Query with Selection” service, a QueryComplete notification tap, and the App Intent all silently failed to focus the session or prefill the query. They now route through one in-process activation path that delivers the request to the most-recently-active window (or stashes it for the next window to drain on a cold launch). The “Open Polycode” App Intent — which can run in a separate process at cold launch — keeps using the App Group inbox, but the host now drains it (on first-window appearance and on app activation) with a single-winner atomic take, so two windows racing to drain can’t both act on the same entry and a write that lands mid-drain isn’t lost. (FABLE audit H25 +PolycodeApp.swift:236/:260+OpenPolycodeIntent.swift:39. See openspec changeapp-activation-routing.) -
The diagnostics toggles and “Reset diagnostics identifier” now reconfigure telemetry even when no main window is open. The observers that rebuilt the telemetry client when you flipped Send Analytics / Crash Reporting (or reset the diagnostics id) lived on main-window content, so changing a setting from the menu bar with every window closed left the live client running with the old configuration until the next launch. They now live in the Settings scene that owns the toggles, so the change takes effect immediately regardless of window state. (FABLE audit, out-of-window-lifecycle cluster. See openspec change
app-activation-routing.) -
A
polycode://settings/{tab}deep link now opens the right Settings tab even when the Settings window wasn’t already open. The link’s resolved tab is buffered and consumed when the Settings scene appears (cold open) as well as on change (warm open), instead of being dropped when the window had to be created to handle it. (FABLE audit, out-of-window-lifecycle cluster. See openspec changeapp-activation-routing.) -
The menu bar’s “current session” line now tracks the genuinely frontmost window, and the Quick Prompt overlay presents in exactly one window. The current-session line and the global-hotkey / menu-bar Quick Prompt keyed off registration order rather than which window is actually key, so with several windows open they could reflect or target the wrong one; a repeated global-hotkey fire could also stack a second overlay. The frontmost window is now tracked via its key-window state, the Quick Prompt is targeted at a single window per activation, and a repeated hotkey carrying the same activation token is de-duplicated. Closing the last window resets the line to its “no active session” placeholder rather than stranding the closed session’s title on the one surface meant to be useful with no window open. (FABLE audit, out-of-window-lifecycle cluster. See openspec change
app-activation-routing.) -
The file tools and attachment pipeline are more correct:
file_editno longer corrupts self-containing replacements, project excludes apply at any depth, and dropped folders / build files behave sensibly. A cluster of scoped-tools-hardening gaps closed: (1) a boundedfile_editwhose replacement text contained the search text (e.g.config→config_v2withreplace ≥ 2) repeatedly rewrote the first match and left later ones untouched, corrupting the user’s file; it now scans the original source and advances past each original match. (2) The context-builder’s project resolvers silently dropped a project’s persistedexcludedPatterns(falling back to defaults), so a user-added exclude likesecrets/was ignored by the directory walk that feeds project context to every provider — they now decode and honor the persisted patterns. (3) Directory excludes were root-anchored, so a nestedpackages/foo/node_modules/leaked into listings, grep, and context; a separator-free pattern now matches at any depth (gitignore semantics), and the default set gained the spec-mandated.venv/and__pycache__/. (4) Dropping a folder into the composer produced a chip guaranteed to fail at submit; it’s now rejected up front with a clear error. (5) Extensionless text files (Makefile, Dockerfile, LICENSE) always failed to attach (no extension → no MIME); they now resolve totext/plainwhen their bytes are genuinely textual. (6)ProjectDirectoryReaderstat’d directory children after security-scoped access had stopped; the stats now run inside the scoped-access window. (FABLE cluster H12 + H15 +ExcludePatterns.swift:63/:4+ProjectDirectoryReader.swift:71+DragDropHandler.swift:67+AttachmentResolver.swift:68. See openspec changescoped-tools-hardening.) -
The model registry is more reliable: custom models stick, sibling providers aren’t starved, and stale data is cleared. A cluster of model-registry-integrity gaps closed: (1) a model added via “Add custom model ID” now persists and appears in the composer picker immediately and across relaunch — and is actually sendable (previously it lived only in transient view state, vanished on navigation, and left an orphaned enabled-model pin); (2) when two instances share a provider kind (e.g. two OpenAI-compatible endpoints, or cohort gateways) and one has a cached model list, the other no longer shows zero models — the merge now attributes the curated baseline per-identity instead of dropping it for the whole kind; (3) removing a provider now clears its cached model roster (a re-add no longer serves the prior account’s list) and drops its custom models + enabled-model pins; (4) LiteLLM metadata lookups for a model ID that exists under multiple vendor namespaces are now deterministic and scoped to the model’s own kind (no more random-namespace pricing/limits); (5) the onboarding model picker’s Refresh actually fetches the provider’s models (not just metadata) and re-seeds defaults when models arrive asynchronously; (6) a provider’s model-sync status no longer shows “synced OK” when the refresh actually failed. (FABLE cluster H31 + M8 +
AppEnvironment+Preferences.swift:112+LiteLLMManifest.swift:46+OnboardingModelPickerPage.swift:88/:32+ProviderDetailViewModel.swift:159. See openspec changemodel-registry-integrity.) -
MCP servers are more robust against real-world behavior. A cluster of MCP-client gaps closed: (1) a tool result containing a non-text content block (
audio,resource,resource_link, or a future type) no longer fails the whole call — unknown blocks are tolerated instead of throwing; (2) atools/callis no longer auto-retried on a 5xx server error (only idempotent reads liketools/listare), so a side-effecting tool the server already ran can’t be silently double-executed; (3) a server→client JSON-RPC request (e.g.ping) is decoded as a request instead of being misclassified as a notification — which previously failed to decode on itsidand aborted the in-flight tool call; (4)notifications/tools/list_changednow refreshes the composer’s tool roster (the refreshed union reachesMCPClientsubscribers); (5) the per-submit MCP/native tool execution cache is cleared (and in-flight executions cancelled) when a turn ends, instead of leaking an entry per tool call; (6) concurrent first-use of a server during its connection handshake now awaits the in-flight handshake instead of failing with a spurious “server busy”; (7) adding, toggling, or removing a server in Settings now reconfigures the client from the just-persisted state rather than a stale view snapshot, so a just-added server connects (and a removed one disconnects) immediately. (FABLE MCP cluster:MCPToolResult.swift:46,HTTPTransport.swift:112,JSONRPCCodec.swift:285,MCPServer.swift:253/:157,MCPClient.swift:327,MCPServersTab.swift:114. See openspec changemcp-client-robustness.) -
Restoring a backup now validates it’s a real database first, and a crash-recovery wipe keeps a backup. Two backup/restore-safety gaps closed. (1)
BackupManager.importBackupswapped the payload into place after only checking the JSON header and schema version — never confirming the bytes were a SQLite database, so a truncated download or wrong file was swapped in whole and only failed after the live store was torn down. Import now validates the payload (SQLite header magic + a temp-filePRAGMA quick_check) before closing or moving the live store, rejecting a bad backup with the live store fully intact. (2) The stale-canary recovery path — which wipes the store when a prior launch died mid-open — did so with no backup, unlike the schema-rebuild path which copies the store aside first and refuses to wipe if the copy fails. The canary path now mirrors that contract: it backs the store up to apre-rebuild-<timestamp>sibling before wiping and refuses to wipe if the backup fails. (FABLE auditBackupManager.swift:116+ModelContainerFactory.swift:79. See openspec changebackup-restore-safety.) -
The “Learn more” support-article links now reach the section they advertise, and the site-live flag can finally take effect. Two latent defects made the in-app support-article deep-links inert. (1) The build flag that flips the article URL from the GitHub-hosted fallback to the live polycode.me site (
POLYCODE_SITE_LIVE) was threaded into Swift via a compile condition (#if POLYCODE_SITE_LIVE_YES), butSupportArticlelives in thePolycodeKitSwiftPM package, which never receives the app target’sSWIFT_ACTIVE_COMPILATION_CONDITIONS— so the flag was permanently off and flipping it was a silent no-op. The flag is now surfaced into the app’s Info.plist (POLYCODESiteLive) and read at runtime fromBundle.main; the dead compile-condition wire is removed andConfig/Debug.xcconfig’s conditions list regains its dropped$(inherited). (2) The three settings deep-links (#wipe,#backup,#diagnostics) landed at the top of the GitHub-rendered article rather than their sections: GitHub ignores the source’s## Heading {#anchor}attribute and slugifies the visible heading text including the{#anchor}literal, doubling the slug (wipe→wipe-wipe), whereas polycode.me honors the attribute. The links now resolve the correct fragment per host. A new plumbing-guard script (invalidate.sh+ CI) and a self-verifying anchor test fail the build if either wire is severed again. (FABLE audit M1 + M3 +Config/Debug.xcconfig:11. See openspec changesite-live-flag-plumbing.) -
Crash, hang, and CPU-exception diagnostics now actually reach Sentry when crash reporting is on. The MetricKit bridge that forwards macOS-delivered crash / hang / CPU-exception payloads to Sentry was constructed but never subscribed to
MXMetricManager, so the diagnostics the privacy copy and the data-lifecycle support article promised were silently never collected.SentryAdapter.start()now subscribes the bridge once the SDK is up (andshutdown()unsubscribes); the adapter exists only while the crash-reporting toggle is on, so its lifetime is the gate. The in-app crash-reporting caption and the support article are reconciled to describe the delivery the code now genuinely performs. (FABLE audit H19. See openspec changetelemetry-truth-reconciliation.) -
“Reset diagnostics identifier” now actually rotates the PostHog anonymous id. Minting a fresh anonymous install UUID rebuilt telemetry but left PostHog reusing its persisted anonymous
distinct_id, so the analytics continuity the Reset promised to break carried on unchanged on the dashboard. The reset-triggered rebuild now constructs the PostHog adapter withreuseAnonymousId = falseand callsreset()after setup, minting a freshdistinct_id; launch and toggle-driven rebuilds preserve continuity as before. The freshpolycode_install_idsuper-property — our dominant, documented join key — also rotates fully on this path. Honest limit: PostHog’s internal$device_idsurvivesreset()(its storage purge omits the device-id key and there is no public SDK API to clear it) and rides one post-reset/flagsrequest; under our anonymous-onlypersonProfiles = .neverconfiguration PostHog performs no automatic person-merge on it, so it is documented as an SDK limitation (data-lifecycle.md #diagnostics) rather than overclaimed away. (FABLE audit H20. See openspec changetelemetry-truth-reconciliation.) -
The launch-time integrity-check telemetry event is no longer silently dropped. PostHog’s
setBeforeSendallowlist omittedintegrity.checkRan, so the event the integrity subsystem emitted was filtered out at the adapter boundary and never left the device. The allowlist is now derived from a singleTelemetryEventNameenum that the emittable event surface maps to exhaustively — a newTelemetryEventcase cannot compile without a wire name, and the allowlist can never drift from the events it is meant to admit again. (FABLE auditPostHogAdapter.swift:141. See openspec changetelemetry-truth-reconciliation.) -
Reconfiguring telemetry no longer drains events onto a half-torn-down adapter or attributes the drop count to the wrong adapter set.
TelemetryHub.reconfiguretore down the old adapters and started the new ones acrossawaitpoints where an interleaved emission could drain onto a shut-down or not-yet-started process-global SDK singleton. Reconfigure now gates event draining for its duration: pre-existing queued events flush to the outgoing set, the outgoing set is fully torn down before the new set starts (so the process-global SDK is closed before it is re-initialized), and events that arrive mid-reconfigure — plus any accrued drop count — drain to the new set only. (FABLE auditTelemetryHub.swift:56. See openspec changetelemetry-truth-reconciliation.) -
With a chat mode active, the live primary card, the saved attribution, and every replay now show the model that actually answered — not your global Settings primary. A mode overrides the synthesis primary, but only the consensus engine honored it: the submit path stamped the global primary into the stream spec, built the live primary card from the global selection, and persisted the global primary as the exchange’s “which model answered” record. So a mode-driven turn was dispatched to one model yet displayed live, saved, and replayed as another — corrupting cost attribution and trust. The effective primary is now resolved once per submit by a single pure resolver and stamped into the stream spec, so the live card, the engine’s primary selector, and the persisted attribution are all the same pair. (FABLE audit H22. See openspec change
primary-selection-coherence.) -
A model picked in the composer can no longer be sent to a provider that doesn’t serve it. The per-window model pick was a bare model id with no provider attached, so when the global primary changed out from under a window — Settings → Models, a provider removed and re-added, or another window’s pick — the stale model id was shipped to whatever provider was now primary. The pick now carries its
(provider, model)pair and is dropped at submit whenever its provider no longer matches the effective primary, falling back to the provider’s default model; a model swap within the same provider still preserves the pick. (FABLE audit H21. See openspec changeprimary-selection-coherence.) -
Switching conversations after a stream finishes no longer shows the previous conversation’s live trace or highlights an unrelated exchange. The per-window live primary card and fan-out trace were not cleared when the active session changed, so the inspector for a newly-selected conversation could render the prior one’s live state; separately, a stream completing after you had already switched away wrote its “last completed” marker onto whatever conversation was now showing, mis-highlighting a foreign exchange. Live trace state is now session-scoped — consumed only when it belongs to the window’s active conversation — and stream completion records its marker only when its conversation is still the active one. (FABLE audit H23 + cross-session completion. See openspec change
primary-selection-coherence.) -
A finalized answer’s primary crown and trace ordering now follow the model that answered, not your current Settings primary. On replay (not streaming), the inspector read the current Settings primary for the crown and an empty live dictionary for primary-first ordering, so a past answer’s crown drifted to whatever you later set as primary and lost its primary-first ordering. Both now read the persisted exchange attribution. (FABLE audit, InspectorView replay attribution. See openspec change
primary-selection-coherence.) -
“Ask Polycode” from Siri and Shortcuts now answers with the same primary and fan-out set you configured in the app. The headless App Intent built its engine from the first configured provider and wired none of your in-app selections — it ignored your saved primary
(provider, model), your per-provider fan-out opt-outs, and your pinned peer models, so a Shortcut could answer with a different primary and a different consensus set than the app. It now loads the persisted primary (an explicitprimaryModelparameter still overrides), honors the fan-out opt-outs, and resolves peer models over the same single model snapshot the in-app engine uses. (FABLE audit H24. See openspec changeprimary-selection-coherence.) -
Token usage and cost for tool-using conversations are no longer undercounted to the last step. A multi-turn agent-loop exchange — the model calls a tool, reads the result, then continues — bills one provider request per step, but the recorded usage and cost reflected only the final step. A multi-step tool exchange therefore under-reported its tokens and dollar cost in the message footer, the per-provider inspector trace, the persisted row, and the Usage dashboard. Usage now accumulates across every step of the exchange. (FABLE audit H3. See openspec change
agent-loop-accounting-and-lifecycle.) -
A stalled tool-using model can no longer hang the whole request. The agent loop had no liveness bound on a step’s network drain, so a peer that opened a stream and then stopped emitting (a mid-tool-call drip, or a wedged connection) blocked consensus synthesis indefinitely with Stop the only way out. Each step’s drain is now bounded by the same fan-out timeout used elsewhere (re-armed per step), and a step that times out keeps its partial text and prior-step usage. The clock is paused while a tool approval is waiting on you, so deliberating over an approval never trips the timeout. A step that finishes at the same instant the timeout fires keeps its result and tool calls rather than being mislabeled as timed-out. (FABLE audit M10. See openspec change
agent-loop-accounting-and-lifecycle.) -
The tool-call hard cap now stops an over-budget step before it runs, not after. When a single agent-loop step requested more tool calls than the hard cap allows, every requested call executed and only then did the loop stop — so the cap bounded reporting, not work. Calls past the cap are now rejected before dispatch with a synthetic “hard cap reached” result, and the banner reports the cap rather than the larger requested count. (FABLE audit, hard-cap-per-call. See openspec change
agent-loop-accounting-and-lifecycle.) -
The context-window trimmer no longer treats tool calls as free. Token-cost estimation counted plain text but assigned zero cost to a message’s tool calls, so a thread heavy with tool use was under-measured and could be trimmed too little — overrunning the model’s context window near the limit. Tool calls now cost their arguments, name, and per-call framing. (FABLE audit M2. See openspec change
agent-loop-accounting-and-lifecycle.) -
The inspector now shows each tool call’s own result, latency, and success. Calls were joined to results by the provider’s per-stream call id plus latest timestamp; when an adapter recycles the same id across steps (Gemini reuses
gemini-call-0), an earlier call picked up a later step’s result — wrong duration, and a later failure mis-attributed onto an earlier successful call. Each call is now linked to its specific result by a minted id, with the previous join kept as a fallback for older conversations. (FABLE audit H26. See openspec changeagent-loop-accounting-and-lifecycle.) -
Regenerating an answer no longer stacks its old tool calls onto the new ones, and deleting a conversation no longer leaks tool records or cached result blobs. Tool-call/result rows are keyed to their exchange but were not covered by the exchange’s delete path, so regenerate showed the union of the prior and fresh attempts’ tool calls in the bubble and inspector, and deleting a session — manually or via the automatic archived-session retention sweep — left its tool rows, and any oversized tool-result blobs cached on disk, orphaned in the store. The manual delete and regenerate paths now delete the exchange’s tool records and reference-aware-purge the now-unreferenced cached result blobs; the retention sweep deletes the tool rows too, with its orphaned blobs reclaimed by the existing age/size cache-pruning backstop. (FABLE audit H8. See openspec change
agent-loop-accounting-and-lifecycle.) -
Stopping and immediately regenerating no longer risks a duplicated streaming slot, and regenerating an earlier answer mid-stream no longer wedges the live one. Stop and the in-flight stream task could both release the same per-exchange streaming slot; because regenerate reuses the exchange id, a stale late release could free the new attempt’s slot and let a concurrent stream double-write its tool rows. Slot release is now ownership-scoped so only the current owner can release it. Relatedly, regenerating a prior answer while a different answer was still streaming reassigned ownership without releasing the streaming one’s slot — the cancelled stream’s now-mismatched release was a no-op, stranding that exchange as “already streaming in another window” for the rest of the session. Taking the new ownership lease now relinquishes any still-held slot for a different exchange. (FABLE audit §8 cancel + cross-id supersede. See openspec change
agent-loop-accounting-and-lifecycle.) -
Azure OpenAI tool conversations no longer silently lose their tool calls and attachments. When a message carried multiple parts (text plus tool calls, a tool result, or a file attachment), the Azure adapter dropped everything except plain text — so multi-turn tool use sent the assistant’s narration but not its
tool_calls, and the model never saw the tool results. Multipart messages now lower into the Chat Completions wire shape exactly like the other OpenAI-compatible adapters: orderedtool_calls, one tool-role message per result, text-file attachments rendered as a content banner, and image/binary attachments raised as a clear “attachment not supported for this endpoint” error instead of vanishing. (FABLE audit H2. See openspec changeadapter-stream-parity.) -
Azure OpenAI streams now detect truncated and malformed tool calls instead of passing them through half-formed. The Azure adapter returned from its SSE loop without ever running the finalize boundary the other OpenAI-compatible adapters use, so a stream that cut off mid-tool-call (truncated arguments), shipped invalid-JSON arguments, or reused a tool-call id was accepted silently. Azure now walks the same
flush+finalizepath, surfacing those three failure modes as a clear tool-call error, and a stream that omits a usage block now emits a synthetic zero-usage record so downstream token accounting stays consistent. (FABLE audit M4. See openspec changeadapter-stream-parity.) -
An OpenAI response cut short by the token limit or a content filter no longer surfaces as a hard error. The Responses-API
response.incompleteterminal event — a normal early stop when the model hitsmax_output_tokensor a content filter — was handled like a failure and threw. It now finishes the stream cleanly with the correct reason (max-tokens or content-filter) and still reports token usage; a genuineresponse.failedevent keeps throwing. (FABLE audit M5. See openspec changeadapter-stream-parity.) -
OpenAI-compatible endpoints now surface mid-stream server errors instead of ending silently. When a provider (for example an overloaded proxy) emitted an in-band
{"error":{…}}object partway through a stream, the adapter discarded the explanation and ended the turn as if it had completed normally. The server’s message now reaches the caller as a clear error, and it takes precedence over a tool call left pending by the same interrupted stream. (FABLE audit M7. See openspec changeadapter-stream-parity.) -
HTTP 402 (billing) and 403 (forbidden) responses now produce actionable errors on every HTTP envelope adapter. Some adapters let these statuses fall through to an opaque “unexpected status” message — so a 403 didn’t map to the “check your API key” error, and a 402 hid the vendor’s billing explanation. Across the OpenAI, Anthropic, Gemini, OpenAI-compatible, Anthropic-shape, and Azure adapters, 403 now maps to the invalid-API-key error and 402 surfaces the vendor’s billing message (for example “quota exceeded”). (The AWS Bedrock adapter uses a non-HTTP-envelope error model and is governed separately by
bedrock-restoration.) (FABLE audit, HTTP-status parity. See openspec changeadapter-stream-parity.) -
AWS Bedrock works again — it was failing every request on two independent axes. (1) SigV4 request signing single-encoded the canonical URI path; because every Bedrock model ID carries a
:-suffix that travels on the wire as%3A, AWS re-encoded it to%253Awhile the signer signed%3A, so every converse-stream call returnedSignatureDoesNotMatch(403). The signer now double-encodes the path (the SigV4 non-S3 contract), so the signature matches; escape-free paths like the model-list endpoint are unaffected. (2) Every multi-turn tool use failed with aValidationExceptionbecause tool-result messages were dropped from the request entirely — the assistant’stoolUsehad no matchingtoolResult. Tool results now lower into a Converse user turn (with consecutive tool results merged into one turn), and the resultstatusfield is sent only for the model families that support it (Anthropic Claude 3+, Amazon Nova). (3) The streaming decoder also no longer buffers a non-Bedrock error body up to 4 GiB: it validates the frame prelude checksum at 12 bytes and caps header/payload sizes to AWS’s documented ceilings before waiting for the frame body. A live end-to-end sign-off against real AWS credentials remains to be done. (FABLE audit H4, H1, M9. See openspec changebedrock-restoration.) -
Provider-configuration changes are now atomic and refuse to overwrite a configuration they couldn’t read. Every change to the configured-provider list — adding or removing a provider, a custom/OpenAI-compatible endpoint, or a cloud gateway, plus the one-time launch migrations and the orphan-credential reconcile — now runs through a single process-wide serial executor, so two concurrent edits (e.g. from two windows) can no longer clobber each other, and the launch reconcile can no longer race an in-flight cloud-gateway add and delete its just-written credential. A change attempted while the configuration can’t be read (a transient store error or a corrupt blob — distinct from a genuinely empty, never-configured state) now fails with a visible error instead of silently overwriting every other provider with an empty-plus-one list. Removals persist the list change before deleting the credential (so a failed write never strands a provider pointing at a deleted key), and the legacy-xAI migration writes + persists the migrated entry before deleting the old key and no longer swallows the credential write — a failure at any step retains the prior state for a clean retry next launch instead of dropping the provider. This completes the H10 fix whose launch-reconcile interim guard shipped earlier this cycle. (FABLE audit H10 full treatment;
ProviderConfigurationService.swift:174,:252,+Cohort.swift:25. See openspec changeprovider-config-resilience.) -
Sidebar full-text search works again. Conversation content search was dead in production — the
exchange_ftssearch table was never created (the migration only ran in tests), so every search threw and every index write silently failed. The table is now created on the database connect path, maintained by a single writer (the prior dual trigger/writer design indexed under mismatched key formats), and a query containing a punctuation-separated identifier likedata-lifecyclenow matches (it previously fused into an unsearchable token). FTS index-maintenance failures are logged instead of silently dropped. (FABLE audit H5, H6, M11 +ChatSessionViewModel+Persistence.swift:337; the GRDB connection also gains a busy timeout so an index write contending with the main store waits instead of being dropped.) -
Tool approvals now work across multiple windows and survive window churn. Every window used to iterate the approval gate’s single-consumer event streams directly, so opening a second window (
⌘⇧N) could trap the app, and closing any window permanently killed approval delivery process-wide (cards stopped appearing and mutating tool calls hung forever). A single app-level consumer now owns those streams and publishes to observable state that every window reads; the OS approval notification fires even with no window open. (FABLE audit C1.) -
Pressing Stop now tears down pending tool approvals instead of stranding them. Cancellation propagates through the per-submit approval dedupe (previously an unstructured task awaited non-cancellably), so Stop removes the pending card, rejects every awaiting caller, and — via a new dispatch barrier in the agent loop — guarantees a tool can never execute for an exchange the user already cancelled, even if the approval resolves in the same instant. (FABLE audit H17 + the pre-cancel gate races.)
-
Concurrent agent-loop peers pausing at the tool-call soft cap no longer hang the request. Soft-cap pauses are held in a per-peer queue (keyed by provider + model) instead of a single overwritable slot, so two peers — including two different models of the same provider — pausing at once each keep their own continuation; the banner shows a
"+N more"queue depth, and stream teardown drains every queued pause. Previously the second concurrent pause orphaned the first’s continuation, hanging that peer’s loop and leaking the engine task. (FABLE audit H18.) -
Return on a tool-approval card now approves, as the ↵ badge promises. The
.keyboardShortcut(.defaultAction)modifier sat on the footer container, so SwiftUI bound Return to the first contained control — “View diff” when a diff existed, otherwise “Deny” — silently rejecting tool calls the user meant to approve. The shortcut is now attached to the Approve button itself. (FABLE audit H29.) -
A transient settings-read failure can no longer wipe all cloud-gateway credentials. The launch-time orphan-credential reconcile deletes every
cloud.<uuid>.credentialkeychain item not present in the provider config — but the config loader was fail-soft, so a transient SwiftData fetch error or a corrupt blob decoded as “no instances configured” and the reconcile deleted every Vertex/Bedrock/Azure credential. The loader now distinguishes “config genuinely absent” from “config failed to load”, and the reconcile is skipped (with a log line) on failure. Intended orphan cleanup on healthy launches is unchanged. (FABLE audit H10, interim guard — full treatment in theprovider-config-resiliencechange.) -
A failed backup restore can no longer leave the app with no data store.
BackupManager.importBackupmoved the live store aside and deleted its WAL siblings before writing the restored bytes; if that write failed (e.g. disk full — the atomic write needs a temp copy of the full database), the original store was never put back and the next launch silently created an empty store. The swap now rolls the original store and its-shm/-walsiblings back into place when the restore write fails, and on success the WAL siblings are preserved alongside the.replaced-*safety copy instead of being deleted — so the pre-restore state stays fully recoverable either way. (FABLE audit H7 + the WAL-preservation finding atBackupManager.swift:138.) -
Toggling Touch ID credential gating can no longer destroy stored credentials.
SecretStore’s accessibility rewrite previously deleted each keychain item before re-adding it with the new accessibility — on Macs where the re-add fails (no enrolled biometrics, ACL-rejecting keychain), the credential was permanently lost and the Settings toggle swallowed the error. The rewrite now proves the target accessibility is addable via a throwaway probe item before touching anything, builds the replacement attributes before deleting the original, and restores the held value under default accessibility if the swap’s add still fails — a failed toggle now leaves every credential intact. Settings → Privacy surfaces the failure as an error toast instead of silently reverting, and the toggle is disabled (with an explanatory caption) on Macs where Touch ID isn’t available rather than offering an enable path that can only fail. (FABLE audit C2.)
Added
- Live MCP tool progress preview. Tool cards now stream
notifications/progresstext from MCP servers while a tool is running, so users see what’s happening instead of a static “running” status until completion. The plumbing spans four modules:- New
StreamChunk.toolProgressDelta(callID:, deltaText:)case inPolycodeCore/Sources/ProviderKit/Types.swift. Emitted by the tool-dispatch path (NOT by model adapters) between.toolCallRequestedand the terminal.toolResultSubmittedfor the samecallID. Adapters explicitly do not emit this case; the consumer drops chunks for unknown call IDs silently to defend against protocol violations and late-after-cancel races. AgentToolDispatcherprotocol extended with a 5th argumentprogress: (@Sendable (String) -> Void)?.LiveAgentToolDispatcherpasses the callback through toMCPClient.callToolDedupedfor MCP tools; native tools ignore the parameter (no streaming-capable native tool today; the parameter is reserved for a futurebash/run_commandtool). Test conformances acrossConsensusKitTestsandPolycodeTestsupdated in lock-step.MCPClient.callToolDedupedmulticast subscriber list. The per-submitexecutionCachevalue type changed fromTask<MCPDispatchedExecution, Error>to a smallMCPExecutionState { task; progressSubscribers: [@Sendable (String) -> Void] }struct. When N peers concurrently call the same MCP tool with identical arguments (Pattern-C dedupe), each non-nil-progress caller’s callback is appended to the subscriber list; the underlying singleMCPServer.callToolinvocation uses ONE multiplexing callback that fans out to all subscribers insideMCPClientactor isolation. First-caller-decides semantics: if the FIRST caller passesprogress: nil, no_meta.progressTokenis sent — late subscribers receive nothing (preserves nil-backwards-compat).MCPServer.callToolextended withprogress: (@Sendable (String) async -> Void)?(async, NOT sync — theawaitis the ordering primitive that prevents Task races during multicast fanout). When non-nil, MCPServer generates a UUID_meta.progressToken, registers the callback in a per-actorprogressTokenToCallbackmap, includes the token in the outgoingtools/callrequest, and clears the map entry viadeferon completion or throw.handleNotificationbecomesasyncand handles a newnotifications/progresscase that looks up the token andawaits the callback withparams.message. Numeric-only progress notifications (nomessagefield) are dropped silently in v1.MCPServer.sendRequestrefactored fromtransport.send(single-message) totransport.sendStreamingwith a response-ID-match loop. Interleaved notifications are dispatched tohandleNotificationand consumption continues; the matching response is returned when it arrives; missing-response stream-end throwsMCPError.protocol(.unexpectedMessageType(...)).sendInitializeis unaffected — it keeps using the single-message path becauseinitializenever receives interleaved progress notifications. The streaming-receive loop is strictly more permissive than the prior single-message path; existinglistTools/listPrompts/callToolcallers continue to work unchanged.StreamingToolCall.previewBuffer: String?field + chunk handler.ChatSessionViewModel+Streaming.swift:472’s nestedStreamChunkswitch gains acase .toolProgressDelta(let callID, let deltaText)branch that appends to the matching peer-call entry’spreviewBufferviacappedPreview(_:). Cap policy: 6 content lines + 2048-char ceiling, front-trim with leading"…\n"sentinel, sentinel does NOT stack on repeated overflow, char-cap-only path (single long line) omits the sentinel.ToolCallSection.virtualGroup(from:)threadsentry.previewBuffertoAggregatedGroup.resultPreview(replaces the hardcodednil).- Disclosure rule flip on
.executing. Thetool-call-disclosure-and-promotionmatrix’s.executingrow now splits onresultPreview: non-nil and non-empty → auto-expand to show live progress; nil/empty → stay collapsed (today’s universal behavior when no progress is plumbed). Sticky user-override stickiness is unchanged; transition tests pin both directions of the flip. - Cap-direction trade-off (visible swap at handoff). The streaming buffer trims FROM the front (recency-first, leading
"…\n") whileToolCallSection.previewString(from:)trims FROM the back (start-context-first, trailing"…"). The streaming-to-persisted handoff at.toolResultSubmittedtime produces a one-time visible content swap; this is documented as an accepted UX trade-off (the two phases have different user-value optima). Unifying the directions is future work. - Deferred work tracked in
TODOS.md: loopback HTTP test fixture (127.0.0.1:0JSON-RPC + SSE responder), end-to-end multicast / streaming-receive / progress-routing integration tests, manual end-to-end sign-off with a streaming-capable MCP server (each peer’s card shows progress independently in the multi-peer case). Production code shipped with unit-level coverage of the cap policy, chunk handler, and multi-peer buffer independence. - See openspec change
streaming-tool-progress-preview.
- New
Changed
-
Streaming assistant messages now render as live styled markdown. Replaced
swift-markdown-uiwithmarkdowndelta-swiftas the markdown rendering engine. During LLM streaming, headings, bold, italic, code blocks, and tables now render with full styling as they arrive — previously streaming content displayed as plain unstyled text with a blinking cursor. The commit-lattice architecture prevents visual flicker as closing delimiters arrive (e.g.,**bold**doesn’t flash between plain and bold). A character-precise blinking cursor tracks the streaming frontier viainsertionPointRect. Citation chip styling now uses per-URLcontextStyleResolver, an improvement over the prior all-or-nothing link styling. Code blocks are styled natively viaMarkdownStyle.fencedCodewith a contextual copy-to-clipboard action. The dependency pinsmarkdowndelta-swift≥ 0.2.1: 0.2.0 decouples block-card overlays from style-identity restyles — letting Polycode cache its style values (stable restyle identity) instead of paying a full restyle of every visible message per render, the dominant scroll cost found in the 2026-07-28 lag triage — and 0.2.1 re-derives overlay geometry from the view’s settled width, fixing code-block cards drawn offset from their block on one-shot static renders. See openspec changemarkdowndelta-swift-rendering. -
Markdown styling now derives from executable typography rules. The markdown surface’s style table is generated by
PolycodeMarkdownTheme— named rule functions (scale snapping, around-vs-within ratio bounds, line-gap from font metrics, the platform type ladder) derive every spacing and heading value from the design tokens, replacing the third hand-frozen value table this surface has carried. The transcript’s.chatdensity is equality-pinned field-for-field to the previously accepted values (a provable visual no-op — nothing changes on screen), and a first.readerdensity (looser document ramp, one-step-up rhythm) ships generator-complete for a future reading surface, previewable on the Design Book’s new “Markdown Theme” page. (See openspec changemarkdown-theme-rules-generator.) -
Markdown typography rebuilt on design tokens. Assistant-message markdown now renders with a deliberate typographic system instead of renderer defaults: a pinned heading ramp (17pt bold / 15pt semibold / 13pt semibold), body and code faces bound symbolically to the token catalog, opened-up rhythm (paragraph spacing, code-block outer gap, list indent 24pt), tabular-figure table digits, token-radius code cards with hairline borders, and honest per-appearance color resolution — including code-block text that correctly inverts in dark mode. Every value is a
Spacing/Radius/color token or named constant, enforced by a drift-guard test suite so the on-screen typography cannot silently diverge from the design system. (See openspec changemarkdown-style-token-fidelity.) -
The unused settings-history audit log no longer bloats the store. Every preference and UI-state write (and delete) appended a
SettingsHistoryEntryrow carrying the full old and new value blobs — an audit log that nothing in the app ever read, growing without bound and doubling the bytes each write persisted. Those writes are retired. TheSettingsHistoryEntrytable is now dormant (never written; existing rows are harmless and ignored); the entity itself will be removed at the next schema cutover. No schema change and no data migration — only the write path changed. (FABLE auditPersistenceStore.swift:177. See openspec changebackup-restore-safety.) -
Main window default size + Live response disclosure now auto-tracks content state. Fresh main windows open at 1200×800 (a new
.defaultSizemodifier added to the mainWindowGroup— distinct from the existing Settings scene’s 880×620). The conversation column gains a.navigationSplitViewColumnWidth(min: 400, ideal: 600)floor via the split-view column-width API so the column never collapses below readability — at minimum window width with the inspector open (sidebar 220 + center 400 + inspector 260 = 880pt) the layout remains fully legible. Existing user-resized windows are unaffected — macOS NSWindow autosave / system scene restoration preserves user frames across launches without application-level@SceneStoragewidth/height keys. TheProviderTraceCard’s “Live response” disclosure (Row 4) now uses a tri-stateLiveDisclosureState— auto-expanded AT FIRST TOKEN (whensnap.textorsnap.reasoningbecomes non-empty), auto-collapsed on finalize, user clicks lock state across mode transitions until view unmount. The pre-first-token pending phase keeps the disclosure compact-with-waiting…-placeholder, not auto-expanded into an empty body. See openspec changeinspector-polish-pass. -
Inspector
ProviderTraceCardtools section is now collapsible. Header becomes aButtonwith a rotating chevron +Tools (N)count; default collapsed via a tri-stateProviderToolsSection.DisclosureState. The section auto-expands when any tool errored, with a· N failedsuffix instatusErrorcolor that persists across collapse so the failure signal survives even when the rows are hidden. User clicks lock the state (.userExpanded/.userCollapsed) so auto-expansion can’t override an explicit user collapse. State is@Statescoped to the section (per-card, not persisted). See openspec changeinspector-tools-disclosure. -
Native file tools succeed reliably under fan-out. A bundle of three layered bugs in the file-tool dispatch path is fixed end-to-end. (1)
ToolRegistry.runUnderlyingExecutionANDToolRegistry.runToolnow wrap everytool.execute(...)invocation inProjectScope.perform { _ in ... }so security-scoped bookmark access is active during file I/O — closes the gap where the canonicalscoped-files-and-tools“Balanced Scope Access” requirement was satisfied forProjectStorebut silently skipped at both tool-dispatch seams, producing intermittent “you don’t have permission” failures on otherwise-valid file reads against sandboxed projects. The two dispatch sites share a per-Project.idProjectScopecache so concurrent fan-out invocations against the same project share a single start/stop access-scope nesting pair. (2)FileReadToolno longer throwsToolError.fileTooLargefor over-cap files — it truncates with a continuation marker that reportsnextOffsetequal to the model-visible byte count (not the byte count the tool read), so the LLM has a correctness-safe path forward on large markdown / log files instead of a dead end.FileToolSupport.truncateIfNeededgains an optionalpaginatedReadInfoparameter and avisibleBytesfield onTruncationResultto compute the offset correctly; this accounts for the subtle case where a file fits under the 1 MB read cap but exceeds the 128 KB result cap (e.g., 145 KB / 420 KB / 73 KB markdown docs) by basingnextOffseton the truncated content’s UTF-8 byte count, not the requested read length. (3)FileReadToolgains explicitoffset: Int?andlength: Int?args for byte-range pagination, with a revised schema description that discourages themaxBytesarg (which LLMs were defaulting to ~20 KB caps, sabotaging reads of normal-sized project docs) and a newFileToolSupport.decodeWithBoundaryBackoff(data:offsetBase:)helper so byte offsets landing mid-codepoint (CJK, emoji, em-dashes) adjust to the nearest valid UTF-8 character boundary instead of failing decode — both the start byte (skip leading continuation bytes) and the end byte (trim trailing incomplete codepoints) are handled. The existingLog.toolsOSLog subsystem gains call sites at both tool-dispatch entry/exit points + a warning when an LLM capsmaxBytesbelow 100 KB, so future flaky behavior is spottable in Console.app. TheToolError.fileTooLargeenum case is preserved for source-stability but is now unused production API (FileReadToolwas its only thrower). See openspec changefile-tool-resilience. -
Settings → Tools picker is now honest for read tools across native and MCP rows, and the engine’s MCP read-only classification is correct end-to-end. Previously, setting a read tool to “Ask” silently behaved like “Always” — the resolver at
AppEnvironment+ToolGate.swiftfolded.askinto.alwaysfor read-only invocations regardless of the user’s explicit choice. Fresh installs now default read tools to “Always” (matching the previous silent-read behavior with a truthful label) and default write tools to “Ask” (unchanged). Toggling a read tool to “Ask” now surfaces the inline approval card on the next invocation. Any existing stored.askvalue on a read tool will produce prompts on the next read — this is the contract the click was requesting. The default-by-classification rule is encoded in a newToolPreferences.defaultFor(toolName:isReadOnly:)overload that both the picker UI binding and the resolver consult, so the visual default and the runtime resolution agree on a single rule. Four engine-layer corrections land in the same change. (1) The MCP read-only classifier now consults each tool’sannotations.readOnlyHintfrom thetools/listresponse (per the MCP specification) —ResolvedToolgainsreadOnlyHint: Bool?,MCPTool.annotationsdecodes tolerantly viatry? container.decodeIfPresent(Bool.self, ...)(non-Bool values resolve tonilwithout aborting the descriptor), and the agent-loop classifier consumes the hint via a new@Sendable (String, UUID?) async -> Boolclosure onAgentLoopConfig.readOnlyClassifier— ConsensusKit consumes only a Bool, never importing MCPKit (which would create a package cycle). The closure’s body lives in the app target’smakeAgentLoopWiring(...); it readsMCPClient.tool(named:)?.readOnlyHintfor MCP names and falls back to the new PolycodeKit-hostednativeWriteToolNamesset for native names. The safe-default direction when a server hasn’t annotatedreadOnlyHintisfalse(mutating), so all unannotated MCP tools — not just destructive ones — begin prompting on default settings. (2) The server-levelMCPServerConfig.readOnlyflag is now honored on the agent-loop coordinator path.LiveAgentToolApprovalCoordinator.requestApprovalDeduped(...)checksawait mcpClient.server(for: serverID)?.config.readOnlybefore invoking the gate and short-circuits to approval when set — mirroring the existing direct-call bypass atMCPClient.swift:201. Previously the agent-loop path silently ignored the flag. (3) The MCP namespaced-name parser inAgentLoopwas reading the prefix"tool_"whileResolvedTool.namespacePrefixwas"mcp_"; both now reference a singlePolycodeKit.MCPNamespacePrefixconstant, fixing the pre-existing mismatch that causedparseNamespacedNameto never identify real MCP tool names. (4) The session approval cache now keys MCP invocations bynamespacedName, so two MCP servers exposing same-named tools (search,query,list, etc.) no longer share session approvals — Server A’s “Approve for Session” decision onsearchdoes not silence Server B’ssearch. The inline approval card footer renders a new “Read-only preview not required” caption for native read tools with no diff payload (parallel to the existing “No diff preview for MCP tool” caption), and MCP Settings rows now render a classification-aware icon trichotomy —eyeforreadOnlyHint: true,wrench.adjustableforreadOnlyHint: false, fadedquestionmark.circlefor unannotated (with a tooltip + accessibility hint “Server didn’t declare read-only status; defaulting to ask.”). The Pattern-C info caption above the picker list now advertises the opt-in path: “Reads are silent by default; switch a read to Ask to see a prompt before each new path.” Three escape hatches for users seeing new prompts on previously-silent MCP tools: mark the entire serverreadOnly: trueat the server level (gate bypassed); toggle the specific tool to.alwaysin Settings → Tools (per-tool opt-out); or update the MCP server to declarereadOnlyHint: trueon the relevant tools (recommended long-term). See openspec changeread-tool-honest-ask. -
Tool-call rendering and approval move to a Claude.app-style three-tier disclosure with inline approval. Cards are replaced by an always-on L1 summary line (“Ran N tools · 1.2s” / “Working — N tools · running…” / “Working — N tools · approval needed” / “M of N tools failed · 1.2s”) expanding into per-provider grouped tool lines (L2) and per-tool mono preview bodies (L3). Per-provider grouping is suppressed in single-peer sessions; multi-peer sessions show a header per provider with
(N) toolschip in first-appearance order with deterministic(createdAt, providerID, modelID, callID)tie-break; lines within each bucket sort reverse-chronological with a 10-line cap and a “Show K older tools” affordance. The modal Tool Approval Sheet is REMOVED and replaced by an inline approval card preserving every structural element (warning glyph, title, subtitle, details well, “Remember for session” toggle, View diff button, Deny + Approve, queue-depth pill). Mutating tools (!isReadOnly) get elevated visual urgency on the inline card —statusError × 8%background, 24×24 warning glyph, 3-ptstatusErrorleading-edge stripe. When multi-peer dedup collapses N concurrent identical-args calls to one approval prompt, ONE canonical card renders under the first affected peer + N-1 slim pointer rows under the others; all bind to the samePendingApprovaland resolve in sync via shared state observation. A new cross-session banner above the conversation column surfaces pending approvals from other sessions with destructiveness-adaptive title prefix ("Action required: …"red vs"Approval pending: …"warning) and click-to-navigate. Engine changes:PendingApprovalcarriesaffectedPeers: [PendingApprovalPeer];InteractiveApprovalGate.requestDecisiontakes apeer:argument;PublishingApprovalGate.requestDecisionnow wraps its continuation inwithTaskCancellationHandlerwith a sync-once resolver (closes a pre-existing leak masked by the modal sheet’s.sheet(item:)setter) AND publishesupdates+removalEventsstreams;SessionApprovalGate.requestApprovalDedupedplumbs the peer triple and appends to in-flightaffectedPeerson cache-hit via the publishing gate’s newappendPeer(_:to:)API.AccessibilityReduceMotiongates the chevron snap-animation;ViewThatFitsfalls the L2 tool-line HStack to a wrapping VStack at accessibility-large Dynamic Type sizes. Folds in a fix for the post-streaming-archive bug where.executingwith non-empty preview wasn’t auto-expanding. See openspec changetool-call-text-forward-rendering. -
Tool-call cards collapse by default with status-driven auto-promotion.
ToolCallCardpreviously rendered its result-preview body inline below the caption strip whenever a non-emptyresultPreviewexisted (~140pt per card; a five-tool investigation sequence consumed ~700pt of vertical real estate). Cards now render as single-line rows by default with a leading chevron (SymbolRegistry.disclosureRightcollapsed /SymbolRegistry.disclosureDownexpanded,.snappy(duration: 0.15)symbol swap) when a non-empty preview exists; the body lives behind the chevron. The disclosure state is owned byToolCallSectionas section-scoped@Statekeyed byAggregatedGroup.id(in-memory only; not persisted) — the new tri-stateDisclosureStateenum (.followStatus/.userExpanded/.userCollapsed) lets user overrides survive status transitions until section unmount. Status-promotion rules:.complete(isError: true, ...)cards auto-expand under.followStatus(failure visibility is the most important property); every other status defaults to collapsed. Chevron clicks always write.userExpandedor.userCollapsed— never.followStatus— so user intent sticks. When a failure is currently collapsed (.userCollapsed+.complete(isError: true, ...)+ non-empty preview), the caption strip renders an inline one-line error-summary text (first ~80 chars of preview, mono 11ptstatusError) so the “what failed” signal is preserved without the body. Contiguous tool runs of 5+ groups within a section render a “Working — N tools • Ts” header above the cards with a trailing “Expand all” / “Collapse all” chip; the chip’s label tracks aggregate state (≥50% expanded → “Collapse all”); the duration segment reads “running…” until every group resolves to a terminal state, then snaps to the cumulative latency formatted compactly (e.g. “380ms” / “1.2s”). The chevron is aButton(not.onTapGesture) so keyboard focus and AX actions work;.accessibilityLabel("Tool result"),.accessibilityValue("Expanded"/"Collapsed"),.accessibilityHint("Double-tap to toggle."). See openspec changetool-call-disclosure-and-promotion. -
Settings → Providers cards render the fan-out toggle on every card. The toggle was previously hidden on the Primary provider card (the original rationale: a derived-state toggle would visually contradict the
Primary/Solochip). Cards now have uniform anatomy: peer cards keep the existing interactive toggle (or the locked-OFF “Disabled” treatment when no models are pinned, unchanged fromprovider-deliberate-model-selection), and primary cards render the toggle visually ON, locked, with label"Always on"and the help tooltip “Primary providers are always queried. Make another provider primary first to disable.” The locked-ON treatment is inert by construction — the underlyingBindingisBinding.constant(true)so accessibility actions and any future code path cannot fire the setter, not just.disabled(true)blocking the SwiftUI gesture. The toggle gains explicit accessibility modifiers (.accessibilityLabel("Fan-out"),.accessibilityValuematching the visible label one-for-one,.accessibilityHintmirroring the help text per branch) so VoiceOver users can discover the lockup reason —.help(...)is hover-only on macOS. The new pure helperProviderCardToggleAffordanceconsolidates the(isPrimary, noPins, isActive)→ matrix logic and is exhaustively covered byProviderCardToggleAffordanceTests(19 tests). See openspec changeprovider-card-toggle-always-visible. -
Every provider requires deliberate model selection before fan-out. The consensus-engine peer model resolver no longer falls back to identity-scoped or kind-scoped descriptors when a non-primary provider has no pinned models — it returns
[]wheneverModelPreferences.explicitPreferences(for:)is nil/empty, AND filters non-empty pin sets throughavailableModelsso stale pins (e.g. a pinned model removed from the curated registry) are dropped at dispatch. The retired auto-pin-top-3-curated-models initialization onAppEnvironment.reloadProviders()is gone; pins now arrive only via two explicit user paths: the onboarding wizard’s new “Pick models” step (Step 3 of the now-six-page wizard) and the post-onboardingProviderDetailViewper-model toggles.Settings → Providerscards whoseenabledTotal(...).enabled == 0render the fan-out toggle.disabled(true)(visually greyed, unclickable) with a.help(...)tooltip pointing users to the detail page to pin a model. TheIdlechip vocabulary is retired (the(toggle ON, no pins)state is no longer reachable under the toggle gate);ProviderContributionStatecollapses to.active/.disabledand the non-primary chip set narrows toActive/Disabled. TheSolopredicate now treats no-pin peers as non-contributors in addition todisabledForFanOutpeers.FanOutIntentDecisionsimplifies from five cases to three (.writeDisabled/.clearDisabledForFanOut/.noOp) — the toggle never writes toModelPreferencesanymore.ExchangeView.anticipatedPeerKeysmirrors the resolver’s filter exactly so the pre-response peer-pill row no longer shows a placeholder that will be filtered out at dispatch. A one-time informational migration toast — “Provider model selection is now required for fan-out. Open Settings → Providers to review.” — fires once on first launch for upgraders (OnboardingPhase == .completedAND newUIStateBlobkeypolycode.ui.didShowDeliberateModelSelectionToastabsent); fresh-install users do NOT see it because their wizard “Pick models” step is the discovery surface, and the wizard’s completion hook seeds the flag to suppress the toast on the second launch. See openspec changeprovider-deliberate-model-selection.
Removed
- Tier 2/3 fallback in
ChatSessionViewModel.makePeerModelResolver. The identity-scoped and kind-scoped descriptor lookups that previously produced a single-element[id]array whenModelPreferences.explicitPreferences(for:)was nil are gone. Engine response to a[]return is unchanged — the existing skip rule (availableModelCount > 0→ omit peer;== 0→ dispatch failing peer) governs. Part of the deliberate-selection requirement above. ProviderContributionState.idlecase and its.idlechip rendering. The state was reachable only when the toggle could be flipped ON while no models were pinned; locking the toggle in that case (above) makes the case unreachable.FanOutIntentDecision.clearModelPrefsand.clearDisabledAndModelPrefscases. The setter no longer writes toModelPreferences— the toggle can only fire when the user has pinned at least one model.AppEnvironment.initializePinsIfNeeded()and thepolycode.ui.pinsInitializedLocalUIStateflag. First-run auto-pinning of the top-3 curated models per configured provider is gone (it was the silent write that historically produced the absent-prefs state the deliberate-selection requirement now bans). Onboarding handles first-run pin writes via the wizard’s “Pick models” step; post-onboarding adds (Available providers section) require a manual pin viaProviderDetailView.
Added
-
Settings → Providers exposes a post-onboarding add path for every direct provider via a new
Available providersband between the configured-providers grid and the Cloud Gateways section, plus anAdd provider ▾header menu that replaces the priorAdd OpenAI-compatiblebutton. The band renders one row per directProviderIdentity.Kind(Anthropic / OpenAI / Gemini / Apple Intelligence) not yet inenvironment.providersand hides entirely when all four are configured. The menu’sDirect APIsection composes dynamically against the same filter; the two always-present items at the bottom (OpenAI-compatible endpoint…,Cloud gateway…) preserve every existing add path. Both surfaces route through the parent-ownedaddingKindstate andProviderKeyEntrySheetmodifier, preserving the existingreloadProviders()+refreshLiteLLMMetadataIfDueside effects on successful configuration. A new sharedProviderKindPresentationhelper consolidates the kind → (glyph, displayName, cardDescription, sheetSubCaption, connectLabel, isLocal) switch that previously lived duplicated inOnboardingProviderCardandProviderKeyEntrySheet’s header; both call sites refactor to consume it in the same change. NewAvailableProvidersSectionTestscovers the filter, summary-text math, and presentation round-trip (18 tests). See openspec changesettings-providers-add-affordance. -
GitHub Actions CI workflows under
.github/workflows/covering lint + build + test + release on Polycode’s new GitHub home. Three workflows replace the deleted.gitlab-ci.yml:ci.yml(auto-trigger on push to main + PR; six jobs —validate-privacy-copy,validate-telemetry-keys,verify-entitlementson Ubuntu, pluslint-swiftlint,build-debug,test-xcteston macos-15);release.yml(workflow_dispatchmanual archive + export, gated onmain, runs validate-telemetry-keys against the CI-injected Release env values as the belt-and-suspenders check);refresh-litellm.yml(workflow_dispatchmanual LiteLLM metadata refresh, opens a PR viapeter-evans/create-pull-request@v7if anything changed — schedule cron pre-wired but commented out). Concurrency onci.ymlcancels in-progress runs on the same ref when a new push arrives. SwiftPM + DerivedData cached acrossbuild-debug→test-xctestviaactions/cache@v4keyed onPackage.resolved+ the three xcconfigs.xcbeautify --renderer github-actionsproduces inline annotations on failures. The original 226-line.gitlab-ci.ymlis preserved in git history at the commit immediately before its deletion. -
.github/PULL_REQUEST_TEMPLATE.mdauthored to replace the never-existed.gitlab/merge_request_templates/referenced byphase-f-trackingtask 7.5. Sections: Summary / Test plan / Deferred work (required gate per phase-f-tracking governance) / Reconciliation surfaces touched (per AGENTS.md “Changing data-handling behavior” guidance) / CHANGELOG entry. GitHub auto-prefills the body for every new PR.
Changed
-
Settings → Providers card’s fan-out toggle now mirrors the chip’s contribution state. The toggle’s
Enabled/Disabledlabel is no longer a flat read ofProviderPreferences.disabledForFanOut— it derives fromproviderContributionState, displaying ON only when the provider is actually contributing (.active) and OFF when state is.idleor.disabled. This eliminates the previous internal contradiction where a card could simultaneously read “Enabled” (toggle) and “Idle” (chip). Toggling fan-out ON when the provider is in the Idle state deletes the explicit-empty preference (so the resolver’s Tier 2/3 fallback dispatches a model on the next submit); the lossy gesture is the documented trade-off — the user’s “I want this enabled” intent overrides their prior “I un-pinned everything” state. The toggle is now hidden on primary cards (the primary always queries, so a derived-state toggle there would create a new contradiction with thePrimary/Solochip).ExchangeView.anticipatedPeerKeysupdated to mirror the engine’s skip prediction so the pre-response streaming peer row no longer shows a placeholder pill for soon-to-be-skipped peers. The two preference stores (ProviderPreferences.disabledForFanOut,ModelPreferences.enabledModelIDs) remain independent at the data layer; the toggle’s setter writes both conditionally. See openspec changefan-out-toggle-mirrors-state. -
Settings → Providers card shows
IdleandDisabledchip states, in addition to the existingActive,Primary, andSolochips.Idle(warning style) appears when fan-out is on for a non-primary provider AND the user has explicitly un-pinned every model in the provider’s detail view (ModelPreferences.explicitPreferences(for:) == Set<String>()ANDavailableModelsfor that identity is non-empty).Disabled(neutral style) appears when fan-out is off for a non-primary provider, fixing a latent visual bug where fan-out-off providers still showedActive. The chip’s predicate exactly mirrors the consensus engine’s new skip rule (below) — both surfaces consume the sameproviderContributionStatehelper inProvidersTabSignalHelpers.swift, so the peer card’s chip and the primary card’s Solo badge can never disagree about whether a peer is contributing. Apple Intelligence is the acute case forIdle(total == 1, single-click reachable). The card’s enabled-count chip (“X / N enabled”) now also uses the warning style whenenabled == 0 && total > 0(in addition to the existingtotal == 0 → "no models"warning). TheSolobadge on the primary card now considers engine-skipped peers (Idle state) as non-contributors in addition to the previousdisabledForFanOut-only predicate. The fan-out toggle is intentionally NOT auto-correlated with model-preference state (preserves the user’s ability to temporarily skip a provider without un-pinning models, and vice versa). -
Consensus engine now silently skips fan-out peers in the
Idlestate.ConsensusEngineConfiggains anavailableModelCount: @Sendable (ProviderIdentity) -> Intclosure (defaulted to{ _ in 0 }for source compatibility). WhenpeerModelResolver(identity)returns[]for a non-primary peer ANDavailableModelCount(identity) > 0, the engine omits the peer entirely (noPeerKey, noTask, noProviderResponse). WhenavailableModelCount == 0, today’s failing-peer surface is preserved — that case represents “metadata has not synced for this provider,” which remains a useful visible failure. The chat-pathChatSessionViewModel.makeEnginewires the closure via a newmakeAvailableModelCounthelper that counts entries inenvironment.availableModelsmatching the identity (same identity-tag-then-kind-tag rule aspeerModelResolverTiers 2/3). The App Intents path (AskPolycodeIntent.swift) is intentionally untouched — it relies on the{ _ in 0 }default, preserving today’s behavior for the non-interactive Siri/Shortcuts surface. Active-mode submits flow through the samemakeEngineconstruction site, so the closure carries through unchanged (perchat-modes’ preserved-config-slots requirement). -
Chat-path resolver distinguishes “preferences absent” from “preferences explicit-empty.”
ChatSessionViewModel.makePeerModelResolver’s Tier 1 now readsModelPreferences.explicitPreferences(for:) -> Set<String>?(a new method that exposes the underlying storage). When the value is non-nil, the resolver returns its sorted contents — even when empty — and SHALL NOT fall through to Tier 2/3. The previous behavior collapsed both cases viaenabled(for:)and silently kept un-pinned-everything providers participating with a Tier 2/3 fallback model. The existingenabled(for:)API is preserved for callers that don’t need the distinction. See openspec changeprovider-idle-state-and-fan-out-skip. -
Settings → Providers header summary reworded from
N disabledtoN available, scoped to direct kinds only (anthropic,openai,gemini,foundationModels). The prior count overloaded the per-provider fan-out “disabled” concept insideProviderSettingsCardand double-counted N-instance.customkinds — every compat instance tickedconnectedup by one butdisablednever went down. The new count is stable (range0..=4): adding a Vertex gateway or a second xAI compat endpoint doesn’t budgeavailable. When all four direct kinds are configured, the· N availablesuffix collapses entirely (summary reads"N connected"). The Settings → Providers zero-providers empty state — previously a centeredPolycodeEmptyStatewith a CTA hardcoded to.anthropic— is now theAvailable providersband’s four rows. See openspec changesettings-providers-add-affordance. -
Migrated the canonical remote from
gitlab.com/amizzo/polycodetogithub.com/izzoa/polycode-apple. Operational infrastructure change: hosting moves from GitLab to GitHub. Done without a dedicated openspec change because the migration is a one-time hosting swap, not a behavior or capability change; the working-tree sweep is captured in this CHANGELOG entry and the file diff. Twelve touched sites:PolycodeCore/Sources/PolycodeKit/Support/SupportArticle.swiftfallbackURL(nowhttps://github.com/izzoa/polycode-apple/blob/main/docs/support/data-lifecycle.md); matchingSupportArticleTestsassertion;openspec/specs/local-data-transparency/spec.mdrequirement + scenario describing the resolver’s repo-hosted fallback;openspec/specs/telemetry/spec.mdreference to thevalidate-telemetry-keysCI job’s host (.gitlab-ci.yml→ GitHub Actions workflow under.github/workflows/);scripts/validate-privacy-copy.shflipped its GitLab-vs-GitHub invariants (now enforces the fallback hosts ongithub.com/izzoa/polycode-appleand rejects anygitlab.comreference in the resolver);scripts/validate-telemetry-keys.shcomment-only update;README.mdProduction-CI section (GitLab CI/CD Variables → GitHub Actions Secrets) + the rotation procedure + the “Rules I need to remember” checklist;CONTRIBUTING.mdPOLYCODE_TEAM_ID instructions;openspec/changes/phase-f-tracking/tasks.md5.6/5.7/7.5references (GitLab CI / merge-request templates → GitHub Actions / pull-request templates);TODOS.mdmac-native-foundationoperational-sign-off list (GitLab runner budget → GitHub Actions equivalent) plus a new2026-05-12-gitlab-to-github-migrationfollow-up section listing the CI-port + PR-template + branch-protection + GitLab-repo-disposition follow-ups;AGENTS.mdSibling repo: polycode-websection gains migration context. Deleted:.gitlab-ci.yml— the historical CI config is preserved in git history at the commit immediately before deletion. Three carve-outs explicitly preserved: (1) historical CHANGELOG entries that reference GitLab CI at the time of adoption (e.g. the entry describing when.gitlab-ci.ymlwas first introduced) — these are a record of past state and would be revisionist to rewrite; (2) theAGENTS.md## Repository state“Archived” list describing2026-04-19-mac-native-foundation’s GitLab-CI delivery — same rationale; (3) the prior changelog entry frompolycode-domain-canonicalizationthat mentions the “GitLab-hosted fallback” — that was true at commit time and is now superseded by this entry. CI gating is INACTIVE until a GitHub Actions workflow ports the jobs (validate-telemetry-keys,validate-privacy-copy,swiftlint --strict, etc.); local./scripts/validate.shruns are the interim verification. Shipped Mac app builds carry whicheverfallbackURLthey were built against; the in-app “Learn more” deep-link from Settings on those existing builds will resolve to GitLab until the GitLab repo’s disposition decision lands (archive / mirror / delete — tracked inTODOS.md). -
Retired the
polycode.appplaceholder domain in favor of the canonicalpolycode.me.polycode.appwas a stand-in used during early development while the real public URL was being decided; it was never purchased and was never load-bearing. The2026-05-11-openrouter-app-attributionarchive establishedpolycode.meas Polycode’s canonical public URL (the eternal OpenRouter app-page identifier per the OR docs). This change sweeps the remainingpolycode.appreferences in source, tests, docs, and one in-flight openspec change so the codebase aligns with the established canonical. Nine touched sites: the in-app “Visit Polycode website” Link in Settings → About (now openshttps://polycode.me); theSupportArticle.dataLifecycle.liveURLconstant (nowhttps://polycode.me/support/data-lifecycle); the matchingSupportArticleTestsassertion; theShared.xcconfigPOLYCODE_SITE_LIVEcomment; thedocs/identifier-strategy.mdUniversal Links domain row (drops the “not yet purchased” parenthetical becausepolycode.meIS the final domain); theapp-store-readinessin-flight change’s proposal + tasks (Marketing URL / Support URL / Privacy Policy URL); and the canonicallocal-data-transparencyspec’sSupport Article URL Resolutionrequirement via a MODIFIED-requirement delta. The live-vs-fallback selector behavior is unchanged —POLYCODE_SITE_LIVEstill defaults toNO, so production builds continue to follow the GitLab-hosteddocs/support/data-lifecycle.mdfallback until the website is wired up. Three carve-outs explicitly preserved (substring matches that look like the domain but aren’t): theLogger(subsystem: "com.izzo.polycode.app", category: "app")logger subsystem atPolycodeKit/Logging.swift(reverse-DNS-style namespace, unrelated to the website); thepolycode.appearance.colorSchemeAppStoragekey atIntegrationKit/Appearance/ColorSchemePreference.swift(the substringpolycode.appis the first 12 characters ofpolycode.appearance— renaming would invalidate every existing user preference at next launch); and any futurecom.izzo.Polycode.appBundle-ID-style identifiers (Apple-platform convention). The spec delta’s new “Non-domainpolycode.appsubstring uses are explicitly preserved” scenario codifies the carve-out list so a future audit-sweep doesn’t trip on them. See openspec changepolycode-domain-canonicalization. -
Inspector trace card’s
First tokstat cell now renders real TTFT values. The 4-stat grid landed byalign-views-with-bundlePhase 2 Step 1 hardcoded theFirst tokcell to"—"becauseProviderResponsecarried no first-token-latency field; the value was dropped at the live → finalized boundary. This change closes the gap end-to-end:ConsensusKit.ProviderResponsegainsfirstTokenMs: Int?,ConsensusEnginecaptures TTFT engine-locally from the same.textDeltachunk event the chunk handler already iterates (NO reach across theConsensusKit ← Polycodemodule boundary into the app-targetLivePeerSnapshot), the SwiftDataProviderResponseRecordgains a corresponding nullable column, andProviderTraceCard.statGrid’sFirst tokcell reads the persisted value. (a) NewPolycodeCore/Sources/ConsensusKit/PeerFirstTokenStore.swift— afinal classwithOSAllocatedUnfairLock<[PeerKey: ContinuousClock.Instant]>internal state (synchronous API by design per design.md §Decision 2; an actor-backed alternative would force an async cascade through ~6 call sites). One instance is constructed atConsensusEngine.execute(...)scope and threaded into BOTH branches of the execute split —FanOutContext.firstTokenStorefor the fast path,AgentLoopConfig.firstTokenStorefor the agent-loop path. (b)consumeProvider(fast path) andrunAgentLoop’s chunk consumer (agent-loop path) both callfirstTokenStore.record(peerKey:, firstTokenAt: ContinuousClock.now)on first.textDelta— idempotent first-write-wins..reasoningDeltachunks do NOT trigger capture, matching the existingLivePeerSnapshot.firstTokenAtrule. (c) Both branches’ terminal-response assembly (buildPeerResponse,buildResponseFromOutcome, plus the synthesized timeout/cancel responses inrunOnePeer) read from the store and stampfirstTokenMson the emittedProviderResponse. Timeout-after-partial-stream preservation (design.md §Decision 6): a peer that produced a partial stream then hung on the timeout supervisor MUST carry its real TTFT on the synthesized.timedOutresponse — the shared store + cross-arm read inrunOnePeer’s inner-group timeout-arm enforces this invariant. (d)AgentLoopOutcomegains afirstTokenMs: Int?field; all 8 outcome construction sites inrunAgentLoopthread the value through;AgentLoopConfiggainsfirstTokenStore: PeerFirstTokenStore+peerStartedAt: ContinuousClock.Instantso the agent-loop chunk consumer can write to the same per-execute store and compute its elapsed-ms against the per-peer dispatch start. (e) Schema amend followspre-1-0-schema-amend-mode’s head-amend path (pre-cutover by default — no signed build had left the developer’s machine and no v1.0.0 tag; explicit-statement triplet documented in tasks.md §1.2): edit the canonicalModels/ProviderResponseRecord.swiftdirectly, NOSchemaV<N+1>cut, accept that the dogfood store wipes on next launch viaModelContainerFactory.openOrRebuild’s134504checksum-mismatch handler. Three-checkpoint cutover-trigger gate (pre-implementation, pre-schema-edit, pre-archive) documented in design.md §Decision 10 for the freeze-path fallback if a signed build escapes mid-implementation; all three checkpoints fired clean. (f)PersistenceStore+FanOut.writeFanOutResponsescopiesresponse.firstTokenMsinto the persisted row;loadFanOutResponsesreads it back; the two-tierordinal-first sort at the load site is unchanged. (g)ProviderTraceCard.statGrid’sFirst tokcell swaps its hardcoded"—"literal for a new privatefirstTokDisplay(for: response)helper that performs an explicitif let ms = response.firstTokenMs { return "\(ms)ms" }unwrap — the explicit unwrap avoids the Swift Optional-interpolation gotcha that would render"Optional(412)ms"for direct interpolation. (h) Tests added:ConsensusEngineFirstTokenMsTests(4 fast-path scenarios: reasoning-gap-then-textDelta, immediate-textDelta, no-chunks-closes, fail-before-first-chunk);ConsensusEngineAgentLoopFirstTokenMsTests(3 scenarios across two seams — directrunAgentLoopoutcome contract + full-engineProviderResponsevalue chain);ConsensusEngineTimeoutFirstTokenMsTests(2 timeout scenarios: partial-stream-then-hang preserves TTFT vs. silent-hang leaves nil);PersistenceStoreFanOutFirstTokenMsTests(3 round-trip scenarios — non-nil, nil, no-default-substitution);ProviderTraceCardFirstTokSourceScanTests(1 body-scan test asserting the hardcoded em-dash literal was removed from thevalue:parameter at theFirst tokcell). All 13 new tests pass. Note on parity-matrix coverage: the per-scenario parity matrix atSpecParityMatrix.mdis intentionally scoped to design-system specs only; expanding it to chat-experience + swift-persistence would require either backfilling ~710 pre-existing canonical scenarios OR taking responsibility for ~50 scenarios across other in-flight changes — both out-of-scope. Behavioral coverage for the new scenarios is provided directly by the Section 13 Swift Testing tests above. (i) Spec deltas: 3 MODIFIED requirements inchat-experience(Provider Trace Cardcodifies the 4-stat-grid shape + TTFT-survives-finalize invariant;Provider Trace is scoped to an exchangeaddsfirstTokenMsto the hydration list;First-Token Latency Surfacingrewritten to reflect that TTFT IS now preserved onProviderResponse) + 2 MODIFIED requirements inswift-persistence(SwiftData-Owned Domain Modelsadds the column;Fan-Out Response Persistencethreads it through write+load, and corrects a stale sort-order clause that said “sorted deterministically by providerID” — the actual implementation has been two-tierordinal-first since SchemaV3). User-visible outcome: finalized trace cards’First tokcell stops showing—on every new fan-out and starts showing real TTFT (e.g."412ms"); timeout-after-partial-stream still shows the real value (matches the live streaming chip); cancel-before-first-chunk and never-streamed peers continue showing—(graceful degradation matches the existing nullable-on-pre-V3 pattern). Foundation Models TTFT-observability deferred to manual signed-build verification per design.md §Decision 5 (added toTODOS.mdGated on app-store-readiness). -
Aligned every view family with the 2026-05-09 design bundle’s JSX recreation; closes the composition-layer drift the lint/source-scan rules can’t catch. The just-archived
codify-design-ruleschange bound the enforceable drift surface (voice, imagery, hover/press, numeric tabular, materials) as SwiftLint + source-scan tests. This change closes the visual-shape surface — proportions, paddings, surface fills, border weights, composition patterns where a view passes every lint and still diverges from the bundle’s JSX shape. Phase 1 produced a per-view drift inventory underopenspec/changes/align-views-with-bundle/sweep-notes/covering 100% of the 105 view files (Composer / Conversation / Inspector / Main / Onboarding / Overlays / Settings / Sidebar / Miscellaneous). 15 drift items identified, 20 documented divergences, 81 exempt files (mostly “no bundle counterpart” — Polycode-specific affordances post-dating the bundle). Phase 2 lands the 15 fixes:- Composer editor card (3 fixes): corner radius
Radius.standard.value(10pt) →Radius.pill.value(14pt) at 4 sites (background fill, clipShape, both borderOverlay branches, focusHalo proportionally+3). Unfocused border weight0.5pt→1.5pt; focused border bumps to1.5ptfor stroke-jump-free focus transition (was1.0pt). Focus halo alpha0.25→0.14per the bundle’sbox-shadow: 0 0 0 3px rgba(197,90,31,0.14)spec. CV-D6 (send button gradient fill) verified —PolycodeButton(style: .primary)already renders theaccentPrimaryStart → accentPrimaryEndgradient via the existing.primarybackground branch, matching the bundle’svar(--pc-accent-gradient)exactly. CV-D5 (send-button kbd composition) deferred — the live composer’s hint row already carries⌘↵; nesting it inside the Send button would duplicate the affordance. - Conversation user bubble + tool-card (3 fixes): user-bubble border swaps from
borderSubtle0.5pt toaccentPrimarySolid.opacity(0.20)1pt (matches the bundle’s1px solid rgba(197,90,31,0.20)and pairs naturally with the existinguserBubble10–14% accent fill). User-bubble width cap0.72 → 0.76(matches bundle’smax-width: 76%). Tool-call card body gets an explicit.background(PolycodeColors.codeBg)so the mono preview reads as a code block — previously the body inheritedsurfaceElevatedfrom the card chrome, which conflated the metadata-strip and result-strip surfaces. - Inspector trace-card 4-stat grid + chrome (3 fixes): new
PolycodeSectionHeader("Per-provider trace")above theForEachof trace cards inInspectorView— the bundle’s.insp-seclabel was absent in the live design. Trace-card corner radiusRadius.standard.value(10pt) →Radius.card.value(8pt) at the 3 sites (background fill, border overlay, highlight overlay) — brings the card in line withToolCallCardwhich already usescardradius, and with the bundle README’s “tool-call cards in the conversation use 8; message bubbles use 10” taxonomy. HEADLINE drift: 2×2 stat grid (First tok/Total/In / Out/Cost) added below the waterfall in finalized mode; the header sheds the inlinetokenCaption+costCaptionrows (kept in live + livePrimary modes for at-a-glance streaming readouts). ThestatGridis aLazyVGridwith two flexible columns, mono labels uppercased at 10pt with 0.06em tracking, value lines at 13pt monomonospacedDigit(). This Step 1 of the grid surfaces TTFT as a permanent stat across peers (a load-bearing analytical use case the live design was losing on finalized cards); the First tok cell renders—as a placeholder pending the schema amend that persistsfirstTokenMson the fan-out peer row — that work spawns a separateinspector-stat-grid-ttft-persistenceproposal (seeTODOS.md). - Sidebar group-label primitive + search strip + MCP strip (3 fixes):
PolycodeSectionHeaderfont shifts from.system(size: 10, weight: .semibold)to.system(size: 10, weight: .medium, design: .monospaced)so section labels render in monospace per the bundle’s.group-labelfont: 500 10px var(--pc-font-mono)CSS.SidebarSearchStripfont11pt → 12pt, vertical paddingSpacing.small.value(8pt) →5pt, explicitframe(height: 26)to match the bundle’s.pc-sidebar .searchheight: 26px; font: 400 12px var(--pc-font-sans).MCPStatusStripcaption + error count fonts shift from.system(size: 11)to.system(size: 11, design: .monospaced)per the bundle’s.mcp-stripfont: 400 11px var(--pc-font-mono). Halo around the MCP status dot (live design’s richer-than-bundle treatment) and trailing settings-gear button kept as intentional divergences — the dot halo matches the broader Polycode haloing idiom, and the gear is a useful deep-link. - Documented divergences (20 across the families): live shapes that intentionally diverge from the bundle’s recreation are recorded in each family’s sweep-notes file with one-sentence rationale per the spec’s “Intentional divergence requires explicit rationale” scenario. Headliners: composer model-picker pill keeps its light
surfaceInsetchrome (carries richer state than the bundle’s compact dark pill); assistant message body stays icon-column + flat Markdown (the live design outgrew the bundle’s.bubble.assistantform once the FAN-OUT trace row, citation rewriting, reasoning toggle, sources row, and action row landed);NavigationSplitViewadaptive column widths NOT pinned to the bundle’s240/340static-canvas pixels (codified as a spec scenario and now belt-and-suspendered byBundlePixelDimensionPinningTests.testNoBundlePixelDimensionsPinnedInProductionSource— scans production source for declarations matching(sidebarWidth|inspectorWidth|defaultWindowWidth|defaultWindowHeight)paired with the bundle’s pixel literals 240 / 340 / 1280 / 820); titlebar center cluster keeps the editable session-title + project chip (richer than the bundle’s static “poly/code” wordmark — wordmark stays on the About tab + onboarding sheet); session-row leading provider-glyph stack omitted in favor of section-header accent-dot project affinity (perSessionSidebarRow.swift:11source comment). #Previewadditions: new#Previewblocks onSidebarSearchStrip(locks the 26pt height + 12pt font) andMCPStatusStrip(locks the mono caption + 3 status permutations). Existing#Previewblocks onUserMessageBubble(5 variants),ToolCallCard(3 variants), andProviderTraceCard(multi-permutation grid) auto-render the new shape — no preview-side literal updates needed.design-system-foundationspec delta: 1 ADDED requirement “Views Conform To The Bundle’s JSX Recreation Or Document Intentional Divergence” with 6 scenarios codifying (a) the bundle counterpart resolution (JSX file / CSS selector / Primitive / README § / “no bundle counterpart” exemption), (b) the archived-sweep-notes-are-authoritative-drift-record rule, (c) the bundle-pixel-dimensions-not-pinned scenario, (d) bundle-regeneration-triggers-new-sweep-change, (e) the “no bundle counterpart” exemption, and (f) the explicit-rationale requirement for intentional divergences. Plus 6 newSpecParityMatrix.mdrows + theBundlePixelDimensionPinningTestssource-scan test for the code-checkable scenario. See openspec changealign-views-with-bundleand itssweep-notes/archive for the per-view drift inventory.
- Composer editor card (3 fixes): corner radius
-
Codified the narrative half of the design system as binding enforcement. The Polycode design bundle’s hard rules around voice, imagery, hover/press behavior, and numeric tabular figures previously lived as English prose in
DesignKit/README.mdand the bundle’sproject/README.md; the live codebase honored them by convention but nothing failed CI on drift. This change promotes those rules into SwiftLint custom rules + source-scan tests underPolycodeCore/Tests/DesignKitTests/. (a) Four new SwiftLint custom rules in.swiftlint.yml:no_exclamation_in_user_text(no!inText("…")literals),no_marketing_we(no sentence-initial collective “We” in user copy),no_curly_quote_in_source(curly quotes U+201C / U+201D / U+2018 / U+2019 forbidden in Swift source; Markdown unaffected), andno_foreign_gradient(onlyPolycodeColors.accentGradientmay be referenced;LinearGradient/RadialGradient/AngularGradient/EllipticalGradient/MeshGradientconstructions outside DesignKit fail). All four honor the same-line// design-conformance[DC-VOICE-NNN]: <reason>carve-out via the unified negative-lookahead pattern. (b) Five new source-scan tests:GradientUsageScanTests(belt-and-suspenders forno_foreign_gradient),HoverBehaviorScanTests(hover-conditioned ternaries must mutate opacity only, not color identity — block-form deferred per design.md §Q2),PressBehaviorScanTests(DesignKit components with@State isPressedmust use canonicalscaleEffect(isPressed ? 0.97 : 1.0)+polycodeAnimation(Motion.swift, reduced: Motion.reducedSwift, value: isPressed), no color change on press),NumericTabularScanTests(site-list-driven assertion thatTokenEstimateView.swift,PolycodeProviderPill.swift, andPolycodeChip.swiftapply.polycodeNumericTabularor.monospacedDigit()), andEmojiAbsenceScanTests(no emoji codepoints in Swift source string literals — Swift-side scan because ICU regex parsing of\x{NNNN}/\U000NNNNNin YAML-loaded SwiftLint patterns is unreliable; the bundle’s brand-mark★(U+2605) is allow-listed). (c) New reserved carve-out familyDC-VOICE-*(wildcard, one registry row covers all usages) added toCarveOuts.mdand recognized byCarveOutRegistryTests. The existing 10 SwiftLint custom rules’ negative-lookahead patterns updated to includeVOICEin the family alternation so DC-VOICE markers suppress any design-conformance rule uniformly. (d) Inline fixes for the small batch of violations the new linters surfaced:SidebarProjectPicker.swift:79gains DC-004 marker for the project-folder accent gradient (sibling of DC-001/DC-002, promote in the same follow-up audit);AppSidebarView.swift:112straightens curly quotes around session name in the delete-confirmation text per the bundle rule.PolycodeChip.swift,TokenEstimateView.swift, andPolycodeProviderPill.swiftadopt.monospacedDigit()on their relevantText(...)invocations —PolycodeChip’s adoption covers the inspector trace card’s TTFT/latency chips transitively. (e) Spec promotion: 6 ADDED requirements with ~30 scenarios indesign-system-foundation/spec.md(Voice & Content Rules; Imagery & Gradient Rules; Hover Behavior; Press Behavior; Numeric Tabular at known sites; Materials reduce-transparency fallback) + the Carve-Out Marker Convention requirement modified to reserve theDC-VOICE-*family. (f) Closing housekeeping: archived the obsoletemac-design-systemopenspec proposal toopenspec/changes/archive/2026-05-11-mac-design-system-obsolete/— its accent color (Polycode Teal-Violet) and consensus-halo tokens were superseded by what shipped (lapis-ember accent; halo tokens removed). User-visible behavior unchanged; the change is enforcement-only — future drift becomes a CI failure rather than a silent quality slip. See openspec changecodify-design-rules.
Added
-
PolycodeApprovalPickerDesignKit primitive. Three-option-only outlined-segmented-control idiom for surfaces requiring a fixed three-way choice (initial consumer: per-tool approval defaults in Settings → Tools). Generic overHashable & Sendableenum value; init signature enforces exactly three labeled options at the type level. Visual: roundedsurfaceInsetcontainer, 2ptstatusWarningring on the selected segment with no fill change. Keyboard:.focusable()+ left/right arrow advancement with wrap, single-tab-stop. Accessibility:accessibilityValue+accessibilityAdjustableActionso VoiceOver announces “1 of 3, Always” rather than three independent buttons. NewApproval Pickersection in the Design Book +SpecParityMatrix.mdparity rows +PolycodeApprovalPickerTests.swift(13 tests). See openspec changetools-tab-visual-refresh. Subsequently generalized + renamed toPolycodeSegmentedControlbyoutlined-segmented-control-rollout— see Changed entry below. -
App Group Container Migration. Polycode’s primary persisted state — the SwiftData store (with the GRDB-backed FTS5 index inside it),
LargeResultStorecache, andLiteLLMMetadataRefreshersnapshot — now lives under the App Group container at~/Library/Group Containers/group.com.izzo.polycode/Library/Application Support/Polycode/instead of the per-app sandbox~/Library/Containers/com.izzo.Polycode/Data/Library/Application Support/Polycode/. The move is the prerequisite for cross-target features (future iOS app, share extensions, widgets, App Intents extensions) — App Group containers are Apple’s sandbox-friendly affordance for sharing storage across targets in the same developer team. Resources/Polycode.entitlements gainscom.apple.security.application-groupscontaininggroup.com.izzo.polycode, with thePolycode.entitlements.sha256baseline regenerated. NewPolycodeContainernamespace inPolycodeKitis the single seam for resolving every persisted file location inPolycodeCore; direct calls toFileManager.default.urls(for: .applicationSupportDirectory, ...)andfor: .cachesDirectoryare now SwiftLint-blocked outside the canonical declaration site. The namespace exposesgroupIdentifier,dataDirectoryURL,storeFileURL,cachesDirectoryURL, andliteLLMMetadataURL; failure modes (missing entitlement, broken developer-portal registration, bundle-ID mismatch) fatal-error at first launch with a diagnostic naming the App Group identifier and the underlyingFoundation.Error. A@TaskLocaltest override (PolycodeContainer.$testOverride.withValue(...)) keepsswift testrunning outside an App Sandbox container without racing across parallel tests.IntegrationKit/SharedContainer.identifierre-exportsPolycodeContainer.groupIdentifierso the App Group literal lives in one source location. See openspec changeapp-group-container-migration. -
Identifier Strategy Document. New
docs/identifier-strategy.mdcaptures the canonical Bundle-ID convention (com.izzo.Polycode.<surface>), Team-ID resolution (single canonical source: env-var-drivenConfig/Shared.xcconfig$(POLYCODE_TEAM_ID)— signed Release builds set it, local Debug leaves it unset and uses automatic Personal-Team signing; never hardcoded inproject.pbxproj), App-Group container (group.com.izzo.polycode, reserved-not-shipped), iCloud container (iCloud.com.izzo.Polycode, reserved-not-shipped), Keychain access group ($(AppIdentifierPrefix)com.izzo.Polycode, shipped viaResources/Polycode.entitlements), the existingpolycode://URL scheme + its routing table (session/{uuid},prompt?q=...,settings/{pane},trace/<providerID>/<modelID>), and thecom.izzo.polycode.backupdocument UTI (file-extension association for.polycodebackupSQLite snapshots — registered as aUTExportedTypeDeclarationsentry, NOT as a URL scheme as a long-standing internal misreading suggested). README pointer added under the “Where to add what” section. The doc and themac-native-foundationspec’s “Cross-Platform Identifier Strategy” requirement carry the same canonical literal values; edits SHALL land together (two-surface reconciliation, narrowed fromlocal-data-transparency’s four-surface pattern). Future-reserved spaces (Universal Links domain, push notification topic, background URLSession identifier) are listed with the future change owning each. See openspec changeios-platform-readiness. -
Integrity Status Surface. The toolbar shield in
MainViewand the “Launch integrity” row in Settings → Advanced are now real UI tied to a publishedAppEnvironment.integrityStatusaggregate. Two probes run at launch (and on user-initiated recheck): a Keychain sentinel round-trip viaSecretStore.integrityCheckResult()(typed-result variant; the existing throwingintegrityCheck()is preserved as a back-compat wrapper) and a SwiftData sentinel round-trip via the newPersistenceStore.integrityCheck()against a one-rowIntegrityProbeRecordmodel. The toolbar shield colors green (statusSuccess) / yellow (statusWarning) / red (statusError) keyed off the aggregate, surfaces a one-line headline tooltip (“Healthy — 2 min ago” / “Checking…” / “Keychain:”), and opens an IntegrityStatusPopovershowing per-probe rows + a “Recheck now” button + an “Open Advanced settings” deep-link (via the newNotification.Name.polycodeAdvancedIntegrityFocused). Settings → Advanced “Launch integrity” mirrors the same source of truth and supports rechecking from the row. Both surfaces shareAppEnvironment.recheckInFlightso triggering a recheck from one disables the button on the other for the duration of the round. Launch-time integrity probing was previously wired but unreached —FatalBanner.Kind.integrityFailedexisted but no code path ever appended one.AppEnvironmentFactory.live()now kicksrecheckIntegrity()once at construction, and the round routes catastrophic Keychain failures (sentinel-write failed withOSStatus -25291/-25308or “Keychain is locked”) tofatalBannersvia the newisCatastrophicKeychainFailure(_:)classifier; the toolbar shield’s hide-when-fatal rule defers to the FatalBannerStrip in that case. Telemetry: newintegrity.checkRan(outcome:probe:duration:)event routed PostHog-only (not Sentry breadcrumbs — failed probes are aggregate health metrics, not crash-shaped errors); never carries reason strings, file paths, or credential bytes. Both probes have a 2-second UI-level deadline that races againstTask.sleepso a wedgedSecItem*call doesn’t block the toolbar; the underlying call may continue running on its actor (Swift task cancellation cannot interrupt synchronous Core Security calls), and the single-slot debounce prevents stacked probes from accumulating. Pre-1.0 schema-amend mode for the newIntegrityProbeRecord(head-amendSchemaV10.models; data-loss accepted; no backup-preservation requirement). RenamedPersistenceKit.IntegrityStatus(GRDBPRAGMA integrity_checkresult) toStorageIntegrityStatusto disambiguate from the new app-levelPolycodeKit.IntegrityStatus. See openspec changeintegrity-status-surface. -
Enterprise Gateway Cohort — Vertex AI, AWS Bedrock, Azure OpenAI. Polycode now supports three enterprise cloud gateways alongside the existing direct providers: Vertex AI (Google Cloud), AWS Bedrock (Amazon), and Azure OpenAI Service (Microsoft). Configure each under Settings → Providers → Cloud Gateways → Add Cloud Gateway. [Path since changed — cloud gateways are now added from Settings → Providers → Add provider → Cloud gateway…] Each instance is its own consensus peer (
ProviderIdentity.id = "<vertex_ai|bedrock|azure>.<lowercased-uuid>"), so same-SKU peers across direct-Anthropic, Vertex-Claude, and Bedrock-Claude don’t collapse in fan-out. Cohort model metadata flows from LiteLLM’svertex_ai/*,bedrock/*,azure/*namespace entries via a new kind-scoped lookup (LiteLLMManifest.entry(for:kind:)); no curated rows inmodels.jsonfor cohort kinds. Cohort credentials never appear in logs or telemetry payloads (eight new cohort-specific redaction patterns inTelemetryKit/Scrubbers.swiftcover PEM private-key blocks, compact JWTs, Googleya29.*access tokens, AWSAKIA*/STSASIA*keys, AWS STS session-token blobs, Azureapi-key:headers, AzureazureOpenAIAPIKeyJSON fields, and GCPproject_idJSON fields). Per-gateway:- Vertex AI reads a service-account JSON once, JSON-encodes the credential into Keychain (
cloud.<uuid>.credential), and never re-reads the file. Per-request auth uses a sharedCloudTokenCacheactor that mints OAuth2 tokens via JWT-bearer assertion grant (RS256 viaSecurity.framework), caches them for ~1 hour with eager refresh, and serializes concurrent callers behind a single-flight + epoch-guard pattern that survives invalidation races. Query dispatches on model-ID prefix:gemini-*→streamGenerateContentURL withGeminiStreamDecoder;claude-*→streamRawPredictURL with the Vertex-Anthropic-shape envelope andAnthropicStreamDecoder.fetchRemoteModels()issues two parallel calls (publishers/google/models+publishers/anthropic/models) and concatenates. - AWS Bedrock uses the unified
ConverseStreamAPI (bedrock-runtime.<region>.amazonaws.com/model/<id>/converse-stream). Each request is signed with a CryptoKit-backedSigV4Signer(validated byte-for-byte against AWS’sget-vanillareference vector + the documented signing-key derivation example).BedrockEventStreamDecoderdemuxes the binary AWS EventStream framing (12-byte prelude, headers, payload, CRC validation) into typedBedrockEventvalues, surfacingthrottlingException/validationException/ etc. asBedrockExceptionKindcases that the adapter maps toProviderError.BedrockConverseStreamDecodertranslates the in-scope ConverseStream event vocabulary directly intoStreamChunk(text, tool-use, reasoning, signature, redacted-content, usage, finish); out-of-scope events (image content, citations, guardrail traces) and unknown future kinds are logged once and skipped (forward-compat).BedrockModelIdentifierrecognizes foundation-model IDs (anthropic.claude-...), geo-prefixed inference profiles (us.*,eu.*,apac.*,global.*), and full ARNs as the samemodelIdparameter. - Azure OpenAI Service routes through per-deployment Chat Completions URLs (
<resource>.openai.azure.com/openai/deployments/<deployment>/chat/completions?api-version=...) with theapi-keyheader. EachAzureDeploymentmaps a user-chosen deployment name to an underlying OpenAI model (e.g.gpt-4o-prod→gpt-4o). The deployments editor in the add-instance sheet lets users add/remove mappings;fetchRemoteModels()returns the configured deployments without an HTTP call (Azure has no global enumerate endpoint). Wire format reusesOpenAICompatStreamDecoder.
See openspec change
enterprise-gateway-cohortfor the full architecture (CloudInstance+CloudCredential+CloudTokenCache+BedrockEventStreamDecoder+BedrockConverseStreamDecoder+SigV4Signer+JWTSigner+ the kind-scoped LiteLLM lookup) and the four-surface privacy reconciliation (LocalDataInfoCard,OnboardingPages/OnboardingProvidersPage,DiagnosticsSectionwith explicit “diagnostics out of scope for cohort” note,docs/support/data-lifecycle.md#cloud-gatewayssection). - Vertex AI reads a service-account JSON once, JSON-encodes the credential into Keychain (
Fixed
-
Provider Disconnect/Remove now works everywhere, confirms first, and reassigns the primary. Four connected fixes that all stem from one root cause — provider removal was copy-pasted across four surfaces, each independently wrong. (1) The provider detail page’s Disconnect button was a silent no-op: it posted
.polycodeProviderDisconnectRequested(with a comment claiming “the parent pops the stack and removes”) but no observer for that notification existed anywhere — the receiver was never written, and NotificationCenter’s stringly-typed wiring meant nothing flagged it.ProvidersTabnow observes it (mirroring the working.polycodeProviderAddKeyRequestedwire), pops the navigation stack, and removes. (2) The Providers grid card’s Remove silently failed for cloud-gateway instances: cohort identities render as grid cards (they’re loaded intoenvironment.providersfromConfigBlob.cloudInstances) and can open the detail page, but the oldremove(provider:)routed every non-.customkind to the singletonremoveProvider(kind:), which mutatesblob.kinds(cloud instances live inblob.cloudInstances) and targets the wrong Keychain slot — so the instance was never removed. (3) Removing the current primary left a danglingprimaryProviderID:reloadProviders()only reassigns when the ID isnil, so no grid card showed the Primary badge until relaunch. (4) Every working removal affordance (grid card, Cloud Gateways row, onboarding card) deleted the stored Keychain credential immediately with no confirmation — one stray click wiped a credential the user had to re-fetch from the vendor. All four are fixed by collapsing the three duplicatedremovemethods into one sharedAppEnvironment.removeConfiguredProviders(_:)executor: it resolves the removal to a kind-correct route (removeProvider(kind:)/removeCustomEndpoint(id:)/removeCloudInstance(id:)) via a pure, unit-testedProviderRemovalPlanner, clears and reassigns the primary when the removal targets it (deleting the persisted selection blob when no providers remain), and reloads once. Every removal affordance — detail Disconnect, grid card Remove, Cloud Gateways row Remove, and the onboarding wizard’s per-card Remove — now routes through a destructiveconfirmationDialogwhose copy is kind-aware (it names the Keychain-credential deletion for credential-bearing kinds and omits that claim for on-device Foundation Models). The three providerNotification.Namedefinitions, previously scattered acrossProviderDetailView/ProviderDetailHeaderRow, are consolidated intoProvidersTabNotifications.swiftso a poster without an observer is greppable in one place. See openspec changefix-provider-disconnect. -
Right-aligned user message bubble no longer clips past the inspector panel. The previous layout used
HStack { Spacer(minLength: 0); VStack(.trailing).containerRelativeFrame(.horizontal) { width * 0.76 } }to size the bubble to 76% of its scroll container’s width; the container-detection drift across SwiftUI versions allowed the positioned frame to transiently exceed the visible column, surfacing as the prompt clipping past the inspector panel when the conversation column compressed (e.g., the default 1200pt window with the inspector open — the original bug-report screenshot rendered “what is the current ‘state’ of this project” as “…this pro…”). Replaced with a double-frame pattern: an inner.frame(maxWidth: 720, alignment: .trailing)caps content at 720pt and right-aligns within the cap, and an outer.frame(maxWidth: .infinity, alignment: .trailing)expands to the column’s content area and trailing-aligns the inner frame — so the bubble’s right edge always sits at the column’s right padding edge (aligned with the assistant body’s right edge) regardless of text length or column width. The 720pt cap is an aesthetic-continuity constant (≈940pt default-content-width × 76% rounded) chosen for layout-feel parity with the priorcontainerRelativeFramemeasurement; NOT mathematically tied to the inspector panel’s width. Columns narrower than 720pt shrink the bubble with the column. Includes hosting-harness regression coverage atPolycode/PolycodeTests/Views/Conversation/UserMessageBubbleLayoutTests.swift— 4 Swift Testing cases viaNSHostingViewpin the layout invariants at wide / long / narrow / empty column fixtures. Surgically landed on the swift-markdown-ui baseline (no delta-swift dependency). Commitd3577f3. -
Tool-call chips render in the conversation transcript again, and now appear at the moment the model declares the call rather than after persistence catches up. See openspec change
tool-call-chip-rendering. -
Vertex AI now routes global-endpoint requests to the bare host
aiplatform.googleapis.cominstead of the invalidglobal-aiplatform.googleapis.com. All four URL builders inVertexProvider(buildGeminiRequest,buildAnthropicRequest,validate,listing(publisher:)) flow through a singlevertexHost(for:)helper that returns the prefixed host for regional locations and the bare host forregion == "global". Settings → Providers → Cloud Gateways → Add Vertex now exposes aGlobal (preview-model availability)region option. [Path since changed — the Vertex add sheet now opens from Settings → Providers → Add provider → Cloud gateway… → Vertex AI.] This unblocks Gemini 3.x preview SKUs (gemini-3.1-pro-preview,gemini-3-pro-preview,gemini-3-flash-preview,gemini-3.1-flash-lite-preview,gemini-3-pro-image-preview,gemini-3.1-pro-preview-customtools) which Vertex serves only onlocations/globalper Google’s documentation; calling them from a regional instance previously returned a 4-second 404 with no actionable hint. See openspec changevertex-global-endpoint. -
Live fan-out trace honesty. Three connected fixes that all stem from one root cause — the inspector and composer surfaced phase-incorrect signals because they conflated per-peer
.finishchunks with engine-level fan-out completion. (1) Per-peer chip no longer flips to a misleading “done” mid-fan-out: the underlyingLiveStatus.finishedcase was removed, the chunk handler’s.finisharm is now a state-no-op, and per-peer status stays at.streaming(showing TTFT) until.fanOutDoneclears the snapshot and finalized cards take over — the rationale is that in agent-loop mode (!query.tools.isEmpty)ConsensusEngineemits.providerChunk(_, .finish)per-iteration of the agent loop, not per-stream-closed, so the previous transition was fundamentally unreliable. (2) Primary peer’s lifecycle is now visible in the inspector trace from t=0 to exchange completion via a dedicatedlivePrimarySnapshot: PrimarySnapshot?slot onChatSessionViewModelwith explicit four-state vocabulary (queued → running agent loop → synthesizing → ready); a chunk-handler routing rule sends primary-keyed.providerChunkevents to the primary lane (so the primary card mirrors agent-loop chunks during Phase 2 ofrunAgentLoopPhasesinstead of being invisible),.fanOutDoneoverrides into.synthesizing, andfinishStream(success:)is a defensive terminal-close so the lane never sticks in.synthesizingeven when the engine ends without a.consensusFinishchunk. The primary’speerKeyis filtered out of the finalized fan-out response set wheneverlivePrimarySnapshotis non-nil to avoid SwiftUI duplicate-id crashes in agent-loop mode (where.fanOutDonecarriespeerResponses + [primaryResponse]). (3)PolycodeFanOutTraceRowcaption is now phase-aware:.fanout → "fanning out…"(no glow),.primary → "synthesizing…"(motion-glow icon),.done → hidden, replacing the previous “alwayssynthesizing…while streaming” behavior; thePolycodeSynthesizingIndicatorprimitive was renamed toPolycodePhaseCaption(text:showingGlow:)to reflect the phase-aware role. Replay degrades gracefully — prior exchanges synthesize a finalized primary card fromExchange.primaryProviderID/primaryModelID/finishReason/completedAtwithout TTFT, total-ms, or usage chips (no schema amend; synthesis timing is transient diagnostic metadata). See openspec changelive-fanout-status-honesty. -
Composer model-picker pill now displays the count of pickable models — the per-provider row sum
Σ visibleModels(for: provider).countacross configured providers, matching the menu rows it opens — instead of the count of configured providers. The previous label said"{N} models"butNwas wired toenvironment.providers.count, so a user with 2 providers and 5 enabled models saw"2 models". Same correction applied to the accessibility label. Pluralization handles count 1 vs many ("1 model"vs"{N} models"); when the sameModelDescriptor.idappears under multiple provider instances (e.g. a Claude model offered by both an Anthropic-direct provider and an OpenRouter provider) each provider’s row is counted independently rather than collapsed into a global ID set. See openspec changecomposer-model-pill-count. -
Multi-turn tool calling against every adapter that accepts a structured-messages array now succeeds — assistant
.multipartmessages no longer silently drop their.toolCall/.toolResultparts before wire serialization, and Gemini signedfunctionCallparts now preserve theirthoughtSignatureon the same outbound part. AffectedOpenAIProvider(Responses API; observed live as HTTP 400 “No tool call found for function call output with call_id …”),GeminiProvider(observed on Gemini 3.1 Pro Preview as HTTP 400 “Function call is missing a thought_signature in functionCall parts”),OpenAIShapeDelegate(Chat Completions API for.openaiV1ChatCompletionscustom endpoints; latent), andAnthropicShapeDelegate(Anthropic Messages API for.anthropicV1Messagescustom endpoints; latent — the delegate previously borrowedOpenAIShapeDelegate.flattenMultipartwhich masked the bug under both compat shapes simultaneously). Anthropic’s native adapter was already correct. -
Fixed an
EXC_BREAKPOINTcrash inToolCallSection.bodytriggered when twoToolResultRecordrows shared acallID(reachable under multi-pin via OpenAI-compat gateways like LM Studio / Ollama / some OpenRouter proxies that recycle deterministiccall_0-style IDs across peer streams within one submit). The grouping helper usedDictionary(uniqueKeysWithValues:)and trapped on the duplicate key.ToolResultRecordnow carries(exchangeID, providerID, modelID)symmetrically withToolCallRecord(pre-1.0 amend; nullable columns; second store wipe), the section’s_toolResultsquery is scoped byexchangeID, and the call→result lookup picks rows by exact(providerID, modelID)match (tie-breaking to most-recentcompletedAtonly on the rare case of duplicate exact-identity rows). The inspector’s per-peer Tools section also disambiguates correctly under duplicate callIDs by filtering its own_toolResultsquery on the same triple. The non-agent-loop direct-execute writer (ToolRegistry.recordResult) now threadsexchangeIDso its rows preserve column-level symmetry with the call rows; user-visible behavior in both views is unchanged for the direct-execute path. The streaming-overlay surface (ChatSessionViewModel.toolCallStatusByCallID) is the third site in the same vulnerability class but causes only transient mis-status during streaming (terminal state from the persisted result wins per the priority order); deferred toTODOS.mdwith a 14-day soak gate. See openspec changemulti-model-fanout-per-provider§2D / §3.7-3.10.
Changed
-
OpenRouter app-attribution headers expanded; Polycode now appears on the public rankings. Both OpenRouter presets (
.openRouterand.openRouterAnthropic) now stamp the full three-key attribution dictionary on every outgoing request:HTTP-Referer: https://polycode.me,X-OpenRouter-Title: Polycode,X-OpenRouter-Categories: programming-app. The legacy single-keyX-Title: Polycodestamp is dropped —X-OpenRouter-Titleis the canonical namespaced name per OpenRouter’s published contract;X-Titleis a back-compat alias maintained on OR’s side that Polycode no longer needs to send. Existing dogfood installs carrying the prior factoryX-Title-only stamp upgrade in-place on next launch via a new strict-predicate migration (migrateOpenRouterAttributionIfNeeded) that runs after the existing URL migration inProviderConfigurationService.loadProviders()— the two migrations share a singlesetSyncedPrefwrite per invocation. The predicate has two arms: (a) URL satisfies the canonical post-URL-migration shape (https://openrouter.ai/api, default port, no userinfo/query/fragment, exact/apipath); (b)extraHeadersis empty OR exactly equals["X-Title": "Polycode"]. Both arms must match. User-customized URLs (regional proxy on:8443,/v2experimental path) AND user-customizedextraHeadersare left untouched — same intentionality-respecting posture as the URL migration. User-visible outcome: Polycode now appears onopenrouter.ai/rankings(daily/weekly/monthly), on per-model “Apps” tabs, and atopenrouter.ai/apps?url=https://polycode.meas a tracked app. Closes the v1 Non-Goal from2026-04-24-openrouter-preset-polish(“AddingHTTP-Refererattribution. Deliberately omitted from the v1 stamp until Polycode has a canonical public URL”). See openspec changeopenrouter-app-attribution. -
Outlined-segmented-control rollout — uniform orange-outlined idiom across every segmented value-picker surface.
PolycodeApprovalPicker(just landed bytools-tab-visual-refresh) renamed toPolycodeSegmentedControland generalized from three-discrete-tuples to N-option array (options: [(value: Value, label: String)]). Two preconditions trap at init:options.count >= 2(single-segment / empty controls have no meaningful arrow-key wrap) andSet(options.map(\.value)).count == options.count(uniqueness — duplicate values would render multiple selected rings). The OLD thin native-Picker wrapper atPolycodeCore/Sources/DesignKit/Components/PolycodeSegmentedControl.swiftis DELETED — the new outlined primitive overloads the name. Six call sites migrate uniformly:Polycode/Polycode/Views/Settings/ToolsTab.swift:157(3 options,ToolApprovalDefault);Polycode/Polycode/Views/Settings/AppearanceTab.swift:60-66(3 options,ColorSchemePreferencevia typed-binding wrapper that normalizes unknown@AppStorageraw values to.systemper Decision 7 — defends against the case where UserDefaults holds a removed-case raw value and the component’s defensiveaccessibilityValuefallback would otherwise announce a wrong selection to VoiceOver);Polycode/Polycode/Views/Settings/Usage/UsageRangePicker.swift:14-21(5 options,UsageRangeSelection— the enum gainsnonisolated+Sendableconformance for theHashable & Sendableconstraint);Polycode/Polycode/Views/Settings/ProviderDetail/ProviderDetailSectionHeader.swift:40-46(2 options, view-mode toggle);Polycode/Polycode/Views/Settings/ProviderDetail/ModelAdvancedDrawer.swift:66(4 options, reasoning effort);Polycode/Polycode/Views/Onboarding/ProviderAddCustomEndpointSheet.swift:100(2 options, endpoint shape). Visual / keyboard / a11y contracts preserved verbatim from the predecessor primitive — only the API generalizes, not the rendering. New SwiftLint ruleno_native_segmented_picker(.swiftlint.yml) forbids.pickerStyle(.segmented)ANDSegmentedPickerStyle()(whitespace-tolerant regex covering both forms) at any production source path with NO path-exclusion of the canonical implementation file — the body-scan testPolycodeSegmentedControlSourceScanTestsis the multi-line + comment-aware safety net (SwiftLint custom regex rules are line-based per.swiftlint.yml:215and cannot catch split-line invocations alone). Test suite renamedPolycodeApprovalPickerTests.swift→PolycodeSegmentedControlTests.swiftwith 16 tests (the 13 carried over + 3 N-cardinality wrap tests at N == 4 / 5 / 2; precondition-trap tests deferred to compile-time-guarantee parity-matrix entries per Decision 3 because Swift Testing cannot cleanly catchpreconditiontraps). Showcase renamedApprovalPickerShowcase.swift→SegmentedControlShowcase.swiftand extended to render an N-option matrix (cardinalities 2 / 3 / 4 / 5 each at first- and last-selected states; 8 instances total at.frame(maxWidth: 360)). DesignBookView case.approvalPicker→.segmentedControl; section title “Approval Picker” → “Segmented Control”. Spec deltas:design-system-primitivesMODIFIED requirement renamedPolycodeApprovalPicker Three-Option Outlined Picker→PolycodeSegmentedControl Outlined Pickerwith cardinality-and-uniqueness updates + 4 new scenarios (empty-options trap, single-option trap, duplicate-values trap, 2-option visual rendering, N-option arrow-wrap);Primitives Surfaced In Design BookMODIFIED to extend the section list and require the showcase matrix; ADDEDNo Native Segmented Picker In Production Sourcerequirement codifying the SwiftLint rule + body-scan test contract. Cross-referencestools-tab-visual-refreshas the chain’s prior step (the orange-ring idiom landed there for the Tools tab; this rollout extends + uniformizes). Codex peer-review pass identified 7 issues across 3 high / 2 medium / 2 low; all 7 folded in pre-apply (typed-binding correctness fix, no-path-exclusion-escape-hatch lint policy, hard archive-ordering preflight viagrep -c+test ! -d, scope wording carving out navigation chrome, whitespace + long-form regex coverage, empty-options scenario, showcase max-width discipline). See openspec changeoutlined-segmented-control-rollout. -
Settings → Tools tab visual refresh. Seven coordinated styling shifts on the Tools-tab body to match the supplied design comp: (1) header retargeted from raw
.font(.system(size: 17, weight: .bold)).tracking(...)to the.polycodeHeadlinetoken (matchesAppearanceTab/PrivacyTab/ProjectsTab); (2) tool rows + session-approval chips lift via the existing.polycodeShadow(.soft)two-stop card-lift token; (3) mutating-tool icon swapspencil→wrench.adjustableuniformly acrossfile_write/file_edit/file_delete/file_rename(per the design comp).file_deleteretains destructive semantics at the a11y-label layer viaaccessibilityLabel("Delete file (destructive)")so VoiceOver users still hear the difference; (4) per-tool approval picker swapped from native.pickerStyle(.segmented)to the newPolycodeApprovalPickerDesignKit primitive — three-option-only outlined picker (roundedsurfaceInsetcontainer, 2ptstatusWarningring on selected with no fill change). The 2pt ring (vs. 1pt) is the floor that preserves perceptibility against antialiasing erosion sincestatusWarningonsurfaceInsetcomputes to ~3.01:1 in light mode (just above the 3:1 non-text-UI WCAG threshold; documented inResources/Contrast.mdknown-regressions and added toContrastFixture.swiftnonTextUIPairsso the audit visibility matches the regression list). The new primitive replicates segmented-Pickerkeyboard semantics (.focusable()+ left/right arrow advancement with wrap, single-tab-stop) and VoiceOver semantics (accessibilityValue+accessibilityAdjustableAction) that the customHStack-of-Buttons composition would otherwise lose; (5) session-approval rows replaced from sidebar-tunedPolycodeListRowto inline chip cards withlineLimit(1)+.truncationMode(.middle)on the path-prefix and.layoutPriority(1).fixedSize(horizontal: true, vertical: false)on the trailing project-id + relative-time metadata so long paths truncate gracefully without pushing trailing metadata off-screen. Combined.accessibilityElement(children: .combine)with a custom announce-as-one label so VoiceOver doesn’t scrub through five sub-elements; (6) “Clear session cache” button demoted from.destructive(red) to.secondary(regular bordered) — clearing the session approval cache is recoverable (next tool call re-prompts), so the red treatment over-signaled risk; (7) footer caption replaced from the literal-template"Per-project overrides live in Projects > {active}."(same broken-template class as the{provider}bug retired bydissolve-models-tab) with the genericized"Per-project overrides live in Projects.".AppEnvironment.activeProjectexists (line 60) but the multi-window scope mismatch — Settings is one-window-per-app whileactiveProjectis per-window scene state — makes a literal substitution unreliable; the genericized caption is honest. TheLocalizable.xcstringsentry at line 869 is updated alongside;ToolsTab.swift:8doc comment retargeted to match the new.secondarycache-clear treatment. Spec deltas:settings-thoroughnessMODIFIED (“Tools Tab with Per-Tool Approval Defaults” — single paragraph dropping the visual descriptor “destructive” on the cache-clear button, replacing with non-prescriptive non-destructive-prominence prose; all ten existing scenarios carry through verbatim);design-system-primitivesADDED (“PolycodeApprovalPicker Three-Option Outlined Picker” — API contract + visual contract + keyboard contract + a11y contract, eight testable scenarios) plus MODIFIED “Primitives Surfaced In Design Book” extended to include the new primitive’s section. NewPolycodeCore/Tests/DesignKitTests/PolycodeApprovalPickerTests.swift(13 tests; pure helperstatic func value(after:in:by:)exposed for arrow-key wrap testing without ViewInspector).SpecParityMatrix.mdextended with parity rows for the new primitive’s eight scenarios. See openspec changetools-tab-visual-refresh. -
Composer model-picker pill swaps the raw model ID for a compact brand label —
"Claude","GPT","o3","Gemini","Apple Intelligence". The leading provider glyph already encodes vendor; the trailing text now states product family. NewBrandLabelResolverinProviderKitconsultsLiteLLMManifestvia the kind-scopedentry(for:kind:)accessor (NOT the unscopedentry(for:)whose docstring flags arbitrary-suffix collision risk when the same SKU exists under multiple kinds), with a.custom-only unscoped fallback for OpenRouter / Vercel AI Gateway / Cohere / Mistral entries (the kind-scoped accessor’s namespace gate rejects those because no litellmlitellm_providervalue equals"custom"). Resolution chain: direct vendor keys ("anthropic"→"Claude";"openai"/"azure"apply a model-ID prefix split —gpt-*→"GPT",o[0-9]+(-.*)?→ the leading o-version"o1"/"o3"/"o4";"cohere"/"cohere_chat"→"Command";"mistral"→"Mistral"); Vertex sub-key disambiguation ("vertex_ai-anthropic_models"→"Claude"; othervertex_ai*withgemini-*IDs →"Gemini"; else"Vertex"— fixes the would-be regression where akey.hasPrefix("vertex_ai") → "Gemini"shortcut mislabels Vertex-Claude); Bedrock multi-vendor sniff with broad region stripping (stripsus./eu./apac./global./au.— broader than the original three-prefix list, coverslitellm-models.jsonlines ~635 and ~671); OpenRouter / Vercel AI Gateway aggregator sniff (strips the routing prefix then matchesanthropic/.../google/.../openai/.../meta-llama/.../mistralai/.../cohere/.../qwen/...); kind switch fallback;ProviderIdentity.displayNamefinal fallback so the chip never renders empty. Two reconciliation tweaks land alongside: the picker Menu’s outer.help("Pick the primary model for this session")is REPLACED (NOT nested) by.help(primaryDisplayName)carrying the fullModelDescriptor.displayName("Claude Sonnet 4.6","GPT-5 Codex") — SwiftUI’s outer-scope tooltip dominates label-level tooltips so a nested approach would render the static string instead of the dynamic full displayName. The pill’saccessibilityLabelinterpolates the same full displayName so VoiceOver retains variant specificity. The 0.5ptborderSubtlestroke overlay is dropped from the pill chrome (the design comp does not carry it);surfaceInsetfill,Radius.pill.valuecorner radius, and padding envelope are preserved verbatim. Primary-descriptor selection preserves the existingchat.selectedModelID-over-environment.primaryModelIDprecedence so a mid-session primary override still describes the chat-selected model. The picker Menu body is unchanged — fulldisplayNamerows live there for picking. See openspec changecomposer-pill-brand-labelandPolycode/Polycode/Views/Composer/ComposerModelPickerPill.swift. -
UIStateBlobSwiftData entity split intoSyncedPrefs+LocalUIState. User state persisted by Polycode is now partitioned by sync scope at the entity level: user-meaningful preferences that should follow the user across devices live inSyncedPrefsunder thepolycode.prefs.*key namespace; per-device UI state (sidebar grouping, project explorer expansion, composer recents, model registry caches, usage telemetry baseline, notification scheduling state) lives inLocalUIStateunderpolycode.ui.*. Sync-readiness preparation: when SwiftData + CloudKit mirroring ships in a future change, the simplest mirror path is to copySchemaV<current>.modelswholesale; under the prior single-UIStateBlobshape that would have been all-or-nothing for user state, with no per-key sync filter (CloudKit doesn’t support row-predicate-pinned subset configurations at the entity level). Done now under in-forcepre-1-0-schema-amend-modeso the V10→V11 lightweight migration accepts a one-time wipe of any dogfooding preference data; doing it post-cutover would require a real custom-stage transformer that partitions every key into the right entity. No behavioral change to existing preferences other than the wipe. Per theSync-Scope Partitioning of User Staterequirement (newly added toswift-persistence),PersistenceStoreexposes six typed accessors (getSyncedPref/setSyncedPref/deleteSyncedPrefandgetLocalUIState/setLocalUIState/deleteLocalUIState); each setter wrapstry modelContext.transaction { ... }, preserves theSettingsHistoryEntryaudit-log append on set, adds a deletion-markerSettingsHistoryEntryappend on delete (new behavior — the priordeleteUIStatewas silent), and traps loud on a wrong-prefix key. Application-layer dedup replaces@Attribute(.unique)per the CloudKit-Compatible Schema Shape constraint. See openspec changesplit-ui-state-blob. -
Settings → Models tab dissolved; default-primary picker, model-metadata refresh, and pinned-models list now live under Settings → Providers (Primary section at top + Pinned models and Model metadata footer sections). Deep-link
polycode://settings/modelsredirects to Providers as a back-compat alias. Side fix: the broken{provider}placeholder text in the prior migration banner is gone with the banner. Settings tab count drops from 11 → 10. See openspec changedissolve-models-tab. -
Settings → Providers cards now lift two health signals from the detail page. Each provider card on the Settings → Providers root tab gains an
X / Y enabledchip (mirroring the detail header’senabledModelCount / totalModelCountcount, with ano modelswarning chip when the matching model list is empty) and anauto-synced Nm agoliveness line driven byRemoteModelCache’s last-fetch stamp (showingsyncing…during a refresh,not yet syncedbefore the first successful fetch, andlocalfor the on-device Foundation Models adapter). The previous redacted API-key preview pill (sk-••••••••XXXX · primary) is removed — it carried no decision-relevant information and its absence is a small privacy improvement. Browse-then-drill navigation, the fan-out toggle, the Primary / Solo / Active status chip, and the context menu are unchanged. Enabled/total counts are computed synchronously at render time fromAppEnvironment.modelPreferences+availableModels, so the chip stays current after a user toggles model enable state inside the detail page and pops back. Only the last-sync stamp is async-loaded; a stale-result guard re-checksTask.isCancelledand a sorted[providerIdentityID]snapshot before assigning to@State. See openspec changericher-provider-grid-cards. -
Persistence store relocates from per-app sandbox to App Group container. The SwiftData store, GRDB-backed FTS5 index (inside the same SQLite file),
LargeResultStorecache, andLiteLLMMetadataRefreshersnapshot now live under~/Library/Group Containers/group.com.izzo.polycode/Library/Application Support/Polycode/. Previously these all lived under the per-app sandbox at~/Library/Containers/com.izzo.Polycode/Data/Library/Application Support/Polycode/. Required prerequisite for cross-target features (future iOS app, share extensions, widgets, App Intents extensions). Under the in-force pre-1.0 amend-mode discipline, the migration accepts a one-time store wipe — first launch finds an empty App Group container, creates a freshSchemaV<current>store, and the developer sees an empty Polycode (same outcome as adev-wipe-store.shinvocation). The old per-app store directory is left intact on disk as a forensic artifact; manual cleanup is tracked inTODOS.mdunder “Gated on first-iOS-target consumer”. Privacy four-surface reconciliation:LocalDataInfoCardPrivacy + Advanced bodies,docs/support/data-lifecycle.md(Wipe + Backup + Restore + Migrate sections), and thelocal-data-transparencyspec scenario all reference the new App Group path; Onboarding + Diagnostics surfaces audited and have no path mention to update.scripts/dev-wipe-store.shretargets to the App Group store directory. The amend-mode triplet is acknowledged: no signed build has left the developer’s machine, data loss is accepted for this change, no backup preservation is required for this change. See openspec changeapp-group-container-migration. -
PolycodeCore is now a multiplatform SwiftPM package.
PolycodeCore/Package.swiftplatformsarray bumped from[.macOS(.v15)]to[.macOS(.v15), .iOS(.v18)]. The iOS 18 floor is grounded byPolycodeCore/Sources/PersistenceKit/Models/MCPToolCallCount.swiftline 6’s#Unique<MCPToolCallCount>([\.serverID, \.toolName])SwiftData macro, which is iOS 18+ / macOS 15+. The package’s iOS leg is exercised manually viaswift build --triple arm64-apple-ios18.0 --sdk "$(xcrun --sdk iphoneos --show-sdk-path)"(cross-compile from macOS); CI does NOT yet run this leg (perios-platform-readinessDecision 5 — the SwiftLint custom rule below provides drift prevention until an iOS app target lands and CI gets an iOS leg). Three module-level changes were required for cross-compile readiness: (1)DesignKit/Components/PolycodeCodeBlock.swiftgot an iOS pasteboard branch (UIPasteboard.general.string = code) paired with the existing macOSNSPasteboardpath under#if canImport(AppKit) ... #elseif canImport(UIKit) ... #endif; the AppKit import is consolidated at file top with a// swiftlint:disable:next no_unconditional_appkit_importcompanion. (2)ToolKit/ProjectStore.swiftProjectStoreOptions.securityScopedstatic is gated#if os(macOS)(the underlyingURL.BookmarkCreationOptions.withSecurityScopeandURL.BookmarkResolutionOptions.withSecurityScopeareAPI_UNAVAILABLE(ios)per the iPhoneOS SDK); theProjectStore.init(persistenceStore:options:)default-argumentoptions: ProjectStoreOptions = .securityScopedis dropped — every caller now suppliesoptions:explicitly. The macOS app target’s call site inPolycode/Polycode/App/AppEnvironmentFactory.swiftpasses.securityScoped; the project-bookmark feature is macOS-only at this change (iOS-side ToolKit consumers using.plainseestartAccessingSecurityScopedResource()return false, harmless).ToolKit/ProjectScope.swiftcarries an inline invariant comment explaining the macOS-bookmark-mint precondition. (3)ProviderKit/Adapters/FoundationModelsProvider.swift@available(macOS 26, *)widened to@available(macOS 26, iOS 26, *)because theSystemLanguageModel,LanguageModelSession.streamResponse(to:), andString: Generablesurfaces require iOS 26 (uncovered when the iOS cross-compile actually ran during apply); macOS app-target call sites already wrap construction inif #available(macOS 26, *)and continue to work unchanged on macOS-only compilations. (4)IntegrationKit/GlobalHotkey.swift(CarbonRegisterEventHotKey) is wrapped end-to-end in#if os(macOS) ... #endif; iOS apps cannot install process-wide keyboard listeners and there’s no iOS counterpart. The macOS app target’s consumers (HotkeyCoordinator,AppearanceTabrecorder,GlobalHotkeyTip) are macOS-only and keep working. New.swiftlint.ymlno_unconditional_appkit_importcustom rule (severity: error, scoped toPolycodeCore/Sources/.*\.swift, exemptingPolycode/Polycode/.*) catches future unconditionalimport AppKit/import Cocoadrift at pre-commit time even without a CI iOS leg. See openspec changeios-platform-readiness. -
Vertex 404s carrying the body
Publisher Modelwas not found or your project does not have access to itare now translated to a structuredProviderError.vertexModelNotAvailableInLocation(modelID:projectID:region:). The composer toast renders a one-line summary (Vertex 404: <id> not available in <region>. Try region=global.) and the inspector trace card adds multi-line hint text covering the three common causes (preview / global-only model, allowlist not granted, project lacks Vertex API) plus an isolation suggestion to trygemini-2.5-proon the same instance. The composer model picker also renders an inline informational warning beneath anyglobalOnly-flagged Gemini SKU when the owning Vertex instance is configured for a non-global region — preventing the picker-vs-actually-invocable cliff before the request goes out.ModelDescriptorgains aglobalOnly: Boolfield (defaultfalse); curatedmodels.jsonrows for the six known global-only Gemini 3.x preview SKUs are taggedglobalOnly: true. See openspec changevertex-global-endpoint. -
FAN-OUT pill row now shows per-peer status (pending/streaming/succeeded/failed/timedOut/cancelled) via a trailing icon pip and a status-tinted progress track. Inspector trace card and pill row now share the same
PeerDisplayStatusaggregator so per-peer status never drifts between surfaces. NewPolycodeProviderPill.Statusenum on the DesignKit primitive (six cases, optional initializer parameter, accessibility label extended with the status word). The pill’s internaleffectiveProgressderives track-render-or-not from status —.streaming/.succeeded/.failed/.timedOutrender a full status-tinted track,.pending/.cancelledrender no track at all. Legacy callers that passprogress:withoutstatus:(composer model picker) preserve today’sprogress: 1.0 → statusSuccess / else accentPrimarySolidtint rule bit-for-bit.ExchangeView.pillsnow derives the pill set from a union of finalized + live + anticipated peer keys (deduplicated, primary-first, otherwise stable alphabetical) so an unanticipated live peer produced by a Tier 2/3 fan-out fallback both gets an aggregator status AND a rendered pill. Inspector trace card chip dispatches style/icon ondisplayStatusonly — inline switching onLivePeerSnapshot.statusorProviderResponse.statusis fenced by a parity unit test. See openspec changeper-peer-pill-status. -
Pinning multiple models for a single provider now fans out one consensus peer per pinned model. Three pinned OpenRouter models = three OpenRouter peers in fan-out, dispatched in parallel; the inspector renders one trace card per
(provider, model)peer with a model row in the header, citation chips disambiguate by provider+model, Sources-row pills disambiguate, and tool traces attribute to the specific model that fired them. PreviouslyChatSessionViewModel+Resolvers.makePeerModelResolvercollapsedModelPreferences.enabled(for:).sorted().firstto a single model per provider; the resolver now returns[String]and the engine fans out one task per element. The fan-out filter that used to exclude an entire provider when it was the primary now excludes only the (primary identity, primary modelID) effective dispatched model — so a sibling pin on the primary’s provider (Claude Sonnet alongside an Anthropic Claude Opus primary) now participates in fan-out instead of being silently dropped. NewPeerKeyvalue type (inPolycodeKit) pairsProviderIdentitywithmodelID: String?and threads throughConsensusChunk.providerChunk,SynthesisChunk.synthesisTagMap, the synthesis tag generator (SynthesisPromptResult.tagToPeer),AgentLoopConfig.peerKey, the inspectorForEachID, and the citation deep-link. SwiftDataToolCallRecordgains a nullablemodelID: String?column via the in-force pre-1.0 amend rule (no schema bump; store wipes on next launch with one pre-rebuild backup retained). See openspec changemulti-model-fanout-per-provider. -
Citation URL scheme widened from
polycode://trace/<providerID>topolycode://trace/<providerID>/<modelID>to support multi-pin fan-out routing. Both URL components are percent-encoded with the existingidentityAllowedCharset(alphanumerics +-._~) so OpenRouter-style model IDs likeanthropic/claude-sonnet-4round-trip as a single percent-encoded path segment (%2Frather than splitting into two segments). The parser usesurl.path(percentEncoded: true)to preserve raw encoding before splitting. Legacy single-component URLs continue to parse defensively (returningPeerKey(providerID:, modelID: nil)) so any pre-amend persisted citation chip still routes — though pre-amend rows do not exist post-wipe. -
ConsensusEngineConfiggains an optionalperProviderConcurrencyCap: Int?knob (defaultnil= unbounded; matches today’s behaviour for single-pin users). When set, an actor-mediated semaphore (PerProviderSemaphore) bounds simultaneous in-flight peer tasks perProviderIdentity— different providers never contend with each other. Engine-level only; not yet user-facing. Tracked as a follow-up gate inTODOS.mdto flip the app-side default to1if soak data shows 429 pressure on rate-limit-sensitive providers. -
Pre-1.0 schema migrations now support an amend-and-wipe path with a pre-wipe backup safety net for schema-touching changes that don’t require local data preservation.
ModelContainerFactory.openOrRebuildcopies store sidecars to a sibling<store>.pre-rebuild-<timestamp>directory before wiping; if backup fails, throws a newError.backupBeforeRebuildFailedand refuses to wipe. Retains the most recent 3 backups (rotation). Diagnostic log distinguishes confirmed schema-mismatch (Core Data 134504 →Log.persistence.notice) from unbridged underlying causes (.warning). Newscripts/dev-wipe-store.shdeveloper affordance for deterministic store reset (default--backup,--no-backupopt-out). The graduated rule self-deletes at the cutover trigger (signed build leaves the developer’s machine OR v1.0.0 tag, whichever first); seeCLAUDE.mdand openspec changespre-1-0-schema-amend-mode(rule) andpre-1-0-schema-cutover(dormant cutover plan) for the full discipline.
Added
- Watch each fan-out peer stream in the inspector with first-token latency, partial reasoning, and live tool calls (post-approval). The Provider Trace panel now populates while the fan-out is in flight instead of staying empty until synthesis ends — every peer card shows a live status chip (
…pending →TTFT Nmsonce the first text token arrives →done), a 3-line tail-following preview of the streaming text, and an opt-in “Show reasoning” toggle when the model emits chain-of-thought content. The assistant message bubble gets a parallel “Show reasoning” toggle for synthesis-phase reasoning. Live cards transition to finalized cards in place (no re-mount, scroll position stable, expanded disclosures preserved) via a unifiedForEach(traceItems, id: \.peerKey)source. Pending tool-approval rows do not appear until approval is granted (a documented v1 limitation). See openspec changelive-fanout-trace. - Project excluded-patterns now persist. The Save button in Settings → Projects > excluded-patterns editor was a dead wire (input dropped silently, no on-disk effect); both
ProjectStore.addProject(url:name:excludedPatterns:)and the editor now round-trip the patterns through SwiftData via a newexcludedPatternsData: Data?column onProjectBookmark. NewProjectStore.updateExcludedPatterns(projectID:patterns:)API persists edits + re-resolves the security-scoped bookmark in-place; failures surface viaToastCenterwith the editor staying open so input isn’t lost. Subsequentlist_directory/grep_search/find_filestool calls honor the new patterns immediately. - Sidebar project explorer “Show N more” affordance is now interactive. Previously
.disabled(true)(a rendered-but-permanently-greyed dead control); now bumps a per-parent-path visible-entries count by 25 each tap. Initial visible page size is 25 entries; tapping expands to 50, 75, etc. up to the underlying reader’s 500-entry cap. State is view-local — does not persist across app relaunches per the projectstore-gap-closure design. - New
ProjectStore.listDirectory(projectID:relativePath:limit:offset:)paginated API returning aDirectoryPagevalue with both the slice and the post-exclusion total, so callers can render “Show N more” without a separate count fetch. Applies the project’sexcludedPatternsbefore slicing;offsetpast the post-exclusion entry count returns an empty array (not an error). - SwiftData
SchemaV9addsProjectBookmark.excludedPatternsData: Data?(JSON-encoded[String]). LightweightSchemaV8 → SchemaV9migration;BackupManager.currentSchemaVersionbumped to9. Frozen V8 snapshot lands atSchema/V8/*_V8.swiftper the schema-freeze convention. Pre-V9 rows migrate withnil; readers fall back toExcludePatterns.defaults. BakedPolycodeStore-V8.sqlite+ sha256 fixture for chained V3→V9 migration testing. - New Settings → Modes tab with
ChatModeCRUD, basic composer mode picker, and built-in#quickcheap-consensus mode. Modes bundle a primary, fan-out panel, and system-prompt-append into a named, user-explicitly-invoked submit preset. The composer dropdown is intentionally unpolished — a siblingchat-modes-composerchange replaces it with a styled chip +#nameprefix accelerator.#quickships with primary(anthropic, claude-haiku-4-5), panel[anthropic, gemini, openai], and a sidecar peer-model resolver that pins Gemini togemini-2.5-flashand OpenAI togpt-5-nanofor cheap-consensus runs. Active mode is per-window via@SceneStorage("activeModeID")— selection survives app restart for restored scenes per the existing per-window persistence pattern. Invalid modes (deleted primary provider, unknown model ID, panel collapse) fall back to user defaults with a non-blocking banner toast and stampExchange.modeName == nil. Partially-degraded modes (one missing panel peer, primary intact) silently drop missing peers and stampExchange.modeName == mode.name. SwiftDataSchemaV8addsExchange.modeName: String?(lightweight migration, frozenSchema/V7/snapshot, bakedPolycodeStore-V7.sqlitefixture).BackupManager.currentSchemaVersionbumped to8. NewLog.modessubsystem for loader anomalies (rename re-materialization, duplicate-name dedup) with idempotent-write guard so the loader’s own writes don’t re-trigger reload churn. Settings tab list now eleven tabs (Modes inserted between Models and Tools). - Tool calling on every provider that natively supports it (Anthropic, OpenAI, Gemini, OpenAI-compat) plus a no-op degraded-mode for Foundation Models. Adapters now emit unified
StreamChunk.toolCallRequested(ToolCall)chunks; tool results round-trip back to providers in their native wire shape. Failure modes (truncated_arguments,invalid_json,duplicate_callID) are mapped toProviderError.toolCallInvalid(reason:)so the engine can isolate per-provider tool-call failures without poisoning the whole submit. - Multi-turn agent loop. When the active project is set and tools are wired, every fan-out provider runs an independent agent loop with file + MCP tools (
read_file/write_file/list_directory/ etc., plus all registered MCP tools). The primary then runs its own loop with the full toolset including writes; synthesis is tool-less. Reads fan out to every provider, native writes are primary-only (Pattern C). Per-iteration tool calls flow as.providerChunk(provider, .toolCallRequested)chunks; results flow back as.providerChunk(provider, .toolResultSubmitted). - Per-tool approval gate with cross-provider de-duplication. Three providers asking to read the same file inside one submit collapse to a single approval prompt, and the user’s decision (
Approve Once/Approve for Session/Reject) attributes back to all three. Argument canonicalization is recursive:{"a":1,"b":2}and{"b":2,"a":1}collapse to the same dedupe hash. Per-(provider, tool) gate matrix and per-MCP-tool primary-only flag are tracked as future work. - Settings → Tools now lists MCP-hosted tools alongside the nine native tools. Each MCP row carries a server-name chip and the same
Always/Ask/Denypicker as native tools (keyed onnamespacedNameso prefs don’t collide across servers exposing the same tool name). The list refreshes automatically when the MCP registry changes viaMCPClient.toolsChanges(). - Soft / hard tool-call cap UX. Per-provider counter trips a soft cap at 25 (default) and emits
.toolBudgetSoftCapso a banner (“Polycode has called 25 tools while answering with Anthropic. Continue?”) can pause at the iteration boundary; the user picksContinue 25 more(default) orStop and summarize(which runs one final tool-less iteration). Hard cap at 100 (default) force-terminates a runaway loop, synthesizes a terminal isError ToolResult for any in-flight call, and emits.toolBudgetHardCap. Both caps are tunable perConsensusEngineConfig. - Per-provider tool-call attribution in the inspector.
ProviderTraceCardgains a per-provider Tools section listing each call (status, latency, tool name, arguments preview) attributed to that exchange’s(providerID, exchangeID)pair. Pre-V7 rows withnilproviderID are intentionally omitted from per-provider cards — the bubble-level aggregation handles them. LargeResultStoreintegration for oversized tool results. Results whose UTF-8 size exceedsToolLimits.maxResultBytes(128 KB) now stash full bytes in the disk-backed cache and surface a UUID viaDispatchedToolResult.largeResultStoreIDso a future “View full result” affordance can fetch the complete payload. Concurrent dedupe siblings share one stash entry. Retention is 7 days / 500 MB total.- SwiftData
SchemaV7addsToolCallRecord.providerID: String?andToolResultRecord.largeResultStoreID: UUID?. LightweightSchemaV6 → SchemaV7migration;BackupManager.currentSchemaVersionbumped to7. Frozen V6 snapshot lands atSchema/V6/*_V6.swiftper the schema-freeze convention. Pre-V7 rows migrate tonilfor both columns. - Active project’s
AGENTS.md/CLAUDE.md/GEMINI.md/.cursorrules/.cursor/rules/*.md/CONVENTIONS.md(orREADME.mdas a fallback) are now rendered into a project context block alongside an environment header (project name, absolute root path, ISO date, macOS marketing version, project’sexcludedPatterns, depth-2 directory listing) and injected as the system prompt on every chat submit and every regenerate. Files are concatenated in stable precedence order with SHA-256 content-hash dedup (so aCLAUDE.mdsymlinked toAGENTS.mddoesn’t double-bill the token budget). Per-file 8 KB cap with truncation marker; global 12K-token cap that drops convention files as whole units in lowest-precedence-first order, with.cursor/rules/*.mddropping individually in reverse alpha within their slot. Per-modelsystemPromptOverridecontinues to win over the project context block via the existingapplying(systemPromptOverride:)path. Per-project opt-out toggle (default on) under Settings → Projects → “Inject project context into chats”; the preference persists inUIStateBlobunderpolycode.prefs.project-contextand is consulted live on each submit (no app restart required). PolycodeIcon.MotionState.completion— a transient 700 ms star-sparkle + soft-glow + stream-settle flourish, played automatically when the brand mark transitions from.synthto.idle(and available for explicit caller use viamotion: .completion). Honors the long-standingchat-experienceConsensus Visual Metaphor invariant (“synthesis completes plays a brief sparkle flourish and fades”), which had been spec-only for the lifetime of the retiredPolycodeConsensusHalo. Reduce-motion suppresses the flourish (no animation, instant settle); rapid retriggers cancel any in-flight prior flourish task.- Polycode now ships its first wave of macOS platform-integration surfaces (Phase A): three App Intents (
AskPolycodeIntent,OpenPolycodeIntent,SearchPolycodeSessionsIntent) registered viaAppShortcutsProviderso they appear in Shortcuts, Spotlight, and Siri; Spotlight Core indexing of every session as aCSSearchableItemkeyed to apolycode://session/{uuid}deep link with index updates on create/rename/archive/delete; thepolycode://URL scheme handling/session/{uuid},/prompt?q=..., and/settings/{path}; a Services menu entry “New Polycode Query with Selection” routing through the deep-link prompt path; per-category OS notifications (query complete / tool approval requested / provider error) gated onNSApp.isActive == false, with a per-category toggle in Settings → Privacy and aUNUserNotificationCenterDelegatethat routes Reopen / Approve / Reject actions; a user-configurable global hotkey via CarbonRegisterEventHotKeywith a Settings → Appearance recorder field, and an inline error caption when the OS rejects the combo because another app already owns it; an opt-in menu-bar extra (off by default) with quick actions for New Prompt / Open Main Window / Settings… / Quit; per-session and per-exchange drag-out as MarkdownNSItemProvider; and a TipKit-driven feature-discovery layer with three context-gated tips (InspectorTip, ContextMenuTip, GlobalHotkeyTip) anchored to the inspector toggle, session rows, and the hotkey recorder. - Custom endpoints can now carry a per-instance
extraHeadersdictionary, applied to chat-completion, validation, and models-list requests. Entries whose keys collide with the auth header are filtered out before send (defending against malformed configs that would otherwise overwriteAuthorization/x-api-key). The two OpenRouter presets stampX-Title: Polycodeso Polycode is identifiable on OpenRouter’s app-discovery surface. The OpenAI-shape models decoder now extracts the optionalnamefield, so OpenRouter models render with friendly display names (Anthropic: Claude Sonnet 4.6) instead of slugs (anthropic/claude-sonnet-4.6) in the composer picker. - Tool approvals now include a unified-diff preview. Clicking “View diff” on a write-class tool approval sheet (
file_write,file_edit,file_rename,file_delete) opens a diff viewer showing +/- line coloring, 3 lines of context per hunk, blank-line preservation,\ No newline at end of filemarkers, and automatic hunk merging. Rename shows path metadata; create shows all-added; delete shows all-removed. Diffs are captured at approval time and revalidated via a SHA256 check at execute time — if the file changed between approval and execute, the tool throws a “file changed since approval” error rather than applying a stale edit. - MCP write-class tool approvals now display a “No diff preview available for MCP tool” caption so the absence of a diff button is explicit rather than ambiguous.
- Tool Approval sheet now shows a “+N more” queue-depth pill in the header when multiple tool approvals are pending, so burst approvals aren’t invisible behind the active one. The pill updates live as new approvals arrive while the sheet is open, via a narrow
@Observablesource (ApprovalQueueDepthSource) whose subscription is guaranteed by Apple’s Observation framework — independent of.sheet(item:)content-closure behavior. Title and tool-name caption truncate cleanly at the 460-pt sheet width so long names don’t crowd the pill. VoiceOver announces the queue depth via anaccessibilityLabel. CHANGELOG.md— the project now tracks user-visible changes here going forward.TODOS.md— roll-forward list of deferred openspec follow-ups, grouped by blocking gate. SeeAGENTS.md(“MaintainTODOS.mdfor openspec deferrals”) for the same-commit maintenance contract.
Changed
- Settings → Usage Top Sessions click-through now lands the picked session in the most-recently-registered chat window instead of an arbitrary one. Previously
WindowChatRegistrywas dictionary-backed andallWindows().firstreturned whatever Swift’sDictionaryiteration happened to surface — a stable-within-a-process but semantically meaningless pick. The registry is now array-backed ([(UUID, WeakChatRef)]) withregister(windowID:chat:)deduplicating then appending to the tail, so a re-registered window moves to the most-recent slot.allWindows()andotherWindows(excluding:)walk the array in reverse, returning entries most-recent-first while preserving the existing weak-ref self-heal pruning. The other consumer (ProjectSwitchDecision+SnapshotBuilderviaotherWindows(excluding:)) consumes its result via.contains(where:)(set semantics) and is unaffected. With multiple windows open, the click-through now lands in what’s effectively “frontmost / most-recent” — a deterministic proxy for true frontmost-window tracking (which would require anNSWindowactivation observer; deferred to post-launch user feedback). - Privacy-surface copy reconciled to disclose the new project-context flow across all four canonical surfaces:
LocalDataInfoCard(Privacy + Advanced variants), Onboarding Privacy page,Privacy/DiagnosticsSectioncaption (clarifying transmission goes to user-configured AI providers, not PostHog/Sentry), anddocs/support/data-lifecycle.md(new## Project contextsection anchored at#project-context). - Brand mark consolidation:
PolycodeConsensusHalo(the dashed-ring + bold-”P” letter mark) retired across the menu bar, inspector consensus card, and assistant message bubble in favor ofPolycodeIconmotion states driven byPolycodeConsensusState.iconMotion(.fanout → .thinking,.primary → .synth,.done → .idle). Lifecycle-bearing call-sites attachstate.accessibilityDescriptionto the brand-mark wrapper so VoiceOver retains the same “Fan-out in progress” / “Primary synthesizing” / “Consensus complete” labels the halo previously announced. The titlebar and quick-prompt overlay marks remain decorative-hidden as app identity surfaces. - Settings → Usage now shows a durable cost-and-tokens dashboard with per-provider, per-model, per-project, and top-session breakdowns. Replaces the in-memory
TokenMeter-backed counter that reset on every app restart. Hero card shows total USD cost, input/output tokens, an optional↑/↓ N%delta vs the prior same-length window (suppressed below 8 prior records), and a Charts-rendered cost sparkline that buckets hourly when the range is ≤ 24h and daily otherwise. By-Provider rows are default-collapsedDisclosureGroups with per-model sub-rows (Foundation Models renders as a leaf with the literal text “Free” viaProviderIdentity.decode); By-Project rolls up to top-4 + “Other” with a sticky(No project)bucket; Top Sessions opens the picked session in the first registered chat window viaWindowChatRegistry.allWindows(). Time-range picker offersToday/7 days/30 days/Since reset/Custom; the Custom popover’sDatePickers constrain selection tobaselineDate...Date(). The newReset baseline…button rebases the dashboard’s start toDate()(persisted underUIStateBlobkeypolycode.usage.baselineDate) — pastExchangeandProviderResponseRecordrows are NOT deleted; the sidebar continues to surface them. - Project-switch affordance now auto-switches on safe entry points instead of always prompting. At app launch, new-window creation, and session-select (where no other open window has a composer draft or attachment scoped to the current active project), the active project silently flips to match the opened session’s remembered project. The previous always-on banner remains as a fallback: it renders whenever a cross-window composer state is at risk, when switching from “No Project” is the only ambiguity, or when the new
Settings → Projects → Automatically switch projects when opening a sessiontoggle is off. Default: on. - Provider glyphs across all 28 in-app surfaces (settings rows, onboarding cards, composer model-picker pill, menu-bar extra, inspector trace cards, provider detail header, crown mark) replace editorial brand-mark approximations with a single deterministic per-provider treatment: a letter chiclet (rounded-square tile, brand color, white initial, highlight + shadow gradients) for
.anthropic/.openai/.gemini; the preservedAppleMark(the only retained editorial mark, sanctioned for Apple-platform context) for.foundation; and an SF Symbol (network) over the violet OpenAI-compat tile for.openaiCompatible. The previous hand-drawnPath-based brand marks (Anthropic twin-A, OpenAI hex-knot, Gemini sparkle, OpenAI-compat plug, Ollama, xAI, OpenRouter) sat in uncanny valley — recognizable enough to evoke each vendor and wrong enough to feel amateur, with implicit IP exposure since none were official assets. The new dispatch is per-provider and computed insidePolycodeProviderGlyph; the public initializer isinit(_ provider:size:)(theVariantparameter is gone). providerOpenAIcolor retuned from#4C8C7A(desaturated teal) to#10A37F(saturated mint), andproviderGeminifrom#4F7AC9(muted indigo) to#1A73E8(Google product blue, saturation bump). Both new values spread the two previously-closest provider tiles on the perceptual color wheel so that color carries the inter-provider distinction the brand mark used to. Neither is a claim of being an official vendor brand color.providerAnthropic,providerOpenAICompat, andproviderFoundationare unchanged.- Onboarding wizard privacy narrative reconciled with the running PostHog + Sentry telemetry posture. Three false claims were removed from
OnboardingPages.swift(intro page no longer says “no telemetry”; privacy page tagline and first bullet no longer claim “doesn’t collect telemetry” / “No telemetry”). The privacy page now acknowledges that anonymous diagnostics are on by default and points users at Settings → Privacy → Diagnostics for opt-out. Strings are extracted intoLocalizable.xcstringsunder anOnboarding/*key prefix so the catalog can grow with the strings rather than fall behind. A newOnboardingPrivacyReconciliationTestsregression-guard suite asserts the source-scan and catalog-scan forbidden tokens ("no telemetry"/"doesn't collect telemetry") do not reappear, mirroring thePrivacyCopyReconcileTests#filePathwalk-up pattern. The drift was originally alocal-data-transparencyreconciliation-contract violation; the contract now binds the onboarding wizard explicitly via a newOnboarding Narrative Reconciles With Telemetry Behaviorrequirement. - Settings → Privacy → Diagnostics section now names PostHog and Sentry in a section-level caption (“Polycode shares anonymous usage metrics via PostHog and crash reports via Sentry. Both can be turned off below.”), and the crash-reporting toggle caption attributes macOS MetricKit as the OS-mediated source of crash and hang diagnostics so users understand Polycode does not run a custom crash collector.
LocalDataInfoCardPrivacy variant body’s “Anonymous diagnostics.” paragraph also gains “(via PostHog)” / “(via Sentry)” parentheticals, closing the audit-flagged asymmetry where vendor names appeared indata-lifecycle.mdandResources/ReviewNotes.mdbut were absent from every in-app surface. NewIn-App Vendor Disclosurerequirement in thetelemetrycapability pins the at-least-one-in-app-surface bar so future copy edits cannot silently drop the disclosure. docs/support/data-lifecycle.md#diagnosticsgained a one-paragraph data-residency footnote describing the v1.0 release-runner configuration (PostHog US cloud + Sentry US cloud). Phrased as configurable rather than hard-coded —Config/Shared.xcconfigships empty DSNs for CI injection andTelemetryConfigaccepts US or EU DSNs — so the doc describes what the v1.0 release runner does, not a hard product guarantee. The footnote also notes that PostHog and Sentry retention policies govern already-shipped data, with a forward reference to theTODOS.md“Gated on future work” entry for EU migration under2026-04-22-telemetry-posthog-sentry.
Removed
-
UIStateBlobSwiftData@Model(the single key/value entity that held both user preferences and per-device UI state via key-prefix discrimination). Replaced bySyncedPrefsandLocalUIStatefor sync-scope partitioning. SwiftDataSchemaV11drops the entity via lightweight migration (V10→V11; row data not preserved per the in-forcepre-1-0-schema-amend-modetriplet — no signed build has left, data loss accepted, no backup preservation required);BackupManager.currentSchemaVersionbumped to11. Frozen V10 snapshot of all 13 entities lands atSchema/V10/*_V10.swift(includingUIStateBlob_V10.swiftas a historical migration anchor — pre-V11 on-disk databases resolve the entity through the chained migration up to V11 where it is dropped).PersistenceStore.getUIState(key:)/setUIState(key:value:)/deleteUIState(key:)API replaced by typed pair-of-triplesgetSyncedPref/setSyncedPref/deleteSyncedPrefandgetLocalUIState/setLocalUIState/deleteLocalUIState. Each setter/deleter wrapstry modelContext.transaction { ... }for upsert +SettingsHistoryEntryaudit-log append atomicity (the priorsetUIStatedid this; the priordeleteUIStatewas silent — the new deletes append a deletion-marker history entry per the spec scenario). Wrong-prefix keys trap loud (precondition;polycode.prefs.*for SyncedPrefs,polycode.ui.*for LocalUIState). Application-layer dedup replaces@Attribute(.unique)per the CloudKit-Compatible Schema Shape constraint.PersistenceStore.resetForTests()’s model-iteration loop migrated fromSchemaV10.modelstoSchemaV11.models. See openspec changesplit-ui-state-blob. -
TokenUsageRollupSwiftData@Model(aday × providerIDdaily aggregate withinputTokens/outputTokenscolumns) andPersistenceStore.recordTokenUsage(providerID:inputTokens:outputTokens:at:)upsert writer. Production code never wired either — the2026-04-25-usage-dashboardchange rebuilt the Settings → Usage panel as a read-side aggregation overExchange.providerResponses, leavingTokenUsageRolluporphaned. SwiftDataSchemaV10drops the entity via lightweight migration;BackupManager.currentSchemaVersionbumped to10. Frozen V9 snapshot lands atSchema/V9/*_V9.swift(includingTokenUsageRollup_V9.swiftas a historical migration anchor — V<10 on-disk databases must still resolve the entity during the chained migration). BakedPolycodeStore-V9.sqlite+ sha256 fixture seeds a populatedTokenUsageRolluprow to verify SwiftData drops a non-empty entity table cleanly. Three test suites tied to the removed surface (RetentionAndRollupTests.TokenRollupTestsx3 +PersistenceStoreTests.tokenUsageRollupUniqueConstraintEnforced) deleted; newSchemaFreezeMigrationV10Testssuite x3 lands.PersistenceStore.resetForTests()’s model-iteration loop migrated fromSchemaV1.modelstoSchemaV10.modelsso the DEBUG reset path tracks the current roster. -
PolycodeConsensusHaloview,PolycodeLegacyConsensusStateenum and its deprecated init bridge, and theconsensusFanOutHalo/consensusPrimaryHalocolor tokens (no remaining consumers after brand mark consolidation). Asset catalog entries for the two halo colors are deleted; the test fixture and token-name expectation list are trimmed accordingly. -
TokenMeter(the in-memory token-totals actor inPolycodeKit) and itsTokenSnapshotsnapshot type. The Settings → Usage panel now aggregates from the durableProviderResponseRecord(per-fan-out-peerinputTokens/outputTokens/costUSDsince SchemaV3) instead. ThetokenMeterparameter onConsensusEngineConfigand the twotokenMeter.record(...)call sites inConsensusEngineare removed; SDK consumers constructing aConsensusEngineConfigno longer pass that argument.TokenUsageRollup(a separateday × providerID@Model) is unused by production code and stays in source as dead-but-quiet infrastructure pending a future schema-bump cleanup change. -
Seven
BrandMarks/*Mark.swiftPath-based brand-mark views —AnthropicMark,OpenAIMark,GeminiMark,CompatMark,OllamaMark,XAIMark,OpenRouterMark— are deleted (~488 LOC).AppleMark.swiftis preserved (still used by the.foundationdispatch).PolycodeProviderGlyph.Variantis removed; the.brand/.abstractcases no longer exist and the third initializer argument is gone (no production call sites passedvariant:; design-book showcases drop the argument). -
PolycodeProviderdisplay-layer enum cases.ollama,.xai,.openrouterare removed. These were aliases forCustomEndpointInstancepresets that the runtime never reached — everyglyph(for: ProviderIdentity)mapper in the app target dispatchedkind == .custom → .openaiCompatible, so the running app already showed the violet compat chiclet for every custom endpoint regardless of preset. Public DesignKit API narrows from 8 cases to 5 (.anthropic,.openai,.gemini,.openaiCompatible,.foundation). ConfiguredCustomEndpointInstancepresets (Ollama / xAI / OpenRouter / LM Studio / Custom) continue to exist asProviderKitruntime configuration helpers, unaffected.
Fixed
- Built-in
#quickmode resolves end-to-end on fresh install — validator and adapter model gates now agree on LiteLLM-tracked aliases for first-party providers. PreviouslyModePreferences.canonicalQuickdeclaredprimary.modelID == "claude-haiku-4-5"(bare alias) but the mergedModelRegistryonly carried"claude-haiku-4-5-20251001"(models.jsonships only the dated SKU; Anthropic’s/v1/modelsreturns only the dated SKU), soResolvedMode.resolveflagged the canonical built-in as invalid on every fresh install — and even with a validator-only patch, every fan-out attempt would have thrownProviderError.unsupportedModel("claude-haiku-4-5")from inside the adapter before any HTTPS call.ModelValidation.requirenow accepts an optionalliteLLMFallback: (identity, snapshot)?parameter; on registry miss, it consultssnapshot.entry(for: modelID)and accepts the alias whenentry.litellm_providermatchesidentity.kind.namespace, returning aModelDescriptorsynthesized via the existingLiteLLMEntry.descriptor(...)helper.ModelRegistry.currentLiteLLMSnapshot()exposes the actor-owned snapshot so adapters can thread it without re-fetching. Every first-party adapter (AnthropicProvider,OpenAIProvider,GeminiProvider,FoundationModelsProvider) now passes the snapshot in; shape delegates (OpenAIShapeDelegate,AnthropicShapeDelegate) deliberately omit the fallback because their.custom-kind namespace cannot match any LiteLLMlitellm_providervalue, preserving conservative rejection for compat clones whose endpoint may not actually serve a first-party alias.AppEnvironment.attachLiteLLMSnapshot(_:)consolidates the two existing snapshot-install call-sites and stages the snapshot onAppEnvironment.liteLLMSnapshotfor sync validator-side reads insideAppEnvironment+Modes.swift’smodelResolverclosure, which now has a matching kind-namespace-gated LiteLLM-fallback leg. Bare aliases stay validator-accepted but are NOT promoted intoavailableModels, preserving picker cleanliness; a known limitation is that the Inspector token-cost panel renders$0.0000for LiteLLM-only alias runs because cost lookups resolve fromavailableModelsonly (a follow-up cost-resolver fallback can read the synthesized descriptor’s pricing without changing validity/send-time semantics). - App-target test bundle (
xcodebuild test -scheme Polycode -only-testing PolycodeTests) cascade-failure mode resolved.ConversationQueryPredicateTests.swiftwas pinningSchema(versionedSchema: SchemaV7.self)while the canonical schema advanced to V10; SwiftData’s container catalog registered the frozenSchemaV7.Exchangefor entity name"Exchange"andFetchDescriptor<Exchange>(canonical at compile time) trapped withSwiftData/ModelContext.swift:712: Fatal error: Failed to cast model PersistenceKit.Exchange ... to Exchange.Three real fault sites turned into 282/319 cascade failures because the SIGABRT crashed the xctest worker. NewModelContainerFactory.fileBackedForTests(at:)(DEBUG-only) replaces the inline schema build; a privatecanonicalSchemacomputed property collapses fiveSchema(versionedSchema: SchemaV10.self)references to one. Future schema bumps update the factory only — no test-site edits required.no_frozen_schema_in_tests+no_frozen_schema_in_tests_split_lineSwiftLint custom rules structurally prevent recurrence by banning inlineSchema(versionedSchema: SchemaV<N>.self)outsideModelContainerFactory.swiftand theGenerateV*Fixture.swiftbake generators.PolycodeTestsnow runs 319/319 green. - Brand-mark motion in both
TitlebarCenterClusterandQuickPromptWindownow distinguishes primary-synthesis (.synth, glowing star) from fan-out (.thinking, dashed streams). Previously both surfaces collapsed.primaryto.thinkingdue to duplicate inlineiconMotionswitches; the centralizedPolycodeConsensusState.iconMotionaccessor now governs the mapping at every call-site. - OpenRouter and OpenRouter (Anthropic) presets now hit the correct API root (
https://openrouter.ai/api/v1/...). The previoushttps://openrouter.ai/v1/...paths returned the marketing site HTML at HTTP 200 and silently failed JSON decode, making both presets non-functional. Existing instances with the broken URL are migrated automatically on next launch. The add-endpoint sheet’s save path now propagates preset-stamped custom headers into the persisted instance (a separate latent bug — preset-stamped headers were dropped before persistence). - Dismissing a tool approval sheet without an explicit decision (Esc key, programmatic dismissal) now rejects the pending approval instead of silently leaving the tool task hanging. The dismissal handler also guards against an empty-queue race and the reentrancy case where a second approval is queued while the first is being resolved.
Pre-1.0 history
Entries below predate the [Unreleased] convention and were backfilled from
git log and openspec/changes/archive/ on 2026-04-24. Each section is a
pseudo-release labeled by commit date; no formal versioned releases occurred
during this period — the project was under continuous active development
between the first commit on 2026-04-18 and this retroactive log. Dates
reflect when the change landed on the shared branch, not when its openspec
proposal was written or archived.
[2026-04-24]
Added
- Conversation-scope attachments — prior-turn file attachments replay automatically across turns, with prefer-drop-files trim when context budgets tighten and eviction/resolution diagnostics surfaced in the inspector.
- Settings > Tools cache inspector — the tool-approval cache state is now visible and inspectable.
Changed
- Tool-approval gate now honors
ToolPreferencesdefaults set in Settings > Tools instead of re-prompting every invocation; read-only tool gate coverage widened; diff-payload seam unifies Edit-tool diff rendering. - App test harness is parallel-safe — per-test SQLite isolation + event-driven waits eliminate cross-test flake.
[2026-04-23]
Added
- Titlebar session affordances — inline title rename, current-project chip, and switch-project submenu live in the window titlebar.
- Session-project affinity — sessions remember the project they were started in; sidebar groups sessions by project; SchemaV5 adds the affinity column with a switch-project affordance.
- Composer resize handle + click-anywhere focus + fluid drag so the composer grows vertically on demand.
Changed
- Output-token-budget defaults — Anthropic
max_tokensis now driven by the model descriptor instead of a static cap; LiteLLM metadata priority flipped higher; SchemaV6 addsfinishReasonwith a truncation badge in the inspector. - Text attachments lower through a unified, nonce-fenced banner across every provider adapter instead of per-adapter ad hoc shaping.
Fixed
- Persistence context freshness —
@Query-driven views + actor-scoped DTO helpers so stale snapshots no longer leak into the UI after writes. PolycodeListRowaccepts clicks across the full row rect via.contentShape(Rectangle())instead of only over text/glyph regions.
[2026-04-22]
Added
- Attachments reach providers end-to-end —
Part.fileflows through every adapter with a SchemaV4 sidecar persisting attachment metadata. - App icon — Icon Composer default master, 7-size macOS appiconset.
- Telemetry local-override — launch-arg live-mode, Debug-only
Local.xcconfig, single-gate resolver for testing live-mode without Release builds. - Anonymous diagnostics telemetry — PostHog analytics + Sentry crash adapters, PII-scrubbed, default-off with a Settings opt-in.
- Conversation-thread context — submits carry the full thread instead of just the newest turn; regenerate respects the thread; per-adapter trim keeps within context windows.
- Consensus citation pills — disagreement-triggered chips inline in the answer, a Sources row, and inspector deep-links to the source peer trace.
- Schema freeze + per-peer cost/model (SchemaV3) — historical schema versions locked in via frozen
VersionedSchemasnapshots; per-peermodelIDandcostUSDnow render in the inspector. - Provider trace persistence — fan-out traces survive session switches and app relaunch.
[2026-04-21]
Added
- Brand refresh v2 — animated streams-and-braces app mark with thinking/synthesizing motion states reflecting fan-out activity.
- Composer @-mention autocomplete — NSTextView-based inline attachment pills with a mention popover.
- @-mention project-file index cache with background revalidation so autocomplete shows results immediately on warm start.
- Sidebar project explorer — inline lazy-loaded directory tree under each project tile for quick attachment by tap.
- LiteLLM metadata integration — richer context-window, pricing, and thinking-mode info for remote-discovered models.
- Pre-commit SwiftLint gate —
scripts/git-hooks/pre-commitrunsswiftlint --strictagainst staged*.swiftfiles; opt-in viacore.hooksPathandscripts/install-hooks.sh. - Settings > Privacy / Advanced Local Data Info cards — static copy documenting the sandbox-container model and manual wipe / backup paths.
Changed
- Model registry install-merge — remote-discovered model IDs are now callable in fan-out instead of being curation-only.
- Peer model resolver — each peer resolves to its own configured model ID instead of inheriting the primary’s, unblocking multi-provider consensus with heterogeneous model preferences.
- Resolver falls back to a provider’s first supported model when no explicit preference exists (fixes silent failures for single-model providers like Apple Intelligence).
- Per-exchange trace scoping — inspector filters fan-out traces by session so switching sessions no longer surfaces stale traces.
- Adapter error-body capture — HTTP error bodies from provider adapters surface in the inspector instead of generic error messages.
- Settings hygiene — non-functional destructive controls and dead-wire toggles removed; color-scheme picker wired through; excluded-pattern editing fixed.
- SwiftLint thresholds tuned for idiomatic shapes, 43 trivial violations swept, and the composer file split for readability.
Fixed
- Onboarding sheet flash on relaunch — four-state phase enum +
LaunchGatedefers sheet presentation until state resolves. - MCP
RegistryRemoteaccepts bothtransport_typeandtypein the registry payload during migration.
[2026-04-20]
Added
- Brand refresh v1 — four-ray consensus app mark + editorial per-provider brand glyphs with a dispatcher-based rendering system.
- Settings > Providers > [Provider] detail subpage with model selection cards, advanced overrides, and enable/disable toggles.
- Settings expanded — per-model advanced overrides (alias, reasoning effort, temperature, max output), fan-out membership toggles, Tools/Usage/Advanced tabs, and pinned-model filtering.
PolycodeTeststarget in the Xcode project with xcconfig governance, for testing app-bound code thatPolycodeCorecan’t reach.- Platform-integration Phase A — notification delegate wired, hotkey recorder, tip rules for TipKit.
Changed
- Five-layer design refresh shipped in stages: tokens aligned to the design spec; primitives (provider glyph, consensus halo, crown mark, buttons, chips) rewritten to match; chrome / sidebar / inspector redesigned with consensus overview cards and per-provider trace cards; conversation center + composer refreshed with consensus halos, fan-out trace rows, inline code styling, and focus-ring states; overlays (Quick Prompt, menu-bar extra, tool approval sheet, toasts) finalized.
- Onboarding wizard + settings window redesigned to match the spec — provider connection flow, tabbed interface, MCP dashboard.
[2026-04-19]
Added
- Chat experience UI —
MainViewthree-columnNavigationSplitView; sidebar with FTS-backed session search + context-menu actions; streaming conversation with scroll-intent tracking + consensus halo; composer with drag-drop + token estimate + model picker; inspector panel with per-provider traces; five-page onboarding wizard with provider validation gate; seven-tab Settings window + MCP dashboard; multi-window via⌘⇧N;PolycodeCommandsmenu bar;ToastCenter+ fatal-banner strip;@SceneStoragestate restoration. - Swift Markdown UI integration —
swift-markdown-uiwired as a DesignKit-scoped dep;PolycodeMarkdownViewwraps it and delegates fenced code blocks toPolycodeCodeBlock;AssistantMessageBubbleswapped fromAttributedString(markdown:);AboutTabcredits line added. - Dynamic model discovery —
Provider.fetchRemoteModels()on all five adapters (Anthropic, OpenAI, Gemini, OpenAI-compat, Foundation Models);ModelRegistry.merge(remote:)overlays live/modelsoutput on the curated manifest;RemoteModelCache(24 h TTL,UIStateBlob-backed); Settings > Models refresh button. - Multiple OpenAI-compatible endpoints —
OpenAICompatInstance+ presets (Ollama, xAI, OpenRouter, LM Studio, Custom);ProviderAddCompatSheetvalidates before persisting; per-instance persistence with one-time legacy-xAI migration; composer picker groups models by instance. - Custom endpoint shapes — Anthropic-format proxy endpoints (LiteLLM, Cloudflare Workers, Tinfoil) alongside OpenAI-compatible proxies, with preset menu entries and shape-aware parsing.
- ToolKit —
Project/ProjectScope/ProjectStore; nine native file tools (read, write, edit, search, grep, delete, move, list, stat);ToolRegistry; three-way approval gate (allow-once / allow-session / allow-project) with a SwiftUI sheet and session cache; drag-drop attachment handler;LargeResultStorefor capped results. - MCPKit — HTTP-only MCP client: JSON-RPC codec, SSE + single transport,
MCPServerandMCPClientactors, registry discovery, approval-gated tool invocation, dashboard. - PersistenceKit — SwiftData models + GRDB-backed FTS5 full-text search; backup/restore; integrity checks; retention + rollup; UI state persistence; observability hooks.
Fixed
- App launch crash caused by
NSAppsetup running insideApp.init— scene-phase hand-off moved out of initialization.
[2026-04-18]
Added
- Native macOS app scaffold —
Polycode.xcworkspace,PolycodeCorelocal SwiftPM package with per-domain library products, xcconfigs as the single source of truth for build settings,Polycode.entitlements+ SHA baseline, GitLab CI (.gitlab-ci.yml) translating the prior apple-ci / apple-release pipelines. Swift 6 strict concurrency throughout. - DesignKit design system — color / spacing / radius / shadow tokens, asset catalog, font registry, SF Symbol coverage, SwiftLint token rules, contrast audit, 15 components, launch-flag-gated
DesignBookViewwith README cross-links. - KeychainKit —
SecretStoreactor (singleSecItem*callsite),SecretKeynamespaced convention, OAuth device flow (RFC 8628),InstallSecret(lazy-generated signing key + install ID), optional biometric gating, launch-time integrity check,ProviderAuthResolverbridge. - ProviderKit —
Providerprotocol,SSEParser,RetryPolicy,ModelRegistry; five production adapters (Anthropic, OpenAI, Gemini, OpenAI-compat with xAI validated, FoundationModels / Apple Intelligence);TokenMeterwiring; model-validation seams. - ConsensusKit — fan-out engine, primacy-recency synthesis,
MockProvider, end-to-end test coverage. PolycodeKit— shared types (Message,ToolCall,TokenUsage,Lognamespace,Sendableprotocol surfaces) as the single root dependency; every other module depends on it and it has no reverse dependencies.
History prior to 2026-04-18 (pre-subtree-split work in the Go TUI monorepo at
github.com/izzoa/polycode) is out of scope for this changelog and lives in the
sunset-go-desktop-plan retirement track.