Skip to content

Latest commit

 

History

History
104 lines (76 loc) · 25.4 KB

File metadata and controls

104 lines (76 loc) · 25.4 KB

Isle

Isle has macOS and iOS clients backed by the same in-tree agent and shared personal tools. On macOS, tapping the fn / 🌐 key shows a Dynamic Island-style pill with a focused, minimal text field; the user types a message and presses Enter to send it to the agent (an in-process loop against openai/gpt-6-sol via OpenRouter), and the answer shows in the pill. The pill walks through three phases (IslandPhase) — composing → thinking (the "Thinking" / running-tool row) → responding — and stays open with the answer until the next turn; after an answer the field is focused again so the next question can be typed straight away. fn is a toggle: tapping it again while the pill is visible hides it (the conversation persists for the next open). The conversation persists across turns: each request replays the full history to the model. Reopening within the idle window restores the last answer on screen (the pill comes back showing it, ready for a follow-up rather than blank). After the pill sits closed for 5 minutes the conversation is cleared — both the model-facing history and the on-screen answers — so the next open starts fresh.

Typing is the only input: there is no mode switching. The pill opens with the field focused, Enter sends, and ⌘N clears the conversation and starts a fresh chat without closing the pill.

Commands

  • xcodebuild -project Isle.xcodeproj -scheme Isle -configuration Debug -destination 'platform=macOS' build - Build
  • xcodebuild test -project Isle.xcodeproj -scheme Isle -destination 'platform=macOS' - Run tests
  • open $(xcodebuild -project Isle.xcodeproj -scheme Isle -configuration Debug -showBuildSettings | awk -F' = ' '/ BUILT_PRODUCTS_DIR /{d=$2}/ FULL_PRODUCT_NAME /{n=$2}END{print d"/"n}') - Launch the built app
  • ./install.sh - Build Debug and install to /Applications/Isle.app (quitting any running copy). Local installs are always the Debug build on purpose: Log compiles only into Debug, so this is the version that writes session logs to ~/Library/Application Support/Isle/logs/. There is no logging Release build — Release ships without it.

Testing

  • IsleTests holds fast unit tests (AgentEngine.EngineError.classify, the tool bridge). IsleUITests is still an empty Xcode stub. Run the unit tests with -only-testing:IsleTests (xcodebuild test -project Isle.xcodeproj -scheme Isle -destination 'platform=macOS' -only-testing:IsleTests) — the plain build already typechecks, and a bare xcodebuild test needlessly launches the app-driving UITests target.

Stack

  • Swift 5, SwiftUI + AppKit + EventKit, deployment target macOS 26.3 (Apple Silicon)
  • Packages/Robo (local SPM package) — the agent engine: model adapters, the tool loop, the bash/web_search harness, and an MCP client. A Swift port of the roboport TypeScript framework, kept in-tree
  • swift-sdk MCP (SPM) — the tool-declaration vocabulary Isle's own tool surfaces use, and the client behind MCPToolProvider

Structure

macOS source is grouped by responsibility under Isle/ (App/, UI/, Input/, Agent/, Tools/, Diagnostics/); the engine lives in Packages/Robo/. Cross-platform Calendar, Reminders, and Fastmail implementations live in Shared/Tools/ and compile into both app targets. New macOS and shared .swift files compile automatically through synchronized file groups; iOS project changes are generated from iOS/project.yml.

