Skip to content

Latest commit

 

History

History
177 lines (127 loc) · 57.9 KB

File metadata and controls

177 lines (127 loc) · 57.9 KB

copad macOS ↔ Linux Parity Plan (v2 — codex round-1 reflected)

Goal

Bring the macOS app to feature/UX parity with Linux. Phase 1 MVP, WebView panel, AI Agent / event integration, and config hot-reload already shipped on macOS. This plan covers everything still missing, prioritized by user-visible impact and architectural dependency.

Confirmed-divergent areas (audit summary)

Source-of-truth audit of the working tree at HEAD (master):

  • Socket commands: Linux 45 / macOS 32. Missing on macOS: background.next, background.toggle, claude.start, theme.list, plugin.list, plugin.open, plugin.<service> (generic dispatcher), statusbar.show/hide/toggle, webview.screenshot, webview.query, webview.query_all, webview.get_styles, webview.click, webview.fill, webview.scroll, webview.page_info. Per-method id parameter for webview commands is required by Linux but ignored by macOS (active-panel only). Parity = adopt panel id resolution on macOS.
  • Service plugin host: copad-linux/src/service_supervisor.rs (1794 LOC). macOS now has a native-Swift PluginSupervisor (PR 3 / PR 6); all 9 first-party plugins build + install on Darwin. KB/Todo/Bookmark formerly gated target_os = "linux" for renameat2; PR 6 routed the atomic-create-or-fail rename through copad_core::fs_atomic (Linux renameat2(RENAME_NOREPLACE) / macOS renamex_np(RENAME_EXCL)) and dropped the gate to any(linux, macos).
  • Trigger engine + Action Registry + ContextService + condition DSL: live in copad-core. Linux pumps via LiveTriggerSink (window.rs). macOS uses only EventBus.swift. Trigger engine in particular carries non-trivial semantics: await preflight & pending state (Phase 14.2 <trigger>.awaited synthesis), completion/failure fanout, covering_patterns subscription dedup, reload draining preserving in-flight events, ordering guarantees. Underestimating this in v1 broke Slack→todo / slack.get_message chained workflows.
  • Status bar: copad-linux/src/statusbar.rs (407 LOC, Waybar-style modules); none on macOS.
  • Plugin panels (HTML/JS bridge): copad-linux/src/plugin_panel.rs (418 LOC) hosts WebKit-rendered panels with a reply-capable bridge — JS calls await window.copad.call(method, params) and receives a structured response. macOS has no equivalent.
  • Custom keybindings: Linux parses [keybindings] and runs spawn:<cmd> as out-of-band shell processes via spawn_command (tabs.rs:1413), NOT via the socket dispatcher. macOS shortcuts are hardcoded.
  • Pane focus navigation: Linux Ctrl+Shift+N/P/arrows; macOS missing.
  • Tabs position: Linux supports top/bottom/left/right; macOS pins top.
  • Background random rotation: Linux reads a flat list at ~/.cache/terminal-wallpapers.txt (note: docs claim ~/.cache/copad/wallpapers.txt — docs lag, source const is terminal-wallpapers.txt). Populating that list (e.g. from a [background] directory) is itself unimplemented on Linux. Active mode toggle is a separate file. macOS has none of this.
  • Terminal-side gaps: OSC 52 clipboard and clickable URL detection pending on both, but see C1 below: macOS already permits unconditional OSC 52 writes through SwiftTerm.

Acknowledged irreducible gap

  • terminal.output event: SwiftTerm's feed(byteArray:) is an extension method, not overridable. Documented in docs/macos-app.md. Out of scope.

Codex round-1 findings reflected (verified against the source)

CRITICAL

  • [C1] OSC 52 is a security regression on macOS today, not a future feature. SwiftTerm/Mac/MacLocalTerminalView.swift:107 declares public func clipboardCopy(source:content:) that calls NSPasteboard.general.writeObjects([str]) unconditionally. The method is public, not open — we cannot override it from outside SwiftTerm, and it does not forward through processDelegate. So today, any program in a copad pane can write to the macOS pasteboard via OSC 52 with no user gesture and no off-switch. Plan changes:

    • Treat OSC 52 as a blocker for Tier 1, not a parity item.
    • Fix path: replace LocalProcessTerminalView with our own subclass that owns being the TerminalDelegate and proxies clipboardCopy ourselves (gated on [security] osc52 = "deny" | "ask" | "allow", default "deny"). If subclassing turns out blocked by the same public-not-open issue on the upstream class, we fork SwiftTerm. Either way it's a real engineering chunk, not a delegate stub.
    • Linux's VTE has the same audit: vte4::Terminal exposes set_enable_osc_52 (or equivalent); confirm our config gate applies symmetrically.
  • [C2] Trigger engine is not "small bookkeeping" — re-implementing it in Swift will silently break chained workflows. copad-core/src/trigger.rs:154 plus copad-linux/src/window.rs:551 show: <trigger_name>.awaited synthesis for event.subscribe-style chains, completion/failure fanout to LiveTriggerSink::dispatch_action consumer, covering_patterns dedup so overlapping globs collapse to one bus receiver, hot-reload reconcile() that preserves still-needed receivers' pending events, and per-event ordering. Plan changes:

    • Drop "Option A — reimplement engines in Swift" from Tier 2. Tier 2 splits as:
      • Native Swift: ContextService (small data class, polled from main runloop), thin ActionRegistry shim that maps method name → completion handler.
      • Rust FFI (cdylib + C-ABI): TriggerEngine, condition DSL, event_bus covering/subscriber bookkeeping. JSON in / JSON out at the boundary.
    • This means the FFI build-system work (cargo + SPM build phase, static .a bundled in Copad.app) lands in Tier 2, not Tier 3. Tier 3 (supervisor) reuses the same FFI pipeline.
    • De-risk first: a small Tier 2 spike that exposes only TriggerEngine::set_triggers + one awaited-trigger fan-out, validated end-to-end with event.subscribe, before committing to wider FFI surface.
  • [C3] Plugin panel JS bridge needs a reply-capable handler. plugin_panel.rs:207 exposes await window.copad.call(method, params) returning a structured response. Plain WKScriptMessageHandler is fire-and-forget. Plan change:

    • Use WKScriptMessageHandlerWithReply (macOS 11+, well within our macOS 14+ target).
    • Public JS contract (window.copad.call) stays unchanged; the injected user-script glue handles the request-id round-trip. No fallback to postMessage + manual id; the API existed in our minimum target.

