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.
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-methodidparameter for webview commands is required by Linux but ignored by macOS (active-panel only). Parity = adopt panelidresolution on macOS. - Service plugin host:
copad-linux/src/service_supervisor.rs(1794 LOC). macOS now has a native-SwiftPluginSupervisor(PR 3 / PR 6); all 9 first-party plugins build + install on Darwin. KB/Todo/Bookmark formerly gatedtarget_os = "linux"forrenameat2; PR 6 routed the atomic-create-or-fail rename throughcopad_core::fs_atomic(Linuxrenameat2(RENAME_NOREPLACE)/ macOSrenamex_np(RENAME_EXCL)) and dropped the gate toany(linux, macos). - Trigger engine + Action Registry + ContextService + condition DSL: live in
copad-core. Linux pumps viaLiveTriggerSink(window.rs). macOS uses onlyEventBus.swift. Trigger engine in particular carries non-trivial semantics: await preflight & pending state (Phase 14.2<trigger>.awaitedsynthesis), completion/failure fanout,covering_patternssubscription dedup, reload draining preserving in-flight events, ordering guarantees. Underestimating this in v1 broke Slack→todo /slack.get_messagechained 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 callsawait window.copad.call(method, params)and receives a structured response. macOS has no equivalent. - Custom keybindings: Linux parses
[keybindings]and runsspawn:<cmd>as out-of-band shell processes viaspawn_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 isterminal-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.
terminal.outputevent: SwiftTerm'sfeed(byteArray:)is an extension method, not overridable. Documented indocs/macos-app.md. Out of scope.
-
[C1] OSC 52 is a security regression on macOS today, not a future feature.
SwiftTerm/Mac/MacLocalTerminalView.swift:107declarespublic func clipboardCopy(source:content:)that callsNSPasteboard.general.writeObjects([str])unconditionally. The method ispublic, notopen— we cannot override it from outside SwiftTerm, and it does not forward throughprocessDelegate. 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
LocalProcessTerminalViewwith our own subclass that owns being theTerminalDelegateand proxiesclipboardCopyourselves (gated on[security] osc52 = "deny" | "ask" | "allow", default"deny"). If subclassing turns out blocked by the samepublic-not-openissue 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::Terminalexposesset_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:154pluscopad-linux/src/window.rs:551show:<trigger_name>.awaitedsynthesis forevent.subscribe-style chains, completion/failure fanout toLiveTriggerSink::dispatch_actionconsumer,covering_patternsdedup so overlapping globs collapse to one bus receiver, hot-reloadreconcile()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), thinActionRegistryshim that maps method name → completion handler. - Rust FFI (cdylib + C-ABI):
TriggerEngine,conditionDSL,event_buscovering/subscriber bookkeeping. JSON in / JSON out at the boundary.
- Native Swift:
- This means the FFI build-system work (cargo + SPM build phase, static
.abundled inCopad.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 withevent.subscribe, before committing to wider FFI surface.
- Drop "Option A — reimplement engines in Swift" from Tier 2. Tier 2 splits as:
-
[C3] Plugin panel JS bridge needs a reply-capable handler.
plugin_panel.rs:207exposesawait window.copad.call(method, params)returning a structured response. PlainWKScriptMessageHandleris fire-and-forget. Plan change:- Use
WKScriptMessageHandlerWithReply(macOS 11+, well within ourmacOS 14+target). - Public JS contract (
window.copad.call) stays unchanged; the injected user-script glue handles the request-id round-trip. No fallback topostMessage+ manual id; the API existed in our minimum target.
- Use
-
[I1] Webview commands need panel
idresolution on macOS. Linux'swebview.navigate/back/forward/reload/query/click/fill/scroll/statetake anidparam. macOS targetsactiveWebView. AI/web automation parity (concurrent panels, async commands) requires panel-id resolution. Plan change:- Add stable per-panel ids to
CopadPanel(UUID at creation), exposegetPanel(id)onTabViewController, 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.
- Add stable per-panel ids to
-
[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] directoryconfig" 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.txtAND 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::backgroundmodule.
- Drop "macOS-side
-
[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.swiftto a real TOML parser (SwiftPM depswift-tomlorTOMLDecoder) before Tier 1 #2. It's a prerequisite, not a Tier-2 task. - Round-trip-test against the existing
plugins/*/triggers.example.tomlfiles.
- Swap
-
[I4] Custom keybindings spawn shell commands out-of-band, not via socket dispatch. Linux's
[keybindings]map"ctrl+shift+g" = "spawn:~/script.sh --arg"tospawn_command(&binding.command)— a fork/exec, not asocket::dispatchcall. Plan change:- macOS keybindings use
Process(orposix_spawn) withCOPAD_SOCKETenv 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.
- macOS keybindings use
-
[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 apre_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.
- On macOS: spawn each plugin in its own process group (
- [N1] Source comment in
TerminalViewController.swift:10claims PTY output interception; reality isdataReceivedis not implemented in the subclass. Update comment to match the documented irreducible gap. - [N2] SwiftTerm has
requestOpenLinkfor 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).
- 0.1 Real TOML parser in
Config.swift(I3) ✅ — SwiftPM depLebJe/TOMLKit(0.6.0).CopadConfig.parsedecodes viaTOMLDecoderinto a privateRawConfigshadow 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 manualCodingKeyssince TOMLKit 0.6 has nokeyDecodingStrategy. 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.tomlplus 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 onTerminalViewControllerandWebViewController. SurfacedTabViewController.panel(id:) -> (any CopadPanel)?andwebView(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) ✅ —
CopadTerminalDelegateproxy owns SwiftTerm'sterminalDelegateslot, gatesclipboardCopyon[security] osc52(default"deny", opt-in"allow"). Hot-reload viaapplyOSC52Policy. 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.
- Pane focus navigation ✅ —
PaneManager.focusNextPane(direction:)does DFS over SplitNode leaves, wraps at both ends. Menu itemsCmd+Shift+]/Cmd+Shift+[inAppDelegate.setupMenuBar. Also exposed viapane.focus_next/pane.focus_prevsocket commands so triggers + coctl can drive it. Each focus change broadcastspanel.focusedevent on the bus (already existed). - Custom keybindings ✅ —
[keybindings]flat dict inconfig.toml, walked via TOMLKitTOMLTable.Keybindings.compileparsescmd+shift+gstyle combos (supportscmd/ctrl/shift/alt/optionmodifiers).NSEvent.addLocalMonitorForEvents(matching: .keyDown)intercepts; on a match returns nil (swallows event) and dispatches. Two value syntaxes:spawn:<cmd>(Process viash -c,COPAD_SOCKETenv injected so script can call back) andaction:<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 viaself. - Background random rotation ✅ —
BackgroundRotatorenum 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-modeholdsactive/deactive(missing = active, matches Linux).background.nextsocket command picks a random line viasubsec_nanos % count(same poor-man's RNG as Linux), preserves current tint/opacity, returns{status, mode, path}.background.toggleflips mode + applies a fresh image on activation / clears on deactivation, returns{status, mode}. Empty list returnsRPCError(no_wallpapers, ...). - Tabs position (top/bottom) ✅ —
[tabs] positionenum (.top/.bottom) inCopadConfig.TabViewController.loadViewbuilds layout constraints conditionally — anchors swap sotabBarlives at top or bottom of root,contentAreafills the remainder. Invalid value (e.g."left"from a Linux config) falls back to.topwith friendly stderr warning.left/rightdeferred (vertical tab bar needs 90-deg rotation of TabBarView itself, separate layout pass). - URL detection + click-to-open ✅ — OSC 8 hyperlinks already worked for free (SwiftTerm's
requestOpenLinkdefault →NSWorkspace.shared.open, no override needed). Plain-text URL detection landed:URLClickHelper.findURLruns anhttps?://regex against the row at the click's cell coordinate (computed from view bounds +terminal.cols/rowssince SwiftTerm'scellDimensionis non-public), usesTerminal.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.installURLClickMonitoradds a.leftMouseUpNSEventlocal 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.mouseUpispublic override(notopen) so we can't subclass — same monitor pattern used by pane-focus tracking andCopadTerminalDelegate. 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,\bword boundary required), case insensitivity. - Webview command panel-id parity (I1) ✅ — All
webview.*commands (navigate/back/forward/reload/execute_js/get_content/devtools/state) now resolve viaparams["id"]first and fall back to active webview when absent (lenient default per plan; Linux requiresid). Errors use a newRPCErrorsentinel thatSocketServer.dispatchdetects 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_jsreadscode(Linux + copad-cli convention) withscriptas back-compat fallback;devtoolsacceptsaction: show/close/attach/detach/toggle(macOS WKWebView has no public inspector-open API, so all enable-style verbs map to the existingdeveloperExtrasEnabledtoggle andcloseis a no-op — protocol parity without functional parity for the inspector window). Success results standardized to{status: "ok"}matchingwebview.rs.
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:
-
PR 1: FFI build/link spike only ✅ —
copad-ffistaticlib (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 inapplicationDidFinishLaunchingconfirming 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. -
PR 2: macOS registry-first dispatch seam ✅ —
ActionRegistry.swift(@MainActor, minimal surface:register/tryDispatch/has/names/count) mirrors Linux'stry_dispatchbool-return semantic so callers compose the same way.AppDelegate.handleCommandchecksactionRegistry.tryDispatch(...)first; onfalse(not registered) it falls through to the legacy hardcoded switch — no behavior change for existing methods.system.ffi_testandsystem.list_actionsregistered 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 → Rustextern "C"round-trip → back through every layer;system.list_actionsreturns{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_invokesync paths (only the trigger engine needs them). -
PR 3: one service plugin host before triggers ✅ —
PluginManifest.swiftdecodesplugin.tomlwithdecodeIfPresent-based optional defaults (TOMLKit doesn't honorvar x: T = defaultsyntax — that's a Swift init feature, not a Decodable feature, so we mirror serde's#[serde(default)]explicitly).PluginSupervisor.swiftdiscovers~/Library/Application Support/copad/plugins/<name>/first then~/.config/copad/plugins/<name>/(dotfile-sharing fallback) and spawns every service withactivation = "onStartup"viaProcess+ 3Pipes. EachPluginProcessruns init handshake ({id, method: "initialize", params: {protocol_version: 1}}→ wait for response with manifest-vs-runtime provides[] subset validation → sendinitializednotification), registers each provided action through the sameActionRegistryfrom PR 2, routesaction.invokeover stdio with id-keyed pending dict, and sendsshutdownnotification onapplicationWillTerminate.install-macos.sh --no-pluginsopt-out flag added; default builds + installscopad-plugin-echoto 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, andsystem.list_actionsshowingecho.pingalongside the PR 2 builtins). Critical bug found + fixed during verification: initial implementation always bounced reader-thread completions back to main actor viaDispatchQueue.main.async, which deadlocked the init handshake (main thread parked oninitBox.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 beyondonStartup(PR 5 with trigger engine), restart-on-crash policy, event subscription /event.dispatchoutbound, inboundevent.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). -
PR 4: port one real direct-action plugin ✅ —
copad-plugin-git(no Linux-only deps, just argv shell-out togitbinary). Compiles clean on macOS first try. Added toMACOS_PLUGINSin 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.tomlpointing at the copad repo: all 6 git actions (list_workspaces/list_worktrees/worktree_add/worktree_remove/current_branch/status) return correct results, andcoctl 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. -
PR 5a: Keychain spike via
llmplugin ✅ — addedcopad-plugin-llmtoMACOS_PLUGINSbecause it's the cheapest of the fourkeyring-using plugins (llm.auth_statusreads keyring entry without any HTTP call). Verified read path:apple-nativefeature successfully bound to macOS Keychain (store_kind: "keyring"reported, plaintext fallback did NOT fire),keyring::Error::NoEntrytranslated to cleancredentials_source: noneinstead of crashing. Slack/Calendar/Discord share the exact samekeyringcode 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. -
PR 5b: enable Calendar plugin ✅ — added
copad-plugin-calendartoMACOS_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-timecalendar.event_imminentevents on schedule). RPC actions still served gracefully without Google OAuth credentials thanks to the plugin'sConfig::minimal()fallback:calendar.auth_statusreturns{configured: false, authenticated: false, store_kind: "keyring"},calendar.list_eventsreturns a cleannot_authenticatederror 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 authwithCOPAD_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. -
PR 5c: TriggerEngine via FFI ✅ —
copad-ffigrew an engine surface (5 newextern "C"symbols + anActionCallbacktypedef) wrappingcopad_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:CopadEngineclass wraps the opaque handle, registers a@convention(c)trampoline that hops to main actor before touchingActionRegistry, JSON-encodes triggers + events for the boundary.EventBus.onBroadcastclosure fans every broadcast (including pluginevent.publishforwards) intoCopadEngine.dispatchEvent.PluginSupervisor.handleLinenow translatesevent.publishnotifications intoeventBus.broadcastcalls.[[triggers]]decoded fromconfig.tomlvia TOMLKitTOMLTableopaque 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'ssystem.heartbeat(envCOPAD_ECHO_HEARTBEAT_SECS=2) → stdio reader → eventBus.broadcast → onBroadcast →copad_engine_dispatch_event→ engine match → C callback →Task { @MainActor in registry.tryDispatch }→system.ffi_testhandler →CopadFFI.callJSON→ Rust round-trip → completion. Triggerparams({source: "trigger", note: "fired by heartbeat"}) flow through the boundary cleanly. Critical bug found + fixed during verification: initially markedCopadEngineas@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>.failedfan-out,covering_patternsdedup of overlapping globs (engine has it, no test config triggers it yet). Real lazyonAction:<glob>activation also still TODO — that's a supervisor-side change, separate PR. -
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-failedcargo buildon Darwin viacompile_error!(target_os = "linux")(copad-plugin-kb,copad-plugin-todo,copad-plugin-bookmark) now route their atomic creates through the shared helper and gate toany(linux, macos). Drops three duplicated copies of the inlineunsafe { libc::renameat2(...) }+path_to_cstringhelper in favor of a single tested module (2 new unit tests infs_atomicfor the create + EEXIST paths, plus all 107 plugin-side unit tests still pass on macOS).MACOS_PLUGINSininstall-macos.shnow includes all 9 first-party plugins (echo, git, llm, calendar, kb, todo, bookmark, slack, discord) — slack/discord install fine but RPC actions returnnot_authenticateduntil the user runs the plugin'sauthsubcommand to populate Keychain (same UX gate as Linux). Pre-existing manifest bug fixed in passing:plugins/todo/plugin.tomlprovides[]was missingtodo.update(advertised at runtime bycopad-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.searchreturns 91 fulltext hits over~/docs/;todo.createwrites a markdown file viarenamex_npandtodo.listreflects it;bookmark.addfirst-attempt returnsexisted: false, second-attempt returnsexisted: true(no-replace dedup path);slack.auth_status/discord.auth_status/llm.auth_statusall reportstore_kind: "keyring"confirmingkeyringapple-nativereaches Apple Keychain for write-bearing plugins too. Deferred: Slack Socket Mode token UX (one-time setup atslack.com/apps), Discord Gateway bot token (developer portal), KB FTS5 phase 13 (cross-platform). Full write-path Keychain prompt still untested (needs real credentials). -
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.tryDispatchnow wraps the supplied completion closure: on success it broadcasts<method>.completedon theEventBuswith the action's result payload (or["value": result]if not a dict — keeps{event.X}interpolation working for non-object returns), onRPCErrorit broadcasts<method>.failedwith{code, message}. Wrapped before forwarding to the original completion so a chained trigger observing.completedruns in the same logical tick as the originating action's response. Mirrors Linux'swith_completion_businvariant (copad-core/src/action_registry.rs): only registered actions emit; legacy match-arm dispatch inAppDelegate.handleCommand(webview.*,terminal.*,tab.*, etc.) bypasses the registry and stays silent — bringing those into the registry is a separate parity item. NewregisterSilent(_:handler:)variant suppresses fan-out for high-frequency introspection actions; applied tosystem.ffi_testandsystem.list_actionsso debug round-trips and tooling polls don't flood the bus. Source-stamp trust boundary (cross-review CRITICAL fix): Linux'stry_promote_or_drop_preflight(copad-core/src/trigger.rs:482) gates await-chain promotion onevent.source == COMPLETION_EVENT_SOURCE("copad.action"); without that gate, an unrelated bus event whose kind happens to end in.completedcould silently advance an await state machine. macOS's FFI dispatch previously hardcoded"macos.eventbus"for every event, so registry-synthesized completions arriving viaEventBus.broadcast → onBroadcast → CopadEngine.dispatchEvent → copad_engine_dispatch_eventwould have failed the trust check and stalled[triggers.await]chains entirely. Fix: extendedcopad_engine_dispatch_eventC signature to accept asourcearg, plumbed throughEventBus.broadcast(event:source:data:)(default"macos.eventbus"for back-compat) →EventBus.onBroadcast3-tuple closure →CopadEngine.dispatchEvent(kind:source:payload:)→ FFI.ActionRegistry.publishCompletionpasses"copad.action"(mirrored asActionRegistry.completionEventSourceconstant — 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 separatecopad_engine_publish_completionC symbol — wrong ownership boundary because completion events are bus-events, not engine-private; (C) hybrid wherePluginSupervisor.handleLineALSO broadcasts on plugin replies — would double-fire for plugin actions becausePluginSupervisor.swift:105already registers eachprovides[]entry throughtryDispatch. Option B (registry chokepoint) wins because plugin RPC replies funnel throughtryDispatchfor free. End-to-end verified: triggerpr7-completed-chainlistens onecho.ping.completed→ firessystem.list_actions;pr7-failed-chainlistens onkb.read.failed. Confirmed[copad-engine] event echo.ping.completed fired 1 trigger(s)aftercoctl call echo.ping, and[copad-engine] event kb.read.failed fired 1 trigger(s)aftercoctl 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 useawait, 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). -
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 withNonecontext (copad-ffi/src/lib.rs:443), so any trigger condition or interpolation that touchedcontext.Xresolved tonull(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; onlyactive_panel+active_cwdare 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 anEventBussubscriber, macOS'sonBroadcast(which fires before channel fan-out) would dispatch the trigger BEFORE ContextService got the event, so{context.active_panel}for apanel.focusedtrigger would resolve to the previous panel. Fix: apply-before-dispatch in the broadcast hook itself, mirroring Linux'sPump::pump_all(copad-linux/src/window.rs:589). (d) Forgotpanel.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'sonBroadcastfires synchronously per event, so polling is pure cost. Shipped:ContextService.swiftwithapply(eventKind:, data:)mirroring Linux's three event handlers;copad_engine_dispatch_eventC signature gained acontext_jsonarg (NULL → no context, engine handles gracefully); SwiftCopadEngine.dispatchEvent(kind:source:context:payload:)+EventBus.onBroadcast3-tuple closure plumbed through;AppDelegatewirescontextService.applyfirst, thendispatchEventwith the post-apply snapshot.context.snapshotregistered as a silent action (mirrorssystem.list_actionsposture — pollable without bus flood). End-to-end verified on macOS: all 3 scenarios passed live (initial empty snapshot;pane.focus_next→ trigger firesecho.pingwith interpolated context, echo's reply payload contains the JUST-FOCUSED UUID + cwd, sequential focuses produce distinct UUIDs proving the ordering;terminal.exec exiton an active pane →panel.exitedcorrectly transitions the active slot to a different UUID). -
PR 8:
claude.startmacOS port (Phase 18) ✅ — Linux'ssocket.rs::handle_claude_start(1411-1579) ported to Swift asClaudeStart.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_pathrequired + canonicalized,session_nameoptional (derived from path's last 1-2 components when absent),resume_sessionoptional (control-char rejected, single-quote-escaped),promptoptional (mutually exclusive withresume_session). Tab spawn goes through newTabViewController.newTerminalTab(cwd:initialInput:)→PaneManager(initialPanel: .terminalSeed(...))→TerminalViewController(cwd:initialInput:)→ SwiftTerm'sstartProcess(currentDirectory:)(native cwd support — no posix_spawn dance) + immediateterminalView.send(txt: tmuxCommand). Race-safe because PTY input is kernel-buffered: bytes wait until the child shell reads them. Prompt seeder is a backgroundDispatchQueue.global(.userInitiated)thread that pollstmux capture-panefor claude markers (Anthropic / Try " / claude -- / "claude code"), cross-checksdisplay-message #{pane_current_command}returnsclaudeornode, and only then runstmux load-buffer+paste-buffer+send-keys Enter Enter. Either readiness check failing → stderr log + return —claude.startalready 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 sessiontmp-copad-claude-testcreated, 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.starttargeting an existingtmux 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.startchains 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.
- 2.1 FFI scaffolding ✅ (PR 1) —
copad-fficrate (crate-type = ["staticlib"], depends only onserde_json) lives at workspace root, exposes 4extern "C"symbols documented incopad-ffi/src/lib.rs. Built viacargo build --release -p copad-ffi→target/release/libcopad_ffi.a. SwiftPM consumes through aCCopadFFIC target (Sources/CCopadFFI/include/{copad_ffi.h,module.modulemap}+ adummy.cso SPM emits an object file that carries the linker settings); theCopadexecutable target adds-L../target/release -lcopad_ffivialinkerSettings. The cargo step cannot be invoked fromPackage.swiftdirectly in SwiftPM 6.0, soscripts/install-macos.shandcopad-macos/run.shboth runcargo build --release -p copad-ffibeforeswift build. arm64 only for now (host-arch cargo default); universal-binary recipe iscargo 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 inAppDelegate.applicationDidFinishLaunchingcallscopad_ffi_version()andcopad_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: macOSEventBus.onBroadcastalready 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) mirrorscopad-core::context::ContextServiceapply 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.onBroadcastcallscontextService.apply(eventKind:, data:)BEFOREcopadEngine.dispatchEvent(...), then passescontextService.snapshot()through FFI ascontext_json. Matches Linux'sPump::pump_allordering (copad-linux/src/window.rs:589). FFI surface extended:copad_engine_dispatch_eventgained a 5thcontext_jsonarg (NULL →engine.dispatch(&event, None)for back-compat); inside FFI,Context::deserializethenengine.dispatch(&event, Some(&ctx)).context.snapshotsocket command added (silent, mirrors Linux), returns{active_panel, active_cwd}. End-to-end verified on macOS: (1)context.snapshotbefore any focus returns{}(both fields nil → omitted from dict, matching Linux serde nullable shape); (2) trigger withparams = { panel = "{context.active_panel}", cwd = "{context.active_cwd}" }onpanel.focused→ echo.ping (chained via PR 7's.completed) echoed back the JUST-FOCUSED UUID + cwd, proving apply-before-dispatch ordering; sequentialpane.focus_nextcalls produced different UUIDs each time; (3) after sendingexitto an active pane's PTY →context.snapshot.active_paneltransitioned to a different UUID, confirmingpanel.exitedcorrectly 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/.failedbroadcast throughEventBuson dispatch) plusregisterSilentfor introspection actions, closing the chain thatexamples/triggers/vision-flow-3.tomlrequires.register_blockingstill deferred — needs the async-boundary redesign because macOS's socket dispatch already pins the socket thread on aDispatchSemaphore. - 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
RwLockarbitrate. 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.
- 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-supervisorcrate 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 ✅ —LazyEntrystate machine (notStarted/spawning/ready/failed) replaces PR 4's eager-spawn workaround.discoverAndStartregisters placeholder ActionRegistry handlers perprovides[]without spawning the plugin; first call triggersLazyEntry.ensurewhich kicksPluginProcess.spawnoff-main onDispatchQueue.global(.userInitiated)and serializes concurrent first-callers behind the entry lock. Verified: launch with all 4 plugins installed →psshows only echo + calendar (onStartup) running, NO git/llm; firstcoctl call git.list_workspacestriggers 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_allextension: on macOS, after the cooperative-shutdown wait, sendSIGTERMto each pgid (catches grandchildren), 200ms grace, thenSIGKILLto the pgid and cleanup the pid file. At next-launch startupsweep_orphan_pidswalks the pid-files dir, probes each entry withkill(-pgid, 0), and if alive sendsSIGTERM/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-.pidfilename branches, and (2) the live-orphan kill path with a realsleepsubprocess spawned in its own pgroup. - 3.3 Plugin platform gates ✅ (PR 6) — KB/Todo/Bookmark were blocked by
renameat2(RENAME_NOREPLACE); PR 6 extractedcopad_core::fs_atomic::rename_no_replace(Linuxrenameat2/ macOSrenamex_np(RENAME_EXCL)), wired all three plugins through it, and re-gated each crate fromtarget_os = "linux"toany(linux, macos).O_NOFOLLOWandOsStrExtwere 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.shrather than a separateinstall-plugins-macos.sh. New--no-pluginsflag opts out.MACOS_PLUGINSreached the full first-party set (echo git llm calendar kb todo bookmark slack discord) at PR 6 oncefs_atomicunblocked 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 pluginauthsubcommands before RPC actions function.
- 4.1 Plugin panel ✅ —
PluginPanelController : NSViewController, CopadPanelwrapsWKWebView.WKUserScriptinjected at.atDocumentStartbuildswindow.copad = { panel: {id, name, plugin}, async call(method, params), on(type, cb), off(type, cb), _handleEvent(type, data) }byte-for-byte matching Linux'splugin_panel.rs:74build_bridge_js. Bridge transport:WKScriptMessageHandlerWithReply(macOS 11+, well within our macOS 14 minimum) for thecopadchannel; eachpostMessageround-trips viaBridgeHandler→ActionRegistry.tryDispatch→ reply JSON. Event delivery: subscribe toEventBus, forward each event to the webview ascopad._handleEvent(type, data)viaevaluateJavaScriptfrom a background thread (hopping to main for the WKWebView call). Panel HTML loads vialoadFileURL(allowingReadAccessTo: pluginDir)so relative CSS/JS/image siblings resolve. New socket commandplugin.open(params:name,panel?="main",mode?="tab") discovers the plugin manifest, finds the matching[[panels]]def, instantiates the controller, and routes viaTabViewController.newPluginPanelTab/splitActivePaneWithPluginPanel. Returns{status, panel_id}. End-to-end verified with a stubpanel-testplugin:plugin.openreturns id; tab.list shows the panel as active tab with the manifest title; the panel's JS issued threewindow.copad.call(...)invocations (system.ping,system.list_actions,echo.ping) which all reachedhandleBridgeMessage(confirmed via stderr instrumentation) and returned via the registered handlers — the echo.ping path even crossed back into a real plugin process. Error envelopesnot_foundfor unknown plugin name and unknown panel name. Theme CSS injection ✅ —buildThemeCSS(theme:)builds the same--copad-*variable set Linux ships viabuild_theme_css(bg,fg,surface0..2,overlay0,text,subtext0..1,accent,red) and injects it through aWKUserScriptat.atDocumentStartso panel.html'svar(--copad-bg)etc. resolve from the very first paint. Identical contract; keep the twobuild_theme_css/buildThemeCSSblocks in sync if either side evolves. Deferred: per-panelWKWebView.takeSnapshotfor plugin-specific screenshot endpoints (covered by genericwebview.screenshotonly after we let it target plugin panels — currently it resolves onlyWebViewControllerinstances). - 4.2 Status bar ✅ — Waybar-style 3-zone bar at the bottom of the window.
StatusBarView(NSStackView left/center/right zones) +StatusModuleRunner(per-moduleDispatchSourceTimerrunningsh -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 Linuxparse_output: try JSON{text, tooltip}first, fall back to plain text; trimming + accidental-bracket detection identical.[statusbar]config controlsenabled(default true),position(toporbottom, defaultbottom),height(default 28).statusbar.show/hide/togglesocket commands return{visible: bool}(Linux contract). Per-module CSS classes (module.classon Linux) ignored — module authors that want color cues should emit unicode markers or JSON-styled text instead. End-to-end verified with a stubsb-testplugin: 3 modules across all zones, 2-seconddateclock ticking on schedule (22:14:45 → 47 → 49), JSON path producingtext="json-ok"+ tooltip, plain text path producing* copad,plugin.listsurfacing the module defs, all 3 visibility commands cycling consistently.topposition ✅ —TabViewController.loadViewnow resolvestopEdge/bottomEdgeanchors fromconfig.statusBar.position. When"top", the bar pins toroot.topAnchorand the tab bar / content area anchor againstbar.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 throughapplyConfig→applyWindowOpacity(which now delegates toapplyTheme), 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 fromcopad-linux/src/webview.rs::jsverbatim into a SwiftWebViewJSenum; each handler resolves the target webview via the existing PR 1.6resolveWebViewhelper, runs the snippet throughWKWebView.evaluateJavaScript, parses the JSON-stringified result intoAnyso the wire shape matches Linux byte-for-byte.webview.screenshotusesWKWebView.takeSnapshot+NSBitmapImageRepto produce a base64 PNG ({image_b64, width, height}).theme.listreturns the 10 hardcodedCopadTheme.byNamearms + current theme name.plugin.listwalksPluginManifestStore.discover()and surfaces each plugin's{name, title, version, description, services: [{name, exec, activation, provides, subscribes}]}. End-to-end verified againsthttps://example.com: page_info reportslinks: 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};screenshotreturns 1200×732 PNG base64. Still missing:plugin.open(depends on Tier 4.1 panel host),plugin.<service>generic dispatcher (already works for free viaActionRegistry.tryDispatchsince plugins register theirprovides[]there directly — no extra method-prefix routing needed on macOS),claude.start(depends on agent integration),statusbar.show/hide/toggle(Tier 4.2).
Session persistence, command palette, KB FTS5, deferred LLM/Slack/Discord phases — neither platform has them; not parity items.
- 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.
- 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. - 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. - OSC 52 trust model — Won't ship via a
LocalProcessTerminalViewDelegatemethod (delegate path doesn't exist for clipboard); requires replacing/proxyingterminalDelegateor forking SwiftTerm. Adopted as Tier 0.3 blocker. - Plugin bridge — Use
WKScriptMessageHandlerWithReply(available on our minimum target). Adopted in C3 / Tier 4.1. - 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.
terminal.outputPTY 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.