App/

  • App/IsleApp.swift - @main entry; no real scene (Settings {}), delegates to AppDelegate
  • App/Preferences.swift - The Preferences struct: one place for every tunable knob (launchAtLogin, idle timeout, the enableLogging debug-log toggle, the model/thinking/systemPrompt model knobs, the turnTimeout cap, the tool toggles (enableReminderTools/enableCalendarTools/enableNotesTools/enableMusicTools/enableMailTools/enableBrowserTools/enableMapTools) plus browser knobs (chromeHeadless/chromeDebuggingPort)). Today it's just compile-time defaults for personal use; it's the seam a future settings UI (or a UserDefaults-backed store) hangs off — the values flow one way, from a Preferences value into AppDelegate → Conversation → AgentEngine
  • App/AppDelegate.swift - Lifecycle, login-item registration (SMAppService.mainApp, reconciled from preferences.launchAtLogin), panel placement, show/hide, layout constants, fn wiring, and submitTypedText() (the send path). show() makes the panel key so it receives keystrokes (the field needs the caret; ⌘N needs to reach the local monitor). Holds the idle timer (armIdleTimer/cancelIdleTimer): armed on hide(), cancelled on show(), it clears the whole conversation — the model-facing history (conversation.clearHistory()) and the on-screen answers (state.reset()) — once the pill has stayed closed past preferences.idleTimeout (5 min). hide() keeps the answers on screen (state.prepareForClose(), only the in-progress compose is dropped), so reopening before the timer fires takes the restore path: onToggle calls state.restore() when state.hasHistory, bringing the last answer back as the active turn with the field refocused. Owns the single Preferences value and injects it into Conversation.

UI/

  • UI/PillPanel.swift - Borderless non-activating floating NSPanel
  • UI/PillView.swift - The pill UI: emerge/retract animation, the input field (inputField, a borderless TextField bound to IslandState.draft, focused via @FocusState/syncFocus()), the submitted-message echo, the answer bubbles (ResponseBubble), the recent-chats switcher, and IslandState (phase + draft + userMessage + currentTool + assistantTurns)
  • Shared/UI/MarkdownText.swift - Renders answers in both apps as styled markdown (headings, bold/italic, inline + fenced code, bullet/numbered lists, GFM pipe tables, blockquotes, rules). A small line-oriented block splitter (MarkdownParser) defers inline styling to AttributedString(markdown:); no SPM dependency

Input/

  • Input/FnKeyMonitor.swift - Global fn-key tap detection (onToggle) plus a local .keyDown monitor for ⌘N (onNewChat, swallowed) while Isle holds focus