IMPORTANT

  • [I1] Webview commands need panel id resolution on macOS. Linux's webview.navigate/back/forward/reload/query/click/fill/scroll/state take an id param. macOS targets activeWebView. AI/web automation parity (concurrent panels, async commands) requires panel-id resolution. Plan change:

    • Add stable per-panel ids to CopadPanel (UUID at creation), expose getPanel(id) on TabViewController, branch in command handlers: id present → resolve; absent → fall back to active.
    • Already needed for Tier 4 webview interaction commands; bring it forward to Tier 1 #5 (URL click) so we don't ship the easy commands with active-only and then change the contract later.
  • [I2] Background rotation is ~/.cache/terminal-wallpapers.txt + active-mode flag, not [background] directory. Linux reads a single flat cache file populated externally (not by copad). The active/deactive toggle is a separate file. The roadmap entry "background random rotation + [background] directory" is forward-looking and not yet implemented on either platform. Plan change:

    • Drop "macOS-side [background] directory config" from Tier 1.
    • macOS parity = read the same ~/.cache/terminal-wallpapers.txt (it's a Linux-y path; on macOS use ~/Library/Caches/copad/wallpapers.txt AND read the legacy path as fallback for users who already have it). background.next = pick a random line. background.toggle = same active-mode file semantics.
    • If we want directory-driven population, that's a separate roadmap item that lands on both platforms behind a shared copad-core::background module.
  • [I3] Config.swift's hand-rolled parser dies on [keybindings] + [[triggers]] + [security]. Linux config already requires arrays-of-tables ([[triggers]]), inline tables (condition strings with embedded quotes), escaped strings. Plan change:

    • Swap Config.swift to a real TOML parser (SwiftPM dep swift-toml or TOMLDecoder) before Tier 1 #2. It's a prerequisite, not a Tier-2 task.
    • Round-trip-test against the existing plugins/*/triggers.example.toml files.
  • [I4] Custom keybindings spawn shell commands out-of-band, not via socket dispatch. Linux's [keybindings] map "ctrl+shift+g" = "spawn:~/script.sh --arg" to spawn_command(&binding.command) — a fork/exec, not a socket::dispatch call. Plan change:

    • macOS keybindings use Process (or posix_spawn) with COPAD_SOCKET env injection, exactly like Linux.
    • Trigger-action-via-keybinding (the "Custom-keybinding spawn" Tier 2 item) is a separate feature: an opt-in "action:webview.open ..." syntax that DOES go through ActionRegistry. Both syntaxes are needed for parity (spawn:) plus useful extension (action:).
    • Document the security implication: spawn: runs commands with the user's full env. Same as Linux.
  • [I5] Supervisor shutdown needs multiple paths + process-group ownership. Linux uses window.connect_destroy + glib::unix_signal_add_local (SIGTERM/SIGINT). macOS app termination is best-effort; SIGKILL bypasses everything. Plan change:

    • On macOS: spawn each plugin in its own process group (setpgid(0,0) in a pre_exec-equivalent on the child). Track child PIDs in a file at ~/Library/Caches/copad/plugin-pids.json.
    • Shutdown hooks: applicationShouldTerminate → graceful, applicationWillTerminate → SIGTERM the group, signal handlers (SIGTERM/SIGINT) → same.
    • Crash recovery: at next launch, read the PID file; for any pid still alive whose pgid we own, SIGKILL the group. This is the only mitigation for SIGKILL'd parents.

NICE-TO-HAVE

  • [N1] Source comment in TerminalViewController.swift:10 claims PTY output interception; reality is dataReceived is not implemented in the subclass. Update comment to match the documented irreducible gap.
  • [N2] SwiftTerm has requestOpenLink for OSC 8 hyperlinks. Use that for explicit links; only fall back to regex-on-buffer for plain-text URLs (regex hit-testing across wrapped lines / scrollback / alt screen is fragile).

Updated phased plan

Tier 0 — Pre-requisites (must land before Tier 1)

  • 0.1 Real TOML parser in Config.swift (I3) ✅ — SwiftPM dep LebJe/TOMLKit (0.6.0). CopadConfig.parse decodes via TOMLDecoder into a private RawConfig shadow type whose sections ([terminal], [theme], [background], [security]) are all-optional so any subset (including the empty file) round-trips to defaults. Snake-case keys (font_family, font_size) handled with manual CodingKeys since TOMLKit 0.6 has no keyDecodingStrategy. Unknown sections from the Linux schema ([tabs], [statusbar], [keybindings], [[triggers]] with nested inline tables) parse without error and are silently dropped — confirmed against a stand-in test that loads the user's existing ~/.config/copad/config.toml plus a synthetic full-Linux-shape config. Malformed TOML logs a [copad] config.toml parse failed: … line to stderr and falls through to defaults instead of crashing.
  • 0.2 Stable panel ids on CopadPanel (I1 prep) ✅ — CopadPanel.panelID (UUID) was already present on TerminalViewController and WebViewController. Surfaced TabViewController.panel(id:) -> (any CopadPanel)? and webView(id:) -> WebViewController? that walk every tab's split tree (O(N) but N is small in practice). Adopted by Tier 1.6 webview command resolution.
  • 0.3 OSC 52 deny-by-default (C1) ✅ — CopadTerminalDelegate proxy owns SwiftTerm's terminalDelegate slot, gates clipboardCopy on [security] osc52 (default "deny", opt-in "allow"). Hot-reload via applyOSC52Policy. VTE on Linux is already deny-by-default, so this fix is macOS-only. Tri-state plan deferred — "ask" requires modal-on-PTY-thread UX design; ship binary deny/allow first.

Tier 1 — UX parity

  1. Pane focus navigation ✅ — PaneManager.focusNextPane(direction:) does DFS over SplitNode leaves, wraps at both ends. Menu items Cmd+Shift+] / Cmd+Shift+[ in AppDelegate.setupMenuBar. Also exposed via pane.focus_next / pane.focus_prev socket commands so triggers + coctl can drive it. Each focus change broadcasts panel.focused event on the bus (already existed).
  2. Custom keybindings ✅ — [keybindings] flat dict in config.toml, walked via TOMLKit TOMLTable. Keybindings.compile parses cmd+shift+g style combos (supports cmd/ctrl/shift/alt/option modifiers). NSEvent.addLocalMonitorForEvents(matching: .keyDown) intercepts; on a match returns nil (swallows event) and dispatches. Two value syntaxes: spawn:<cmd> (Process via sh -c, COPAD_SOCKET env injected so script can call back) and action:<method> [k=v ...] (registry dispatch with plain-string params). Built-in menu shortcuts still take precedence (menu fires before local monitor). Hot-reload swaps the binding list in place; the monitor closure reads the latest snapshot via self.
  3. Background random rotation ✅ — BackgroundRotator enum reads ~/Library/Caches/copad/wallpapers.txt (macOS-native), falls back to ~/.cache/terminal-wallpapers.txt (XDG-compat for shared dotfiles). Mode file ~/Library/Caches/copad/bg-mode holds active/deactive (missing = active, matches Linux). background.next socket command picks a random line via subsec_nanos % count (same poor-man's RNG as Linux), preserves current tint/opacity, returns {status, mode, path}. background.toggle flips mode + applies a fresh image on activation / clears on deactivation, returns {status, mode}. Empty list returns RPCError(no_wallpapers, ...).
  4. Tabs position (top/bottom) ✅ — [tabs] position enum (.top/.bottom) in CopadConfig. TabViewController.loadView builds layout constraints conditionally — anchors swap so tabBar lives at top or bottom of root, contentArea fills the remainder. Invalid value (e.g. "left" from a Linux config) falls back to .top with friendly stderr warning. left/right deferred (vertical tab bar needs 90-deg rotation of TabBarView itself, separate layout pass).
  5. URL detection + click-to-open ✅ — OSC 8 hyperlinks already worked for free (SwiftTerm's requestOpenLink default → NSWorkspace.shared.open, no override needed). Plain-text URL detection landed: URLClickHelper.findURL runs an https?:// regex against the row at the click's cell coordinate (computed from view bounds + terminal.cols/rows since SwiftTerm's cellDimension is non-public), uses Terminal.getText(start:end:) for the row text, picks the match whose char range covers the click column, and strips a trailing punctuation char (.,:;!?) so URLs ending a sentence open cleanly. PaneManager.installURLClickMonitor adds a .leftMouseUp NSEvent local monitor that consumes the event when a URL matches and falls through otherwise so SwiftTerm's OSC 8 handler still gets non-plain-text clicks. MacTerminalView.mouseUp is public override (not open) so we can't subclass — same monitor pattern used by pane-focus tracking and CopadTerminalDelegate. Wiring is verified by 11/11 standalone unit tests covering: click inside URL, before/after URL, parens, query strings, trailing period, multiple URLs per line, scheme requirements (ftp:// rejected, \b word boundary required), case insensitivity.
  6. Webview command panel-id parity (I1) ✅ — All webview.* commands (navigate / back / forward / reload / execute_js / get_content / devtools / state) now resolve via params["id"] first and fall back to active webview when absent (lenient default per plan; Linux requires id). Errors use a new RPCError sentinel that SocketServer.dispatch detects and maps to a proper JSON-RPC error envelope ({ok:false, error:{code, message}}) — codes match Linux: not_found / wrong_panel_type / invalid_params / no_active_webview. Two wire-protocol fixes landed alongside: execute_js reads code (Linux + copad-cli convention) with script as back-compat fallback; devtools accepts action: show/close/attach/detach/toggle (macOS WKWebView has no public inspector-open API, so all enable-style verbs map to the existing developerExtrasEnabled toggle and close is a no-op — protocol parity without functional parity for the inspector window). Success results standardized to {status: "ok"} matching webview.rs.

Codex round-2 reordering (plugin parity)

Round-2 codex pressure-test (against the post-Tier-0/1 working tree) ruled that Tier 2→3 is the wrong order if the goal is "working KB/Slack/Todo fastest"TriggerEngine only calls a TriggerSink, so without registered plugin actions a trigger spike still leaves every user-facing plugin command returning unknown_method. New 5-PR sequence:

  1. PR 1: FFI build/link spike only ✅ — copad-ffi staticlib (copad_ffi_version + copad_ffi_call_json + copad_ffi_free_string + copad_ffi_last_error), CCopadFFI clang module wrapper, SwiftPM linker settings, build-script wrappers, smoke test in applicationDidFinishLaunching confirming JSON round-trip with Rust-generated timestamp. arm64-only, cargo-built statically. No engine, no plugins — just proves the boundary works. Surface kept tiny on purpose.

  2. PR 2: macOS registry-first dispatch seam ✅ — ActionRegistry.swift (@MainActor, minimal surface: register / tryDispatch / has / names / count) mirrors Linux's try_dispatch bool-return semantic so callers compose the same way. AppDelegate.handleCommand checks actionRegistry.tryDispatch(...) first; on false (not registered) it falls through to the legacy hardcoded switch — no behavior change for existing methods. system.ffi_test and system.list_actions registered on launch as proof-of-seam: coctl call system.ffi_test --params '{...}' walks coctl → unix socket → SocketServer.dispatch → main thread → handleCommand → ActionRegistry.tryDispatch → CopadFFI.callJSON → Rust extern "C" round-trip → back through every layer; system.list_actions returns {count, names} for diagnostics. Unknown-method path still returns {ok:false, error:{code:"unknown_method", message:"unknown: <method>"}} via the same RPCError envelope wired in Tier 1.6. Deferred from Linux's full surface: register_silent (no completion bus on macOS yet — every registered action is effectively silent today), register_blocking (codex round-2 flagged that macOS's socket-thread-blocking model conflicts with Linux's worker-thread spawn — the trigger engine in PR 5 will redesign the async boundary holistically before re-adding), invoke / try_invoke sync paths (only the trigger engine needs them).

  3. PR 3: one service plugin host before triggers ✅ — PluginManifest.swift decodes plugin.toml with decodeIfPresent-based optional defaults (TOMLKit doesn't honor var x: T = default syntax — that's a Swift init feature, not a Decodable feature, so we mirror serde's #[serde(default)] explicitly). PluginSupervisor.swift discovers ~/Library/Application Support/copad/plugins/<name>/ first then ~/.config/copad/plugins/<name>/ (dotfile-sharing fallback) and spawns every service with activation = "onStartup" via Process + 3 Pipes. Each PluginProcess runs init handshake ({id, method: "initialize", params: {protocol_version: 1}} → wait for response with manifest-vs-runtime provides[] subset validation → send initialized notification), registers each provided action through the same ActionRegistry from PR 2, routes action.invoke over stdio with id-keyed pending dict, and sends shutdown notification on applicationWillTerminate. install-macos.sh --no-plugins opt-out flag added; default builds + installs copad-plugin-echo to the macOS plugin dir. End-to-end verified (coctl call echo.ping --params '{...}' round-trips through registry → supervisor → plugin process → back, with sleep_ms blocking honored, plugin process auto-cleaned on parent quit, and system.list_actions showing echo.ping alongside the PR 2 builtins). Critical bug found + fixed during verification: initial implementation always bounced reader-thread completions back to main actor via DispatchQueue.main.async, which deadlocked the init handshake (main thread parked on initBox.semaphore.wait() would never wake). Fixed by firing completions inline on the reader thread — both init resolver and SocketServer leaf completions just signal a semaphore and tolerate any thread, and any future completion needing main-actor work can hop inside its own body. Deferred from Linux supervisor surface: activation gating beyond onStartup (PR 5 with trigger engine), restart-on-crash policy, event subscription / event.dispatch outbound, inbound event.publish → EventBus forwarding, provides[] cross-plugin conflict resolution, process-group ownership + signal handlers + crash-recovery PID file (codex I5), KB/Todo/Bookmark plugins (won't compile on macOS until atomic-create primitives are replaced).

  4. PR 4: port one real direct-action plugin ✅ — copad-plugin-git (no Linux-only deps, just argv shell-out to git binary). Compiles clean on macOS first try. Added to MACOS_PLUGINS in install-macos.sh. PluginSupervisor extended: onAction:<glob> now spawns eagerly with a "lazy not yet implemented" log (real lazy activation needs deferred-spawn placeholder handlers — left for PR 5 with the trigger engine). onEvent:<glob> continues to skip with log because eager spawn for event-driven plugins burns resources for nothing. End-to-end verified with a synthetic ~/.config/copad/workspaces.toml pointing at the copad repo: all 6 git actions (list_workspaces/list_worktrees/worktree_add/worktree_remove/current_branch/status) return correct results, and coctl git workspaces / coctl git status (CLI wrapper with cwd-derived workspace defaulting) produce identical UX to Linux. KB/Todo/Bookmark still blocked on atomic-file primitive replacement (codex finding); Slack/Calendar/LLM/Discord still blocked on Keychain UX verification.

  5. PR 5a: Keychain spike via llm plugin ✅ — added copad-plugin-llm to MACOS_PLUGINS because it's the cheapest of the four keyring-using plugins (llm.auth_status reads keyring entry without any HTTP call). Verified read path: apple-native feature successfully bound to macOS Keychain (store_kind: "keyring" reported, plaintext fallback did NOT fire), keyring::Error::NoEntry translated to clean credentials_source: none instead of crashing. Slack/Calendar/Discord share the exact same keyring code path so they're unblocked at the cargo-build / supervisor-init layer; landing them is now blocked only on the per-plugin auth UX (Slack Socket Mode setup, Calendar OAuth, Discord Gateway), not on Keychain itself.

  6. PR 5b: enable Calendar plugin ✅ — added copad-plugin-calendar to MACOS_PLUGINS. Validates the polling-daemon supervisor lifecycle on macOS (different from llm's pure-RPC mode — calendar spawns a background poller thread that publishes lead-time calendar.event_imminent events on schedule). RPC actions still served gracefully without Google OAuth credentials thanks to the plugin's Config::minimal() fallback: calendar.auth_status returns {configured: false, authenticated: false, store_kind: "keyring"}, calendar.list_events returns a clean not_authenticated error envelope with the missing-env-var name in the message. Confirms (a) supervisor handshake works for plugins that need env vars they don't have, (b) keyring code path identical to llm, (c) all 3 calendar.* actions register through the registry. Google OAuth device-code flow (copad-plugin-calendar auth with COPAD_CALENDAR_CLIENT_ID/SECRET) untested — same write-path keyring caveat as llm. Slack and Discord share the same keyring + supervisor pattern; their PRs are now blocked only on Socket Mode token setup (Slack) and Gateway auth (Discord), not on macOS plumbing.

  7. PR 5c: TriggerEngine via FFI ✅ — copad-ffi grew an engine surface (5 new extern "C" symbols + an ActionCallback typedef) wrapping copad_core::trigger::TriggerEngine. Engine + condition DSL + await preflight semantics live in Rust (single source of truth across Linux + macOS); macOS just owns the C-ABI bridge. Swift side: CopadEngine class wraps the opaque handle, registers a @convention(c) trampoline that hops to main actor before touching ActionRegistry, JSON-encodes triggers + events for the boundary. EventBus.onBroadcast closure fans every broadcast (including plugin event.publish forwards) into CopadEngine.dispatchEvent. PluginSupervisor.handleLine now translates event.publish notifications into eventBus.broadcast calls. [[triggers]] decoded from config.toml via TOMLKit TOMLTable opaque walk into [[String: Any]] (no specific Swift types — the Rust serde Deserialize is canonical), passed to engine on launch + reapplied on hot-reload. End-to-end verified: echo plugin's system.heartbeat (env COPAD_ECHO_HEARTBEAT_SECS=2) → stdio reader → eventBus.broadcast → onBroadcast → copad_engine_dispatch_event → engine match → C callback → Task { @MainActor in registry.tryDispatch }system.ffi_test handler → CopadFFI.callJSON → Rust round-trip → completion. Trigger params ({source: "trigger", note: "fired by heartbeat"}) flow through the boundary cleanly. Critical bug found + fixed during verification: initially marked CopadEngine as @MainActor, which would have forced every plugin event through main actor; switched to @unchecked Sendable + nonisolated(unsafe) for the actionRegistry slot since the Rust engine handles concurrency internally via RwLock. Deferred from full Linux semantics (engine supports them but spike doesn't exercise): await/preflight chains, <trigger>.completed/<trigger>.failed fan-out, covering_patterns dedup of overlapping globs (engine has it, no test config triggers it yet). Real lazy onAction:<glob> activation also still TODO — that's a supervisor-side change, separate PR.

  8. PR 6: KB/Todo/Bookmark macOS unblock + Slack/Discord install ✅ — extracted the atomic-create-or-fail rename primitive into copad_core::fs_atomic::rename_no_replace (Linux: renameat2(RENAME_NOREPLACE); macOS: renamex_np(RENAME_EXCL)). Three plugins that previously hard-failed cargo build on Darwin via compile_error!(target_os = "linux") (copad-plugin-kb, copad-plugin-todo, copad-plugin-bookmark) now route their atomic creates through the shared helper and gate to any(linux, macos). Drops three duplicated copies of the inline unsafe { libc::renameat2(...) } + path_to_cstring helper in favor of a single tested module (2 new unit tests in fs_atomic for the create + EEXIST paths, plus all 107 plugin-side unit tests still pass on macOS). MACOS_PLUGINS in install-macos.sh now includes all 9 first-party plugins (echo, git, llm, calendar, kb, todo, bookmark, slack, discord) — slack/discord install fine but RPC actions return not_authenticated until the user runs the plugin's auth subcommand to populate Keychain (same UX gate as Linux). Pre-existing manifest bug fixed in passing: plugins/todo/plugin.toml provides[] was missing todo.update (advertised at runtime by copad-plugin-todo), causing the supervisor's runtime-vs-manifest subset check to reject the whole plugin on first install. Added the entry. End-to-end verified on macOS: coctl call kb.search returns 91 fulltext hits over ~/docs/; todo.create writes a markdown file via renamex_np and todo.list reflects it; bookmark.add first-attempt returns existed: false, second-attempt returns existed: true (no-replace dedup path); slack.auth_status / discord.auth_status / llm.auth_status all report store_kind: "keyring" confirming keyring apple-native reaches Apple Keychain for write-bearing plugins too. Deferred: Slack Socket Mode token UX (one-time setup at slack.com/apps), Discord Gateway bot token (developer portal), KB FTS5 phase 13 (cross-platform). Full write-path Keychain prompt still untested (needs real credentials).

  9. PR 7: ActionRegistry completion fan-out ✅ — closes the macOS-side hole that broke chained workflows like vision-flow-3.toml (todo.start_requested → git.worktree_add → claude.start). ActionRegistry.tryDispatch now wraps the supplied completion closure: on success it broadcasts <method>.completed on the EventBus with the action's result payload (or ["value": result] if not a dict — keeps {event.X} interpolation working for non-object returns), on RPCError it broadcasts <method>.failed with {code, message}. Wrapped before forwarding to the original completion so a chained trigger observing .completed runs in the same logical tick as the originating action's response. Mirrors Linux's with_completion_bus invariant (copad-core/src/action_registry.rs): only registered actions emit; legacy match-arm dispatch in AppDelegate.handleCommand (webview.*, terminal.*, tab.*, etc.) bypasses the registry and stays silent — bringing those into the registry is a separate parity item. New registerSilent(_:handler:) variant suppresses fan-out for high-frequency introspection actions; applied to system.ffi_test and system.list_actions so debug round-trips and tooling polls don't flood the bus. Source-stamp trust boundary (cross-review CRITICAL fix): Linux's try_promote_or_drop_preflight (copad-core/src/trigger.rs:482) gates await-chain promotion on event.source == COMPLETION_EVENT_SOURCE ("copad.action"); without that gate, an unrelated bus event whose kind happens to end in .completed could silently advance an await state machine. macOS's FFI dispatch previously hardcoded "macos.eventbus" for every event, so registry-synthesized completions arriving via EventBus.broadcast → onBroadcast → CopadEngine.dispatchEvent → copad_engine_dispatch_event would have failed the trust check and stalled [triggers.await] chains entirely. Fix: extended copad_engine_dispatch_event C signature to accept a source arg, plumbed through EventBus.broadcast(event:source:data:) (default "macos.eventbus" for back-compat) → EventBus.onBroadcast 3-tuple closure → CopadEngine.dispatchEvent(kind:source:payload:) → FFI. ActionRegistry.publishCompletion passes "copad.action" (mirrored as ActionRegistry.completionEventSource constant — kept in sync with the Rust constant by hand). Codex pressure-test in plan stage (Round 1+2) ruled out two architectural alternatives: (A) FFI-side fan-out via a separate copad_engine_publish_completion C symbol — wrong ownership boundary because completion events are bus-events, not engine-private; (C) hybrid where PluginSupervisor.handleLine ALSO broadcasts on plugin replies — would double-fire for plugin actions because PluginSupervisor.swift:105 already registers each provides[] entry through tryDispatch. Option B (registry chokepoint) wins because plugin RPC replies funnel through tryDispatch for free. End-to-end verified: trigger pr7-completed-chain listens on echo.ping.completed → fires system.list_actions; pr7-failed-chain listens on kb.read.failed. Confirmed [copad-engine] event echo.ping.completed fired 1 trigger(s) after coctl call echo.ping, and [copad-engine] event kb.read.failed fired 1 trigger(s) after coctl call kb.read --params '{"id":"missing"}'. The await-promotion path is verifiable from code inspection (source flows Swift → FFI → Event::new(kind, source_str, payload) → trust check passes); a live [triggers.await] chain test wasn't authored — Vision Flow 3 doesn't itself use await, the await-bearing surface is reserved for Phase 14.2 chained workflows. Deferred: register_blocking (still needs the async-boundary redesign from parity-plan note); legacy switch-arm migration into the registry (parity item, not Vision Flow 3 critical path).

  10. PR 9: ContextService + FFI Context plumbing ✅ — closes parity-plan Tier 2.2. Linux's trigger interpolation supports {context.active_panel} and {context.active_cwd}; macOS previously dispatched every event with None context (copad-ffi/src/lib.rs:443), so any trigger condition or interpolation that touched context.X resolved to null (conditions) or stayed literal ({context.X} interpolation tokens). PR 9 lands the missing pieces. Codex plan pressure-test caught 6 design issues before implementation — most importantly: (a) {context.cwd} doesn't exist in core; only active_panel + active_cwd are supported. (b) Wire shape is {active_panel, active_cwd} — anything wider (e.g. panels: [...]) creates platform drift. (c) An ordering bug: if ContextService were itself an EventBus subscriber, macOS's onBroadcast (which fires before channel fan-out) would dispatch the trigger BEFORE ContextService got the event, so {context.active_panel} for a panel.focused trigger would resolve to the previous panel. Fix: apply-before-dispatch in the broadcast hook itself, mirroring Linux's Pump::pump_all (copad-linux/src/window.rs:589). (d) Forgot panel.exited — clears active panel + cwd cache when the panel dies. (e) FFI signature change is ABI-safe because copad-ffi is rebuilt with the Swift app every install (private staticlib, no third-party consumers — same posture as PR 7). (f) No timer needed: Linux's 100ms GTK timer drains bounded bus channels into ContextService, but macOS's onBroadcast fires synchronously per event, so polling is pure cost. Shipped: ContextService.swift with apply(eventKind:, data:) mirroring Linux's three event handlers; copad_engine_dispatch_event C signature gained a context_json arg (NULL → no context, engine handles gracefully); Swift CopadEngine.dispatchEvent(kind:source:context:payload:) + EventBus.onBroadcast 3-tuple closure plumbed through; AppDelegate wires contextService.apply first, then dispatchEvent with the post-apply snapshot. context.snapshot registered as a silent action (mirrors system.list_actions posture — pollable without bus flood). End-to-end verified on macOS: all 3 scenarios passed live (initial empty snapshot; pane.focus_next → trigger fires echo.ping with interpolated context, echo's reply payload contains the JUST-FOCUSED UUID + cwd, sequential focuses produce distinct UUIDs proving the ordering; terminal.exec exit on an active pane → panel.exited correctly transitions the active slot to a different UUID).

  11. PR 8: claude.start macOS port (Phase 18) ✅ — Linux's socket.rs::handle_claude_start (1411-1579) ported to Swift as ClaudeStart.dispatch (copad-macos/Sources/Copad/ClaudeStart.swift), with all the support helpers (shell_single_quote, derive_session_name, sanitize_session_name, validate_tmux_session_name, spawn_claude_prompt_seeder) ported 1:1. Param contract identical: workspace_path required + canonicalized, session_name optional (derived from path's last 1-2 components when absent), resume_session optional (control-char rejected, single-quote-escaped), prompt optional (mutually exclusive with resume_session). Tab spawn goes through new TabViewController.newTerminalTab(cwd:initialInput:)PaneManager(initialPanel: .terminalSeed(...))TerminalViewController(cwd:initialInput:) → SwiftTerm's startProcess(currentDirectory:) (native cwd support — no posix_spawn dance) + immediate terminalView.send(txt: tmuxCommand). Race-safe because PTY input is kernel-buffered: bytes wait until the child shell reads them. Prompt seeder is a background DispatchQueue.global(.userInitiated) thread that polls tmux capture-pane for claude markers (Anthropic / Try " / claude -- / "claude code"), cross-checks display-message #{pane_current_command} returns claude or node, and only then runs tmux load-buffer + paste-buffer + send-keys Enter Enter. Either readiness check failing → stderr log + return — claude.start already responded success at this point; prompt is post-action best-effort. Guards against shell injection: pasting into a pre-existing tmux session that happens to share the name but runs a shell would otherwise execute the prompt as raw shell input. End-to-end verified on macOS: (1) all 5 validation paths return correct RPCError (missing workspace_path → invalid_params; bad path → not_found; file-not-dir → invalid_params; prompt+resume conflict → invalid_params; bad session_name → invalid_params with specific message); (2) happy path with prompt → tab spawned in /tmp/copad-claude-test, tmux session tmp-copad-claude-test created, claude attached, seeder pasted "Hello from PR 8 — tell me what 1+1 equals in two words.", claude responded "Two." in the pane; (3) refuse-to-paste guard fires correctly — claude.start targeting an existing tmux new-session -d -s shell-only "sleep 60" session logs [claude.start] refusing to paste prompt into session "shell-only": saw_claude_marker=false, pane_current_command="sleep" and leaves the shell pane untouched. Vision Flow 3 macOS critical path is now end-to-end traversable — combined with PR 7's completion fan-out, todo.start_requested → git.worktree_add → git.worktree_add.completed → claude.start chains as designed.

Vision Flow 3 e2e harness validation (post-PR 8 follow-up) — drove the full chain through real plugins on macOS using examples/triggers/vision-flow-3.toml verbatim, against a sandbox repo + ~/docs/claude/global.md + ~/docs/claude/workspaces/flow3.md + a todo with optional linked_jira. Three scenarios exercised: (1) without linked_jira → trigger 2 fired with branch = "todo-{event.id}", worktree at /tmp/copad-flow3-test-worktrees/todo-T-…, claude REPL booted, layered prompt (global + workspace + todo body) pasted, claude responded "ACK" exactly per the global preamble; (2) with linked_jira = "PROJ-456" → trigger 1 fired with sanitize_jira = true, branch became proj-456, full chain landed at /tmp/copad-flow3-test-worktrees/proj-456, same paste behavior; (3) re-click on the same todo → engine fired both events twice, but git's idempotent path-scan kept worktrees at one entry, tmux's new-session -A re-attached the existing session, and the prompt re-pasted as a new turn in the same conversation (preserves prior context). One bug found + fixed during the harness: macOS's seeder originally called tmux paste-buffer -d without -p, so multi-line assembled_prompt arrived at claude as N separate user turns (each line collapsed to its own Enter). Linux's path works due to environmental quirks (default tmux behavior + VTE terminal forwarding bracketed-paste) but the explicit fix is paste-buffer -p -d so tmux wraps the paste in ESC [ 200 ~ … ESC [ 201 ~. Documented in ClaudeStart.swift so the divergence from Linux's flag set isn't surprising. Codex cross-review caught a CRITICAL chain-reachability bug in v1: claude.start was initially wired only as a legacy switch-arm in AppDelegate.handleCommand, but the macOS trigger engine's C callback dispatches exclusively via ActionRegistry.tryDispatch — there's no fallthrough to the switch-arm from the trigger path (only socket dispatch has that fallthrough). So coctl call claude.start worked, but *.completed → claude.start chains would have stalled at tryDispatch returning false. Fix: registered claude.start through actionRegistry.register in registerBuiltinActions; dropped the redundant switch-arm. Re-verified end-to-end after the fix: synthetic chain echo.ping → echo.ping.completed → claude.start { workspace_path: "/tmp/copad-claude-test" } triggered via coctl call echo.ping produces [copad-engine] event echo.ping.completed fired 1 trigger(s) plus a new tab + tmux session + claude attached — the exact mechanism Vision Flow 3 uses. Deferred: prompt-seeded claude.start from inside the full vision-flow-3.toml chain (with assemble_prompt from copad-plugin-todo) hasn't been driven through the trigger engine yet (Phase 14.2 chained workflow tracker); separate PR. tmux being installed at runtime is required (we shell out to /usr/bin/env tmux); not bundled — same posture as Linux.

Open spike questions still owed: SwiftPM cargo-prebuild robustness (PR 1 settled this with the wrapper-script approach — see Tier 2.1 below), universal-binary policy, Gatekeeper/quarantine on plugin binaries, macOS-safe atomic-create replacement for KB/Todo/Bookmark.

keyring Keychain spike — settled (PR 5a). Added copad-plugin-llm to MACOS_PLUGINS because it has the cheapest Keychain probe of the four keyring-using plugins (llm.auth_status reads the keyring entry without making any HTTP call). End-to-end result with no credential stored: {store_kind: "keyring", credentials_source: "none", authenticated: false} — confirms the apple-native feature successfully bound to macOS Keychain at runtime, the plaintext-file fallback did NOT fire (which would have been the codex-feared regression), and keyring::Error::NoEntry was returned and translated into a clean none source instead of crashing. Plugin's stderr corroborated: [llm] token store: keyring (env key: empty — falls back to store). Caveat: this only validated the READ path. The first WRITE (via copad-plugin-llm auth with ANTHROPIC_API_KEY set) is what would trigger a real Keychain user prompt on macOS, and would also exercise keyring::Entry::set_password. That path is unverified for now because it requires a real Anthropic API key — slack/calendar/discord all share the same apple-native keyring code path so when their auth flows land we'll exercise the write side then. The fact that the read path landed clean is the load-bearing finding; write path is conventional Apple Keychain interaction beyond that.

Tier 2 — Wire copad-core engines on macOS

  • 2.1 FFI scaffolding ✅ (PR 1) — copad-ffi crate (crate-type = ["staticlib"], depends only on serde_json) lives at workspace root, exposes 4 extern "C" symbols documented in copad-ffi/src/lib.rs. Built via cargo build --release -p copad-ffitarget/release/libcopad_ffi.a. SwiftPM consumes through a CCopadFFI C target (Sources/CCopadFFI/include/{copad_ffi.h,module.modulemap} + a dummy.c so SPM emits an object file that carries the linker settings); the Copad executable target adds -L../target/release -lcopad_ffi via linkerSettings. The cargo step cannot be invoked from Package.swift directly in SwiftPM 6.0, so scripts/install-macos.sh and copad-macos/run.sh both run cargo build --release -p copad-ffi before swift build. arm64 only for now (host-arch cargo default); universal-binary recipe is cargo build --target aarch64-apple-darwin && cargo build --target x86_64-apple-darwin && lipo -create … -output …, deferred until there's a real x86_64 user. Smoke test in AppDelegate.applicationDidFinishLaunching calls copad_ffi_version() and copad_ffi_call_json(), prints to stderr — verified end-to-end ([copad-ffi] echo round-trip = ["spike": 1, "hello": from swift, "echoed_at": <unix-ms>]).
  • 2.2 ContextService (Swift native) ✅ (PR 9) — active_panel, active_cwd, per-panel cwd cache. Codex pressure-test ruled out the original "polled timer" plan: macOS EventBus.onBroadcast already fires synchronously per event, so the apply-before-dispatch ordering can be satisfied without a timer (Linux's 100ms GTK timer exists because Linux drains bounded bus channels into ContextService — that's a Linux constraint, not a portable design). Implementation: ContextService.swift (@unchecked Sendable + internal NSLock) mirrors copad-core::context::ContextService apply rules verbatim (panel.focused → set active panel; panel.exited → clear cwd cache + null active panel if matching; terminal.cwd_changed → cache cwd by panel id). AppDelegate.eventBus.onBroadcast calls contextService.apply(eventKind:, data:) BEFORE copadEngine.dispatchEvent(...), then passes contextService.snapshot() through FFI as context_json. Matches Linux's Pump::pump_all ordering (copad-linux/src/window.rs:589). FFI surface extended: copad_engine_dispatch_event gained a 5th context_json arg (NULL → engine.dispatch(&event, None) for back-compat); inside FFI, Context::deserialize then engine.dispatch(&event, Some(&ctx)). context.snapshot socket command added (silent, mirrors Linux), returns {active_panel, active_cwd}. End-to-end verified on macOS: (1) context.snapshot before any focus returns {} (both fields nil → omitted from dict, matching Linux serde nullable shape); (2) trigger with params = { panel = "{context.active_panel}", cwd = "{context.active_cwd}" } on panel.focused → echo.ping (chained via PR 7's .completed) echoed back the JUST-FOCUSED UUID + cwd, proving apply-before-dispatch ordering; sequential pane.focus_next calls produced different UUIDs each time; (3) after sending exit to an active pane's PTY → context.snapshot.active_panel transitioned to a different UUID, confirming panel.exited correctly clears the dead panel from cache and nulls the active slot.
  • 2.3 ActionRegistry (Swift native, thin) ✅ (PR 2 + PR 7) — PR 2 shipped the dispatch seam (register / tryDispatch / has / names); PR 7 added completion fan-out (<method>.completed / .failed broadcast through EventBus on dispatch) plus registerSilent for introspection actions, closing the chain that examples/triggers/vision-flow-3.toml requires. register_blocking still deferred — needs the async-boundary redesign because macOS's socket dispatch already pins the socket thread on a DispatchSemaphore.
  • 2.4 TriggerEngine via FFI ✅ (PR 5c) — see PR 5c description above for shipped surface. Threading note from the original plan adjusted: instead of a single serial DispatchQueue owning the handle, we let the Rust engine's internal RwLock arbitrate. dispatchEvent is callable from any thread (plugin reader thread fires it directly), C action callback hops back to main actor only at the ActionRegistry boundary. Per-event main-actor hop avoided.

Tier 3 — Service plugin host on macOS

  • 3.1 Supervisor (PR 3 ✅, native Swift for now) — codex round-2 left the Rust-crate-FFI vs native-Swift question open; PR 3 went with native Swift because (a) PR 1's FFI proved JSON crosses cleanly but Rust→Swift callbacks (which the supervisor would need for plugin event forwarding) are still unproven, (b) the supervisor surface for echo (one plugin, one method) is small enough that Swift didn't fight us, (c) drift risk is bounded — if/when we extract a copad-supervisor crate later, the contract (initialize / initialized / action.invoke / event.publish / shutdown) is the same. Reusing the Linux 7 unit tests would require porting them to a Swift test harness anyway, which we can do separately. Re-evaluate at PR 4 (real direct-action plugin) — if Slack/Calendar-class plugins force more behavioral parity, switch to FFI then.
  • 3.1a Real lazy onAction:<glob> activation ✅ — LazyEntry state machine (notStarted/spawning/ready/failed) replaces PR 4's eager-spawn workaround. discoverAndStart registers placeholder ActionRegistry handlers per provides[] without spawning the plugin; first call triggers LazyEntry.ensure which kicks PluginProcess.spawn off-main on DispatchQueue.global(.userInitiated) and serializes concurrent first-callers behind the entry lock. Verified: launch with all 4 plugins installed → ps shows only echo + calendar (onStartup) running, NO git/llm; first coctl call git.list_workspaces triggers spawn (logged [copad-plugin] git/main: lazy spawn (first call)), second call returns instantly with no spawn overhead; shutdown clean. onEvent:<glob> still skipped (eager would defeat the purpose; needs trigger engine to drive spawn-on-event match).
  • 3.2 Process-group ownership + shutdown matrix (I5) ✅ — pre_exec(setpgid(0, 0)) on macOS puts each plugin in its own process group (Linux already had PDEATHSIG, which the kernel honors even through SIGKILL-of-parent). At spawn the daemon writes the pid (== pgid) to ~/Library/Application Support/copad/plugin-pids/<plugin>.<service>.pid. shutdown_all extension: on macOS, after the cooperative-shutdown wait, send SIGTERM to each pgid (catches grandchildren), 200ms grace, then SIGKILL to the pgid and cleanup the pid file. At next-launch startup sweep_orphan_pids walks the pid-files dir, probes each entry with kill(-pgid, 0), and if alive sends SIGTERM/SIGKILL (orphan from a prior SIGKILL'd daemon — by definition not ours, since this runs before any new spawn). Two unit tests cover (1) dead-pgid + malformed-content + non-.pid filename branches, and (2) the live-orphan kill path with a real sleep subprocess spawned in its own pgroup.
  • 3.3 Plugin platform gates ✅ (PR 6) — KB/Todo/Bookmark were blocked by renameat2(RENAME_NOREPLACE); PR 6 extracted copad_core::fs_atomic::rename_no_replace (Linux renameat2 / macOS renamex_np(RENAME_EXCL)), wired all three plugins through it, and re-gated each crate from target_os = "linux" to any(linux, macos). O_NOFOLLOW and OsStrExt were never actually Linux-only — both compile on macOS unchanged. All 9 first-party plugins now build + install on Darwin.
  • 3.4 Plugin discovery ✅ (PR 3) — PluginManifestStore.discover() walks ~/Library/Application Support/copad/plugins/ first (macOS-native), then ~/.config/copad/plugins/ (XDG-compat for dotfile-sharing users); duplicates resolved by macOS-root precedence. Created lazily by the installer.
  • 3.5 Plugin install on macOS ✅ — folded into install-macos.sh rather than a separate install-plugins-macos.sh. New --no-plugins flag opts out. MACOS_PLUGINS reached the full first-party set (echo git llm calendar kb todo bookmark slack discord) at PR 6 once fs_atomic unblocked KB/Todo/Bookmark. Code-signing punted — locally-built binaries don't carry quarantine xattr so Gatekeeper doesn't block them. Slack/Discord install but require user-supplied tokens via plugin auth subcommands before RPC actions function.

Tier 4 — Plugin panels + status bar + remaining socket commands

  • 4.1 Plugin panel ✅ — PluginPanelController : NSViewController, CopadPanel wraps WKWebView. WKUserScript injected at .atDocumentStart builds window.copad = { panel: {id, name, plugin}, async call(method, params), on(type, cb), off(type, cb), _handleEvent(type, data) } byte-for-byte matching Linux's plugin_panel.rs:74 build_bridge_js. Bridge transport: WKScriptMessageHandlerWithReply (macOS 11+, well within our macOS 14 minimum) for the copad channel; each postMessage round-trips via BridgeHandlerActionRegistry.tryDispatch → reply JSON. Event delivery: subscribe to EventBus, forward each event to the webview as copad._handleEvent(type, data) via evaluateJavaScript from a background thread (hopping to main for the WKWebView call). Panel HTML loads via loadFileURL(allowingReadAccessTo: pluginDir) so relative CSS/JS/image siblings resolve. New socket command plugin.open (params: name, panel?="main", mode?="tab") discovers the plugin manifest, finds the matching [[panels]] def, instantiates the controller, and routes via TabViewController.newPluginPanelTab / splitActivePaneWithPluginPanel. Returns {status, panel_id}. End-to-end verified with a stub panel-test plugin: plugin.open returns id; tab.list shows the panel as active tab with the manifest title; the panel's JS issued three window.copad.call(...) invocations (system.ping, system.list_actions, echo.ping) which all reached handleBridgeMessage (confirmed via stderr instrumentation) and returned via the registered handlers — the echo.ping path even crossed back into a real plugin process. Error envelopes not_found for unknown plugin name and unknown panel name. Theme CSS injection ✅ — buildThemeCSS(theme:) builds the same --copad-* variable set Linux ships via build_theme_css (bg, fg, surface0..2, overlay0, text, subtext0..1, accent, red) and injects it through a WKUserScript at .atDocumentStart so panel.html's var(--copad-bg) etc. resolve from the very first paint. Identical contract; keep the two build_theme_css / buildThemeCSS blocks in sync if either side evolves. Deferred: per-panel WKWebView.takeSnapshot for plugin-specific screenshot endpoints (covered by generic webview.screenshot only after we let it target plugin panels — currently it resolves only WebViewController instances).
  • 4.2 Status bar ✅ — Waybar-style 3-zone bar at the bottom of the window. StatusBarView (NSStackView left/center/right zones) + StatusModuleRunner (per-module DispatchSourceTimer running sh -c <exec> on a serial queue, hopping to main only for label updates). Modules come from plugin manifests' [[modules]] (PR Tier 4.1 already added the manifest section but not the runner). Output protocol mirrors Linux parse_output: try JSON {text, tooltip} first, fall back to plain text; trimming + accidental-bracket detection identical. [statusbar] config controls enabled (default true), position (top or bottom, default bottom), height (default 28). statusbar.show/hide/toggle socket commands return {visible: bool} (Linux contract). Per-module CSS classes (module.class on Linux) ignored — module authors that want color cues should emit unicode markers or JSON-styled text instead. End-to-end verified with a stub sb-test plugin: 3 modules across all zones, 2-second date clock ticking on schedule (22:14:45 → 47 → 49), JSON path producing text="json-ok" + tooltip, plain text path producing * copad, plugin.list surfacing the module defs, all 3 visibility commands cycling consistently. top position ✅ — TabViewController.loadView now resolves topEdge / bottomEdge anchors from config.statusBar.position. When "top", the bar pins to root.topAnchor and the tab bar / content area anchor against bar.bottomAnchor; when "bottom" (default), the inverse. tabsPosition (top/bottom) composes orthogonally so all 4 combos work. Theme hot-reload ✅ — applyTheme(_:) updates bar bg, separator, and every module label's text color. Already wired through applyConfigapplyWindowOpacity (which now delegates to applyTheme), so any config-reload (theme.set, opacity change, etc.) refreshes the bar without restart. Deferred: per-module CSS classes (no clean macOS equivalent without per-module NSAttributedString).
  • 4.3 Remaining socket commands ✅ (mostly) — webview interaction (query / query_all / get_styles / click / fill / scroll / page_info) ports the JS snippets from copad-linux/src/webview.rs::js verbatim into a Swift WebViewJS enum; each handler resolves the target webview via the existing PR 1.6 resolveWebView helper, runs the snippet through WKWebView.evaluateJavaScript, parses the JSON-stringified result into Any so the wire shape matches Linux byte-for-byte. webview.screenshot uses WKWebView.takeSnapshot + NSBitmapImageRep to produce a base64 PNG ({image_b64, width, height}). theme.list returns the 10 hardcoded CopadTheme.byName arms + current theme name. plugin.list walks PluginManifestStore.discover() and surfaces each plugin's {name, title, version, description, services: [{name, exec, activation, provides, subscribes}]}. End-to-end verified against https://example.com: page_info reports links: 1, title: "Example Domain"; query("h1") returns rect + visible; query_all("a", 3) returns the IANA link with index/href/rect; get_styles("h1", ["color","font-size"]) returns {color: "rgb(0, 0, 0)", font-size: "24px"}; click("a") returns {ok: true}; screenshot returns 1200×732 PNG base64. Still missing: plugin.open (depends on Tier 4.1 panel host), plugin.<service> generic dispatcher (already works for free via ActionRegistry.tryDispatch since plugins register their provides[] there directly — no extra method-prefix routing needed on macOS), claude.start (depends on agent integration), statusbar.show/hide/toggle (Tier 4.2).

Tier 5 — Cross-platform roadmap items (out-of-parity-scope)

Session persistence, command palette, KB FTS5, deferred LLM/Slack/Discord phases — neither platform has them; not parity items.


Codex's answers to the 6 architectural questions (paraphrased, kept)

  1. Engine split — Don't reimplement TriggerEngine in Swift; ContextService and a thin action-registration layer can be native, but trigger semantics MUST come from Rust FFI or the macOS version drifts immediately on await/completion behavior. Adopted in v2 plan.
  2. Supervisor lifecycle — Hook applicationShouldTerminate + applicationWillTerminate + window close + signal handlers; for SIGKILL the only mitigation is process-group ownership + PID-file crash-recovery sweep at next launch. Adopted in I5 / Tier 3.2.
  3. MainActor safety — Don't put the engine itself behind @MainActor. Small main-thread adapter snapshots UI context, forwards serial events into an isolated engine queue. Adopted in Tier 2.4.
  4. OSC 52 trust model — Won't ship via a LocalProcessTerminalViewDelegate method (delegate path doesn't exist for clipboard); requires replacing/proxying terminalDelegate or forking SwiftTerm. Adopted as Tier 0.3 blocker.
  5. Plugin bridge — Use WKScriptMessageHandlerWithReply (available on our minimum target). Adopted in C3 / Tier 4.1.
  6. Phasing — Land Tier 0 + Tier 1 (architecture-orthogonal). For Tier 2, do a small Rust-FFI spike (one awaited trigger end-to-end) BEFORE committing to wider FFI surface. Adopted; Tier 2.1+2.4 explicitly carry the spike-first ordering.

Out of scope (explicit)

  • terminal.output PTY interception (SwiftTerm public-API limit).
  • D-Bus integration (Linux-only; macOS uses Unix socket only — already correct).
  • Tabs position left/right (low ROI; defer).
  • Wayland/GTK-specific behaviors that don't translate.