Localize mailAccessNote and AppleMailService's account-count string
(was a manual singular/plural literal, now uses inflect syntax). Also
correct the requestAccess() doc comment and the suspectedPlatformBug
fallback message, both of which still asserted "known macOS 27 beta
issue" — that diagnosis was wrong (see 6b4448d): the real cause was a
missing entitlement, already fixed. The instant-failure heuristic stays
as a defensive fallback, just no longer misattributed.
Confab.entitlements was missing com.apple.security.automation.apple-events,
so tccd's hardened-runtime policy silently refused to even prompt for
Automation consent to Mail.app — confirmed via tccd's own log, the same
failure class as the earlier Calendar/Contacts entitlement bug. Unhides
the Mail integration (MailTools.isHiddenPendingAppleFix = false).
Live-verified: Request Access now grants successfully on macOS 27 beta 7.
Rune caught this live too: content is now visible (last commit fixed
that), but the tinted glass (.regular.tint(.blue) etc.) rendered as a
solid, opaque, saturated color block instead of translucent frosted
glass — looked like a plain filled button, not Liquid Glass.
Dropped .tint() from all four Phase 1 conversions (tabButton,
mcpSidebarRow, and ModelSelectorView's filter/category/favorites/sort
chips) — the selection/active signal already comes from the icon/text
foreground color turning blue (or yellow, or the category color), which
was untouched by any of this. The glass itself is now always .regular
with no tint. tabButton's unselected state also switched from
.buttonStyle(.glass) to .buttonStyle(.plain) — restores the original
"only the selected tab shows any background" look, since with the tint
gone a plain .glass button style for every tab would visually flatten
the selected/unselected distinction back down to icon color alone.
Rune caught this live: the selected MCP tab and "External MCP" sidebar
row rendered as solid opaque blue blocks completely hiding their
icon/label. Root cause: .glassEffect() was nested inside a
.background { } closure applied to a plain Color.clear placeholder,
which isn't how the API is meant to be used — it needs to wrap the real
content directly (as the skill's own examples show), not sit behind it
as an opaque background layer.
Also discovered the skill's documented isEnabled: parameter on
glassEffect(_:in:isEnabled:) isn't available on this SDK build
("extra argument in call") — worked around by branching the view
instead of using that parameter.
Since tabButton/mcpSidebarRow are actual Buttons, switched to the
more correct approach for buttons specifically: native
.buttonStyle(.glass)/.buttonStyle(.glassProminent) for the tab bar
(matching the skill's textbook button pattern) rather than hand-applying
.glassEffect() to custom content. mcpSidebarRow needed to stay on
direct .glassEffect() application (not button styles) since its row
needs to stretch full-width via a trailing Spacer(), which glass
button styles don't support — but applied directly to the real HStack
content this time, branched via if/else, not nested in .background.
Note for the next Rune check: native glass buttons add ~13pt of their
own internal padding, so manual padding was reduced/dropped on
tabButton's label — sizing may look different than before, adjust if
too large/small.
First Liquid Glass adoption in Confab (deployment target is already
macOS 26.2, so no #available gating needed anywhere). Converts the two
confirmed-safe, non-scrolling segmented selectors:
- Settings' top-level 12-tab bar (tabButton) and yesterday's new MCP-tab
sidebar (mcpSidebarRow): selected-state Color.blue.opacity(0.1)
backgrounds become tinted .glassEffect(), wrapped in
GlassEffectContainer. Shipped without morphing (@Namespace/
glassEffectID) for now, since at most one item is visible at a time in
steady state — morphing is a stretch goal only if wanted after seeing
this.
- ModelSelectorView's filter/category/favorites/sort chip row: flat
Color.opacity() pill backgrounds become .glassEffect(), all wrapped in
one GlassEffectContainer for a real multi-element merge demo (several
chips can be active simultaneously here, unlike the single-selection
tab bars).
Deliberately NOT touched in this phase: anything inside a ScrollView/
List (formSection cards, chat bubbles, sidebar rows) per Apple's own
no-glass-in-scroll-views guidance, and the two ChatView anti-patterns
(Header/FooterView's .ultraThinMaterial) which need a bigger safeAreaBar
restructuring, planned as Phase 2.
The MCP tab had grown into one long scrolling list mixing seven unrelated
areas (File System, Bash Execution, Research Agents, External MCP
Servers, CLI Access, Personal Data, Mail), making it hard to find
anything. Split into a left sidebar (mirroring the existing top-bar
tabButton's blue-accent selected style, just as left-aligned icon+label
rows instead of icon-over-label) with each area now its own standalone
page.
The MCP tab renders outside the shared Settings ScrollView so its
content pane can scroll independently while the sidebar stays pinned —
nesting a plain ScrollView inside another one without an explicit height
just sizes to content rather than scrolling on its own. All 11 other
tabs are unaffected. Bumped the Settings window's min/ideal width
slightly to give the content pane room now that the sidebar takes some
of it on the MCP tab specifically.
Personal Data's and Mail's existing kill-switch guards
(PersonalDataTools/MailTools.isHiddenPendingAppleFix) now also hide
their sidebar rows entirely via MCPSubsection.visibleCases, not just
their content.
Root cause: on macOS, SecureField's bound value only commits when the
field loses focus (Return, click-away, tab switch) — not on every
keystroke like TextField. Every "Test Connection" button (Sync, Email,
Paperless, Anytype, Jarvis) gated its .disabled(...) on a *Configured
value fed by a SecureField-backed API key/token/password, so typing a
key straight into the field left the button looking permanently disabled
until something else forced a focus change.
Moved the "configured" check from the disabled condition into each test
function's action handler instead — by the time a click fires, the click
itself has already moved focus away and committed the field's value, so
the check now sees it correctly. Buttons stay clickable at all times
(gated only by their own isTesting spinner state) and show a clear
"Enter X first" message if config is actually incomplete.
Confirmed via live debugging this is a genuine OS bug (first-time
Automation consent grants never work on this beta), not fixable in-app.
Rather than ship a Settings section that can't currently work, added
MailTools.isHiddenPendingAppleFix (true for now) mirroring the existing
PersonalDataTools kill switch used for the same purpose during an earlier
Calendar/Contacts TCC bug — hides the Settings UI and forces mailEnabled
to false regardless of the persisted value, with no code deleted. Flip
back to false once a newer beta/RC is confirmed to fix it.
Confirmed via multiple live rounds with Rune this is a macOS 27 beta issue,
not a Confab bug — matches an already-documented pattern in this project
(Calendar/Contacts requestAccess failing identically). Every failure
returns in single-digit milliseconds, ruled out threading, wrong API,
build/signing, and notarization; even a fresh notarized build fails
identically, while Terminal->Mail (first-party) works with no prompt at
all, and the same-style bug already has a drafted Apple Feedback report
for a different permission category.
Kept the real Apple Event attempt as the primary path (the way this
should work once the OS bug is fixed), but now time it: a failure faster
than any human could plausibly answer a real dialog (default 300ms) is
classified as the platform bug rather than a genuine denial, and the UI
falls back to opening System Settings' Automation pane directly with an
explanatory note, instead of the button silently doing nothing.
Settings previously only surfaced an error message after a failed Test
Connection attempt — there was no way to trigger the actual macOS
Automation permission dialog or see live grant/deny status, unlike
Calendar/Contacts/Reminders/Location.
AEDeterminePermissionToAutomateTarget (askUserIfNeeded: false/true) turns
out to provide exactly that: a non-prompting status check and an explicit
prompt-and-wait call, mirroring EKEventStore.authorizationStatus(for:)/
requestFullAccessToEvents(). Mail's Settings row now uses the same
personalDataRow component as Calendar/Contacts — live status badge plus
a real "Request Access" button — instead of a one-off Test-Connection-only
UI. Test Connection stays as a secondary functional check.
Lets the AI search Apple Mail, read a message, and save an attachment to
disk (e.g. to hand off to paperless_upload_document) — e.g. "find the
receipt from Elkjøp and add it to Paperless." Talks to Mail.app via
AppleScript/Apple Events rather than parsing its private on-disk store,
so no Full Disk Access or MIME parsing is needed; Mail's own attachment
save handles all decoding. Every mail_* tool call requires approval
(Deny/Allow Once/Allow for Session), mirroring the bash_execute and
Personal Data gate pattern.
Also fixes a pre-existing bug found while touching the adjacent
tool-activation condition: paperlessEnabled was missing from it, so
Paperless tools could fail to activate unless another integration was
also active.
It was only shown when Search Provider was set to Google, but the
same key is also needed for Google embeddings in Semantic Search
(Advanced tab) — a completely separate feature from web search. A
user who only wanted Google for embeddings had no way to find where
to enter the key. Now always visible under General → Web Search, with
a caption clarifying both uses; Search Engine ID stays conditional
since it's web-search-specific. Also pointed the Semantic Search tab's
"no providers available" hint at the same location.
The multi-line backslash-continued curl command was fragile to
copy/paste: a trailing space after \ or a dropped backslash (both
common when pasting a multi-line snippet out of a chat UI or browser)
silently breaks the continuation, so each following line gets parsed
as its own bogus command instead of a curl flag - exactly what Rune
hit ("command not found: -H", "-d", "no such file or directory:
http://localhost/") after pasting the previous version. A long single
line has no continuation character left to mangle.
An unquoted prompt containing ?/*/[...] (e.g. `ai Who are you?`) was
getting glob-expanded by zsh before the ai() function ever ran,
aborting with "no matches found" and never reaching the socket server.
A plain function can't protect its own call site from this - filename
generation happens during command-line parsing, before the shell
dispatches to a function. Switched the recommended snippet to
`alias ai='noglob _ai_impl'`, which suppresses globbing for the whole
command line via noglob as a precommand modifier. Quoting remains the
fully robust habit for other shell metacharacters, but this covers the
specific mistake a user is most likely to make by accident.
External MCP Servers previously only spoke stdio (spawn a local
command + args). Adds:
- env vars for stdio servers (merged into the subprocess environment,
not embedded in the args string), with a masked key-value editor
- a native Streamable HTTP transport (URL + Bearer token + custom
headers), so HTTP-based MCP servers like Obsidian's Local REST API
plugin connect directly without needing npx/Node.js as a bridge
Introduces an MCPTransport abstraction (stdio/HTTP) so ExternalMCPClient
stays transport-agnostic — mirrors how Provider.swift already abstracts
AI backends in this codebase.
Also fixes a real crash found via live testing against Obsidian:
convertInputSchema force-unwrapped a tool parameter's `type`, which
isn't required by JSON Schema — Obsidian's plugin was the first real
server to send a parameter without one. Live-verified end to end
(vault search/read/write/edit) before this commit, per the project's
standing rule to hold external-service-dependent changes until they're
actually confirmed working, not just compiling and passing tests.
New CLIServerService listens on a Unix domain socket
(~/Library/Application Support/oAI/cli.sock) speaking a minimal
HTTP/1.1 subset, for one-shot non-streaming shell access to a single
fixed model — e.g. an `ai "prompt"` zsh function — without opening
the app window and without going through the tool-calling loop.
Configured in Settings > MCP > CLI Access (toggle, provider, model —
deliberately independent of the chat UI's active model). JSON
request/response envelope rather than raw text so new fields (model
override, streaming, tool support) can be added later without a
breaking wire-format change.
Verified live end-to-end against a real OpenRouter request, error
paths, and clean-shutdown socket cleanup. 10 new unit tests cover
the HTTP framing/parsing logic.
NSWorkspace.shared.open() silently drops #fragment anchors on file://
URLs, so "Fix It Myself" always landed on the Help Book index instead
of the relevant section. Replaced with GitSyncManualFixSheet, an
in-app sheet showing the real conflicting filenames and sync path.
Also indent conversation rows one level deeper than their containing
folder in the sidebar and conversation list, so nesting is visible on
the conversations themselves and not just the folder headers.
syncOnStartup() (pull+import, fired at launch) and autoSync() (export+push,
debounced off chat activity) ran as fully independent, uncoordinated Tasks
with no mutual exclusion. A user launching the app and chatting right away
could hit autoSync's export mid-pull, leaving a freshly-written untracked
file that the pull then refuses to merge over — the same failure class as
the earlier folders.json bug, now much more likely to surface widely since
folders.json/notes.json are brand new for every existing sync repo.
Adds a shared isSyncing guard across all three entry points (syncOnStartup
skips if busy, autoSync waits for a clear slot, syncNow throws
.syncInProgress) and moves Sync Now's pull/import/export/push orchestration
out of SettingsView into GitSyncService.syncNow(), where the guard can
actually protect it.
Gives each conversation an opt-in, persistent memory file the model reads
automatically every turn and writes to on its own initiative via a fenced
```update-notes``` block in its reply — no per-write approval, matching the
Confab-as-CLAUDE.md-for-itself concept Rune wanted. /notes on|off|show,
files live in ~/Library/Application Support/oAI/notes/, embedded ID header
for future Git Sync compatibility. Adds DB migration v12.
Folders and conversation→folder assignments now sync across machines:
- Folder gains updatedAt (v11 migration) to resolve renames/reparents
last-write-wins across machines.
- New folders.json manifest at the sync repo root: folder tree +
conversationId→folderId assignments, imported before conversation
files so new conversations land in the right folder immediately.
- Local folders missing from the manifest are pruned (reparent-safe),
guarded the same way conversation-orphan cleanup already is against
an empty/stale manifest wiping everything.
Three real bugs found and fixed during live multi-machine testing:
- Sidebar never refreshed after Git Sync imported conversations/folders
directly into the database — only reloaded on launch or when the
advanced conversation list closed, with no equivalent hook for the
Settings sheet.
- "Sync Now" exported before pulling, so it could write folders.json
as an untracked file that then collided with the remote's tracked
copy on the next pull ("untracked working tree files would be
overwritten by merge"). Reordered to pull → import → export → push.
- Folder assignment only applied to brand-new conversations during
import, so any conversation already synced to a machine before this
feature existed never got filed — which in practice is every
conversation on a second machine, not an edge case. Now backfills
a folder assignment for existing conversations that aren't filed
anywhere locally yet, without clobbering an already-set folderId.
Also renamed the "Initialize Repository" button to "Clone Repository"
(it's always been a git clone, not new-repo creation) across the UI,
localization catalog, and Help Book.
"oAI" reads as easily confused with OpenAI, both visually and in
casual conversation. Renamed to "Confab" throughout: Xcode
target/scheme/bundle ID (com.oai.Confab), Info.plist and Help Book
identity, all user-facing UI text, internal Log subsystem and color
identifiers, localization catalogs (6 languages, including a proper
reworded/retranslated Intel-deprecation notice), Help Book HTML
content, and docs (README/DEVELOPMENT/PRIVACY/SECURITY).
Deliberately cosmetic-only: the on-disk data folder
(~/Library/Application Support/oAI/), database/backup filenames,
Keychain service identifiers, and EncryptionService's key-derivation
inputs are all left untouched so existing conversations, settings,
and stored API keys survive the update with zero migration and no
re-entering credentials. Verified live: a real signed build
successfully decrypted a stored API key and loaded an existing
conversation database after the bundle ID change.
Also includes a small already-completed, previously uncommitted
model-release-date feature (ModelInfo/OpenRouterModels/
OpenRouterProvider/ModelInfoView) that happened to share several
files with this rename.
Gitignored on this branch and updated on disk but not part of this
commit: CLAUDE.md, RELEASE_NOTES.md, and the build*.sh scripts.
Replaces heuristic auto-save (goodbye-phrase detection, idle timeout,
min-message count, on-model-switch) with a standard macOS unsaved-changes
gate (Save/Don't Save/Cancel) on New Chat, Clear Chat, Load Conversation,
and Quit. The Save dialog gained a folder picker with inline "New Folder…"
creation.
Separately, the in-progress conversation is periodically mirrored to disk
(DraftRecoveryService, configurable interval in Settings, default 10s) and
offered back on next launch if oAI crashes or is force-quit, including the
model that was selected.
Two real bugs found via ObjectIdentifier/log-based diagnosis before this
worked correctly:
- oAIApp.init() wired AppDelegate.chatViewModel from its own @State read,
which returned a throwaway ChatViewModel instance distinct from the one
ContentView actually renders. Wiring moved to ContentView.onAppear.
- NSApplication.shared.delegate as? AppDelegate always failed silently:
@NSApplicationDelegateAdaptor registers an internal SwiftUI.AppDelegate
wrapper as the real NSApp.delegate (same name, different type in a
different module), which forwards protocol methods but isn't castable
to our type. AppDelegate now tracks itself via a static `shared`.
Also guards checkForCrashRecoveryDraft() against running under
XCTestConfigurationFilePath — oAITests is app-hosted, so xcodebuild test
launches this same app, and a leftover draft file on disk would otherwise
hang the entire test run on a blocking NSAlert with no one to click it.
Apple's Foundation Models framework is still under active development
(built against macOS 27 beta) and likely to stay rough for a while —
small 4K context window, occasional generation errors, no tool
support yet. Surface that clearly wherever it appears:
- Model name: "Apple On-Device (Beta)" (shows in header/model picker)
- Model description: notes the beta status and what to expect
- Settings -> General: "⚠️ Beta — ..." disclaimer under the Apple
Intelligence section, same style as the existing Paperless-NGX beta
note
- Credits panel: same disclaimer for the Apple Intelligence entry
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Apple Intelligence / Foundation Models is now genuinely available on
this machine (macOS 27 beta 4) — it was reverted back in June
(f63226b) when it wasn't. Ports the reverted AppleFoundationProvider
forward onto 2.4.3, adapted for everything that's changed since:
PolyForm license headers, current AIProvider protocol shape, current
Settings.Provider/ProviderRegistry/CreditsView/SettingsView structure.
Fixes a real bug found via live testing: LanguageModelSession.
GenerationError was deprecated in macOS 27.0 in favor of a new
LanguageModelError type. On a macOS 27+ runtime, generation failures
now throw LanguageModelError, not GenerationError, so the original
error-mapping catch never matched and Apple's raw error text leaked
to the user instead of oAI's friendly message. Now dispatches to
whichever type the runtime actually throws, gated with
@available(macOS 27.0, *), keeping the old GenerationError path as
a fallback for macOS 26.x (the app's actual deployment target).
Confirmed end-to-end in the live app: provider selectable, Settings
shows a live "Available" badge, chat header shows correct branding,
and — a real, expected Phase 1 limitation — oAI's default system
prompt (active Agent Skills + MCP tool guidance, ~16K tokens on this
machine) exceeds the on-device model's 4K context window on the
very first message. The friendly error message now correctly reports
this instead of Apple's raw string. Tool calling remains out of scope
until Phase 3.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Contacts was never actually broken by an Apple/OS bug -- it was a
wrong entitlement key in oAI.entitlements, now fixed (abf25bd). No
functional change here: the flag was already false in both places
this session, so behavior is identical; this just removes the
now-pointless beta-badge/conditional-row scaffolding built around it.
isContactsHiddenPendingAppleFix was flipped true on beta 2 after
CNContactStore.requestAccess returned instant "Access Denied" under
hardened runtime, while Calendar/Reminders/Location worked fine.
Rune just installed beta 4 and wants to retest.
Favorites now push/pull through a small oai_favorites.json file in the
same iCloud Drive folder used by Settings > Backup, reconciled by
last-write-wins timestamp on launch and app-become-active. Also adds
an Off/Daily/Weekly frequency picker so the full settings backup can
run itself (checked at launch and hourly) instead of requiring a
manual "Back Up Now" click every time.
- ContentView now reads/writes SettingsService.sidebarVisible so the
NavigationSplitView sidebar's shown/hidden state survives relaunch,
matching the existing window size/position persistence.
- Default system prompt gains a rule: always reply in the user's
language, even for requests (e.g. translation) targeting another one.
Consolidates the mac.oai.pm subdomain references (introduced in the
PolyForm Noncommercial relicense) to the root oai.pm domain, across
the LICENSE file, README, and all Swift source file headers.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Switches the project from AGPL to a source-available license that
restricts commercial use — selling oAI or any part of it, standalone
or bundled into another product/service, now requires a separate
commercial license from the copyright holder. Noncommercial use,
study, modification, and sharing remain fully permitted.
Updates: LICENSE (canonical PolyForm Noncommercial 1.0.0 text +
commercial licensing contact note), SPDX headers and file-header
boilerplate across all Swift source files, the in-app About dialog's
license link (+ its localization catalog entry), README.md and
DEVELOPMENT.md license sections.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Lets the AI connect to any external stdio MCP server (e.g. safaridriver
--mcp) configured in Settings, with tools auto-discovered and prefixed
by server slug. Includes crash detection with backoff restart (5s/15s/30s)
and a Settings UI to add/enable/disable/remove servers.
Fixes the temp-dir allowlist in MCPService.isPathAllowed to also match
/tmp and /private/tmp (not just NSTemporaryDirectory(), which resolves
to a different per-user Darwin temp dir) so the MCP file tools can
actually read files external servers and image generation write there.
Also switches the Add Server sheet's argument parsing to a quote-aware
tokenizer so args containing spaces survive intact.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Personal Data Tools: native Calendar, Reminders, Contacts (hidden pending
Apple TCC fix in beta), and Location & Maps access via EventKit, Contacts
framework, and MapKit. Write actions (create event/reminder, complete
reminder) gate through an approval sheet. Four hardened-runtime entitlements
added to oAI.entitlements; Info.plist usage strings added for all services.
Personal Data section shows a β badge while Contacts is hidden.
2nd Brain always-trust: inline toggle on the Agent Skills row for the skill
named "2nd Brain" skips the bash approval dialog when the command contains
.brain_helper.py, gated by three runtime checks in MCPService.
Research agents: spawn_research_agents tool runs up to 5 concurrent read-only
sub-agents (read_file, list_directory, search_files, web_search — no write,
no bash, no nesting). Bounded by maxConcurrentAgents setting (default 3) and
a hard ceiling of 8 tasks. Added items field to Tool.Function.Parameters.Property
for JSON Schema array support; wired into AnthropicProvider.convertParametersToDict.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Jarvis integration: manage oAI-Web agents and usage from inside the app (/jarvis command, Settings tab 11)
- Model category filter: keyword-based categorisation with popover picker in model selector
- Categories shown in ModelInfoView with coloured chips; dot indicators on model rows
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>