Agent/

  • Agent/Conversation.swift - Owns the model-facing conversation history and the send path: submit(_:) runs a turn through AgentEngine and surfaces the result through callbacks (onResponse / onNoResponse / onToolEvent). clearHistory() forgets the context (idle timer, ⌘N); loadHistory(_:) replaces it when a saved chat is reinstated. The engine is built lazily on the first turn, not at init — resolving the API key can block on a keychain prompt, and doing that during applicationDidFinishLaunching hangs the app before it draws (and hangs xcodebuild test, since the app is the test host). A second submit cancels the turn in flight rather than racing it
  • Agent/AgentEngine.swift - Wraps the Robo package: builds the agent (OpenRouter model + Harness.tools() + Isle's own tools), runs one turn, and returns the answer. EngineError reduces any failure to a short line the pill can show, and classify(_:) maps ModelError/AgentError/cancellation onto it. The turn timeout is a task racing the turn — there's no subprocess to kill, so cancellation unwinds the model stream and any running tool
  • Agent/IsleTools.swift - Bridges Isle's tool providers to the agent in-process. Each still declares its surface as MCP Tool values (a perfectly good JSON-Schema description, and the SDK is still a dependency for the client); MCPBridge.tool wraps each declaration with a closure that calls the provider's existing call(name:arguments:) directly. IsleToolSet.Providers holds the optional providers so a disabled preference simply contributes no tools

Tools/

  • Shared/Tools/RemindersService.swift - EventKit-backed reminders access on its own actor (keeps the non-Sendable EKEventStore off the main thread): list/search/get/create/edit, each calling ensureAccess() (requestFullAccessToReminders) first and returning ReminderDTOs (never EKReminder, which isn't Sendable). Dates are ISO 8601 in/out
  • Shared/Tools/ReminderTools.swift - The shared MCP tool surface: the five Tool declarations (JSON-Schema for tools/list) and call(name:arguments:), which dispatches a tools/call to RemindersService and packages the result as a single JSON text block (errors become isError results with a readable message)
  • Shared/Tools/CalendarService.swift - EventKit-backed calendar access on its own actor: list_calendars plus bounded list_events, get_event, create_event, and edit_event. It requests full event access on first use and returns CalendarDTO/CalendarEventDTOs, never EKCalendar/EKEvent; dates are ISO 8601 in/out. Edits to recurring events support this occurrence or all future occurrences; deletion is intentionally not exposed.
  • Shared/Tools/CalendarTools.swift - The shared MCP calendar surface: the five calendar tool declarations and their JSON-Schema/dispatch, mirroring ReminderTools. Event creation is immediate; no confirmation layer exists.
  • Shared/Tools/MapService.swift - Shared MapKit/Core Location implementation for Apple Maps place search and ETA summaries. MKLocalSearch resolves places, MKDirections.calculateETA() returns distance/time for driving, walking, transit, or cycling, and a main-actor one-shot CLLocationManager supplies current location only when requested. Current-location coordinates are never returned to the model.
  • Shared/Tools/MapTools.swift - The shared two-tool Maps surface: search_places (optionally current-location-biased) and estimate_travel_time (named origin or current location, optional departure/arrival date). Both macOS and iOS expose it.
  • Tools/NotesService.swift - AppleScript-backed Apple Notes service on its own actor. It lists/searches/gets notes and folders, and creates, replaces, or appends notes; MCP receives plaintext while writes are converted to escaped HTML internally.
  • Tools/NotesTools.swift - The seven Apple Notes MCP tools: list_note_folders/list_notes/search_notes/get_note/create_note/edit_note/append_to_note.
  • Shared/Tools/MailService.swift - EmailDTO/MailboxDTO, the MailProvider protocol, MailError, and the MailService actor that forwards to the active provider. MailProvider is the seam behind which any backend slots; Preferences.mailBackend (default .fastmail) picks the one MailService is built with (makeMailProvider()). AppleMailProvider (Mail.app, all accounts, offline) is kept as a fallback; JMAPMailProvider (Fastmail, direct API) is the default. MailError gained .notConfigured (no token set) and .backend (a JMAP/HTTP failure) alongside the AppleScript cases
  • Tools/AppleMailProvider.swift - Drives Mail.app over AppleScript, in-process via NSAppleScript on a dedicated serial queue (in-process so the Apple Events TCC grant attributes to Isle, not a spawned osascript — same reasoning as the Reminders grant). Read scripts return a US/RS-delimited (0x1F/0x1E control chars) string parsed back in Swift; get's body is the final field so stray separators inside it survive the bounded split. EmailDTO.id is an opaque base64 of {account, mailbox, numericId} (Mail's own per-account numeric id isn't globally stable). parseRow/literal are internal (not private) so IsleTests can exercise them without a live Mail.app. Slow by construction — ~10 Apple Events per message (see the header comment) — which is why JMAPMailProvider is the default
  • Shared/Tools/JMAPClient.swift - Minimal JMAP transport over URLSession (session discovery + the single methodCalls POST) — the same "speak the protocol directly, bundle nothing" approach as CDPClient. An actor so the discovered Session (apiUrl + primary mail account) is fetched once and cached; request/response bodies cross the boundary as Data (Sendable), so the provider builds/parses the [String: Any] and no non-Sendable dictionary is passed across. HTTP 401/403 maps to MailError.notConfigured (bad token)
  • Shared/Tools/JMAPMailProvider.swift - MailProvider backed by Fastmail's JMAP API. A "list 20 headers" is one HTTP request (Email/query chained to Email/get via the #ids back-reference) vs. the AppleScript path's ~10 events/message. Uses stable server ids, mailbox roles (special-name → role lookup), real UTC dates. An actor caching the mailbox index + sending identity; send creates a draft then EmailSubmission/set with onSuccessUpdateEmail to move it to Sent and drop $draft (one request). EmailDTO.id is opaque base64 of {accountId, emailId}. Pure parsers (parseEmail/filter/result/resolveMailboxId/…) are nonisolated static (the module defaults to MainActor isolation) so IsleTests exercises them without a live server. Single-account today (the account arg is ignored)
  • Tools/Keychain.swift - A tiny login-keychain generic-password wrapper (Keychain, service DestinerLabs.Isle) plus FastmailCredentials, which resolves the JMAP bearer token from the ISLE_JMAP_TOKEN env var (dev) else the Keychain — never a committed constant. Sandbox is off, so no keychain-sharing entitlement is needed
  • Shared/Tools/MailTools.swift - The shared MCP mail surface: seven Tool declarations (search_emails/list_emails/get_email/send_email/create_draft/mark_read/list_mailboxes) and call(name:arguments:) dispatching to MailService, mirroring ReminderTools (single JSON text block, Log.tool choke point on macOS, errors as isError). macOS exposes the full surface; iOS filters it to readOnlyToolNames and rejects write dispatches defensively. macOS send_email sends immediately — no confirmation step (prototype-grade, same "misheard command runs with full access" caveat as the unsandboxed bash tool)
  • Tools/CDPClient.swift - A minimal Chrome DevTools Protocol client over URLSessionWebSocketTask (no Node/Puppeteer, nothing bundled — it drives the user's own installed Chrome). CDP is JSON-RPC over WS: send(method:params:) correlates replies by integer id (each with its own timeout), ignores unsolicited events (the higher layer polls instead), and fails all in-flight commands on socket close. Returns raw result JSON as Data (Sendable) so it crosses cleanly into BrowserService's actor
  • Tools/BrowserService.swift - Actor that drives Chrome over CDP: navigate/read/click/type/evaluate/screenshot plus show/hide, returning PageDTO/ScreenshotDTO. Launches Chrome lazily on first use (it's heavy) against a dedicated persistent profile under Application Support (chrome-profile) with the debugging port open, so automation logins survive across runs without touching the user's everyday Chrome. Headless by default (--headless=new, which shares the same profile). navigate guards against silent no-ops (bad host → errorText; blocked top-level data: URLs → final URL unchanged). show/hide flip visibility — Chrome can't switch headless↔headed live, so they relaunch against the same profile, shutting the old one down gracefully via CDP Browser.close (SIGKILLing the process tree skips Chrome's cookie/localStorage flush and loses the session — a bug found the hard way)
  • Tools/BrowserTools.swift - The MCP browser surface: seven browser_* Tool declarations (navigate/read/click/type/evaluate/screenshot/show/hide — show/hide are the human-in-the-loop escalation) and call(name:arguments:) dispatching to BrowserService, mirroring ReminderTools/MailTools (single JSON text block, Log.tool choke point, errors as isError)

iOS/

  • iOS/IsleMobile/ManuscriptView.swift - The document-like mobile conversation UI and inline thinking/tool activity row. It uses the macOS app's black, white, and translucent-white palette; assistant answers use the same shared MarkdownText renderer.
  • iOS/IsleMobile/MobileConversation.swift - Persisted mobile threads plus the streaming Robo session. Builds a tool-enabled session with current date/time context and maps tool events into the inline activity row.
  • iOS/IsleMobile/MobileTools.swift - The iOS tool policy: web search, shared Calendar, Reminders, and Apple Maps tools, plus the read-only subset of Fastmail tools. Mail tools are omitted when no JMAP token is configured.
  • iOS/IsleMobile/Credentials.swift - Device-Keychain storage and one-time environment bootstrap for the OpenRouter key and Fastmail JMAP token.

Diagnostics/

  • Diagnostics/Log.swift - Log, the single logging facade everything routes through (model turns, tool calls, conversation turns, lifecycle). Debug-only: the whole path compiles to false/no-ops in Release (#if DEBUG) so nothing ships in a distributed build; in Debug it's on by default, and Log.configure(enabled:) (from preferences.enableLogging) or the ISLE_LOG env var (0/1, wins over the pref) flip it. Writes structured JSONL — one event per line with a shared envelope (ts, level, cat, event, plus conv+turn for conversation-scoped events) — and mirrors each line to os_log (subsystem DestinerLabs.Isle, category = cat). Holds the "current conversation/turn" context (the pill is single-turn, so it's global; lock-guarded because tool/model events build lines off-main); turnUser lazily opens a conversation, endConversation (via Conversation.clearHistory on idle-clear) closes it
  • Diagnostics/LogWriter.swift - LogWriter.shared, the serial-queue file appender behind Log. Owns ~/Library/Application Support/Isle/logs/ and does every write on one private DispatchQueue (callers hand it finished lines, no locking of their own). Two targets: one always-open app.jsonl for app-scoped events, and one <stamp>-<id>.jsonl per conversation for turn/model/tool events (swapped on beginConversation). Nothing is rotated or deleted

Patterns

  • Background agent: LSUIElement = YES + setActivationPolicy(.accessory), so no Dock icon or menu bar. Quit via right-click on the pill.
  • The panel is intentionally taller than the pill (topRoom/bottomRoom in AppDelegate) so the animation can render outside the pill without being clipped by the window bounds.
  • fn is a hardware modifier, not a key, so it's detected via NSEvent .flagsChanged keyed on kVK_Function — not a Carbon hotkey.
  • Keyboard routing: the panel is a .nonactivatingPanel with canBecomeKey = true, so makeKeyAndOrderFront makes it key (caret + keystrokes) without activating Isle or defocusing the frontmost app (Raycast-style) — the trade-off is that while the pill is open you can't type into the frontmost app. Enter is handled by the field's own key handling (onSubmitText → submitTypedText); the only monitor-level key is ⌘N (new chat), caught by a local .keyDown monitor that fires when Isle is key and swallows the event. Esc no longer dismisses; the panel's cancelOperation is a no-op that only swallows the default beep.
  • Conversation as turns: each answer is an AssistantTurn (stable id) in IslandState.assistantTurns. The last turn is "active" (white, full, scroll-capped) only while phase == .responding; otherwise it renders as a collapsed two-line greyed context bubble. Because the bubble keeps its identity across that flip, the active→context change animates as a collapse (both states share ResponseBubble; the measured height is animated in onPreferenceChange so it doesn't jump). The view shows the active turn plus one prior while responding, else just the single prior. The model-facing history (user + assistant text) lives separately in Conversation and is replayed to the model each turn — there is no resumable server-side session, so context is carried by us. The active answer renders as markdown (MarkdownText); the collapsed context line strips markdown to a flattened two-line plain-text preview (ResponseBubble.plainPreview) since structure isn't useful at two lines.
  • The agent engine is in-tree (Packages/Robo): a local SPM package, a Swift port of the roboport TypeScript framework. It owns the model adapters (OpenAICompatible → OpenAI/OpenRouter), the tool loop, the bash/web_search harness, and an MCP client. Isle depends on it like any package; it has its own test suite (swift test from Packages/Robo). Keeping it a package rather than app sources means the engine can be exercised without launching the app — the app is an LSUIElement agent, so it makes a poor test host.
  • No sandbox on bash (a deliberate reversal of the Codex-era computerAccess toggle): the old codex exec path could hand shell work to Seatbelt (-s workspace-write -a never). A native Swift loop has no equivalent, so the bash tool runs with Isle's full privileges and TCC identity, always. That matches how Isle was actually run (computerAccess defaulted to on), and the toggle was removed rather than left as a setting that no longer did anything. The trade-off stands: a misheard request runs with the user's reach, including Apple Events to any app.
  • Isle's own tools run in-process, no MCP server: they used to be reachable only over a localhost Streamable-HTTP MCP server, because MCP was Codex's only mechanism for custom tools. With the loop inside Isle that hop is gone — MCPServer.swift and the swift-nio dependency were deleted. The providers still declare their surface as MCP Tool values (good JSON Schema, and the SDK is still needed for the client), and Agent/IsleTools.swift bridges each declaration to a direct in-process call. Robo/MCP/MCPToolProvider remains for connecting external MCP servers.
  • The API key resolves lazily: OpenRouterCredentials reads ISLE_OPENROUTER_KEY else the login keychain (service DestinerLabs.Isle, account openrouter-api-key). The lookup happens on the first turn, never at launch — a keychain ACL prompt during applicationDidFinishLaunching blocks the app before it draws, and blocks xcodebuild test too. Expect one "Isle wants to use your confidential information" prompt on first use; click Always Allow.
  • Browser automation (native CDP, nothing bundled): BrowserService/BrowserTools drive the user's installed Chrome by speaking CDP over a URLSessionWebSocketTask (CDPClient) — no Puppeteer, no Node, no bundled Chromium. Chrome is launched lazily against a dedicated chrome-profile (persistent logins, isolated from the user's everyday Chrome) and headless by default. The design point: run invisibly, and only surface a window when a human is actually needed — browser_show escalates to a visible window (login, CAPTCHA, confirm), browser_hide tucks it back. Since Chrome can't toggle headless↔headed live, both relaunch against the same on-disk profile and quit the old instance via CDP Browser.close (graceful, so cookies/localStorage flush — a pkill of the tree loses the session). The headless choice is an app pref, not a per-call tool arg: Chrome is launched once and reused, so it's a launch-time property, not something the model should babysit turn-to-turn.

Gotchas

  • App Sandbox is disabled (ENABLE_APP_SANDBOX = NO). Global keyboard monitoring requires it off; re-enabling breaks the fn trigger.
  • The app needs Accessibility permission — the global .flagsChanged tap for fn runs under it. (Isle no longer taps .keyDown globally, so Input Monitoring is not required; a stale grant from an earlier build is harmless.) The grant requires an app relaunch to take effect.
  • Reminders needs the com.apple.security.personal-information.reminders entitlement (a hardened-runtime requirement) plus the NSRemindersFullAccessUsageDescription Info.plist key (set via INFOPLIST_KEY_… in build settings). First use prompts for Reminders access, attributed to Isle — launch from Finder/open, not a terminal, or the grant misattributes; the grant may need a relaunch.
  • Calendar needs the com.apple.security.personal-information.calendars entitlement plus NSCalendarsFullAccessUsageDescription. Calendar tools call requestFullAccessToEvents() lazily, so the system prompt appears on their first use; use Finder/open for correct Isle attribution, and relaunch if macOS asks for it.
  • Maps current location needs the com.apple.security.personal-information.location entitlement plus NSLocationUsageDescription on macOS and NSLocationWhenInUseUsageDescription on iOS. Place search without a location hint needs no permission; nearby search and current-origin ETA request one location fix lazily on first use.
  • Mail (AppleScript) needs the com.apple.security.automation.apple-events entitlement (hardened-runtime requirement to send Apple Events) plus the NSAppleEventsUsageDescription Info.plist key. First mail-tool use prompts "Isle wants to control Mail" (Automation) — same launch-from-Finder/relaunch caveats as Reminders. The prompt fires only on the first Apple Event, so it may surface mid-answer the first time the agent calls a mail tool.
  • TCC ties permissions to the app's code identity (stable: signed DestinerLabs.Isle, team HQR74263JL), so a grant survives rebuilds. But launching the dev build from a terminal can misattribute the permission prompt to the terminal — launch from Finder/open (e.g. a copy in /Applications) so prompts attribute to Isle.
  • The project uses synchronized file groups — new .swift files in Isle/ are compiled automatically. But SPM dependencies still require manual project.pbxproj edits (no auto-sync for packages).