IPC

September 4, 2026 · View on GitHub

Tauri commands

  • Tasks: task_create/task_create_multi (async, spawn_blocking; the frontend never blocks on the returned promise — see "Non-blocking task creation" below) stream the WHOLE creation timeline — worktree add, file copy, port allocation, then the setup script — on one channel, setup-output://<id> ({ line }) + setup-done://<id> ({ code, success }), keyed by the client-generated task id the New Task dialog sends as args.id (so the frontend can subscribe before invoking). task_archive/task_delete (async, spawn_blocking), task_open_repo, task_run_script_stream + task_stop_script (PIDs in RUNNING_SCRIPTS, child has process_group(0) for clean SIGTERM tree-kill), task_ensure_extra_ports (GH #196: tops up frozen named ports from the current config, called by the frontend before every tab spawn).
  • PTYs: pty_spawn/pty_write/pty_resize/pty_kill. Emits pty://<id> (PtyChunk { data: Vec<u8> }) and pty-exit://<id> (PtyExit { code: Option<i32> }). SpawnArgs.role ({ task_id, kind: "agent"|"aux", is_default }) is the CLI attach/logs identity and allocates the 256 KiB output ring; it is deliberately separate from task_id, which doubles as the sandbox trigger (the aux shell carries a role but never a task_id). SpawnArgs.owner ({ task_id?, tab_id?, kind: "agent"|"shell"|"aux"|"run"|"setup"|"custom" }) is a THIRD identity and a reporting field only: the Activity monitor groups rows by project → task → tab with it. Every spawn sets it, including the ones the other two must skip — a scratch shell pegging a core is exactly what the monitor exists to find. Nothing may branch on it.
  • PTY attach ack: pty_attached { id }, called by the webview the instant listen("pty://<id>") resolves. Tauri events are fire-and-forget, so everything the flusher emits before that listener exists is dropped with no trace, and the child starts writing the moment it is forked. Rust therefore holds a PTY's FIRST flush (and the reader's final drain, for a process that exits immediately) until the ack lands or a 3s grace expires. Every caller of pty_spawn must send it (TerminalPane, AuxTerminal today), or that terminal shows nothing until the grace runs out. The gate itself is wait_for_attach in lib.rs, unit-tested for all three exits.
  • Activity monitor: procmon_open_window creates or re-focuses the procmon window; procmon_startProcSnapshot { session, rows, sampleMs, webkitUnavailable }, procmon_sample { session }, procmon_stop { session }, procmon_signal { pid, signal } (TERM/KILL/INT/STOP/CONT only, and only for a pid inside one of OUR PTY subtrees — the webview must not be an arbitrary kill(2) gadget). Sampling is PULL-based: there is no sampler thread, the Activity window's own interval is the clock, and stop leaves the module holding nothing. session is a guard, not decoration: a mismatched id errors so a reloaded webview restarts cleanly instead of reading another window's deltas. Only ever called from the Activity window (activity.html), never the main one. mod procmon in lib.rs is a 3-way #[cfg(target_os = …)] split resolving to procmon.rs (macOS, libproc/mach FFI, ri_phys_footprint for memory), procmon_linux.rs (/proc, plain text, VmRSS for memory — no phys_footprint equivalent, no WebKit-sidecar attribution), or procmon_other.rs (every other OS: a stub reporting "unsupported"). All three share row shapes + OS-agnostic logic (subtree walk, cpu_ratio, label_for, signal_from_name) from procmon_common.rs. The macOS FFI genuinely fails to LINK if it ends up compiled into a non-macOS build — this split exists because that shipped broken once (the Linux release build failing at link time with undefined libproc/mach symbols).
  • Language servers (GH #174, code intelligence): lsp_start { root, language, channel, options } → a server id, spawning the child at root with the user's login-shell PATH but a NARROW environment (VIRTUAL_ENV, RUSTUP_TOOLCHAIN, GOFLAGS, locale and friends, never the tokens an rc exports: resolution prefers a binary from inside the checkout, so the repo picks the process). options carries the project's own { initializationOptions, settings }, forwarded verbatim; lsp_catalog {} → every server termic knows about per language, with what this machine already has; lsp_send { id, message }; lsp_stop { id } (SIGTERM to the whole process group); lsp_reap_foreign { page } → how many were killed (every server started by an EARLIER page load: a reload throws away the webview map that keeps one server per checkout while the processes keep running, unreachable, so App calls this once at startup); lsp_list[{ id, root, language, command, pid }]. Installation is separate: lsp_offer { root, language }{ exe, installLabel, installBytes, caveat } (what can be driven, or downloaded, or neither, plus anything about this checkout that will make the answers look broken), lsp_install_zuban {} (a virtualenv termic owns, pinned and wheels-only, because zuban ships as a PyPI package rather than a binary), lsp_install { language } (idempotent; resolves the LATEST release of a hardcoded upstream repo, verifies the bytes against the SHA-256 that release advertises, unpacks into <data dir>/servers/<language>/<version>/ and strips com.apple.quarantine), lsp_check_update { language }{ installed, latest, upgradable } (on demand only, and never reports an upgrade when the API did not answer, which would offer a downgrade), and lsp_update { language } (installs alongside, keeps the newest two versions, takes effect at the next spawn).

lsp_send is a queue push, not a write: a child that has stopped reading would otherwise block a Tauri IPC thread inside a pipe write, holding the server map's mutex, which is the synchronous-IO trap in CLAUDE.md reached through a pipe. Each server owns a writer thread. The bridge is a Tauri Channel, not emit/listen: Tauri's listeners may process events out of order when a listener is async, and out-of-order delivery corrupts JSON-RPC. Three things in the host are load-bearing and fail SILENTLY when wrong, so they have unit tests rather than comments alone: it answers server→client requests itself (workspace/configuration gets one null per requested item — ty panics on a length mismatch and hangs when the reply never comes — everything else gets a null result, because an ERROR reply is what aborts pyright), it merges workspaceFolders/rootPath into the outgoing initialize (the CodeMirror client sends only rootUri; clangd reads only the former, ruby-lsp only the latter), and Content-Length counts BYTES. Servers are registered in cleanup_children and are procmon roots, so they appear in the Activity window with no taskId — one server serves every task on its checkout. Deliberately NOT sandboxed: only the agent CLI PTY is in that threat model, and a language server is the user's own toolchain.

  • Subscription usage (GH #277, the task footer's usage chip): agent_usage_codex { agentId } -> AgentUsage { session, weekly, planType, accountId }. Async + spawn_blocking, because it SPAWNS codex app-server and waits up to 10s for a cold start; a synchronous command doing that blocks the WKWebView event loop. It is one initialize + one account/rateLimits/read over stdio, then the child is killed, deliberately the same shape as codex_trust::hooks_list (worker thread, join timeout, kill first and report second) because it is the same hazard: a long-lived JSON-RPC peer termic wants exactly one answer from. CODEX_HOME comes from agent_dirs::instance_config_dir for the agent ENTRY, so a clone is asked about its own login rather than the base's. Windows arrive as durations and are sorted into session/weekly BY LENGTH, never by primary/secondary position: a free plan sends a single 30-day window as primary, and reading position as meaning paints a session bar that resets next month. claude has no equivalent command and must not grow one - it reports itself through the status line agent_hooks::statusline_body installs, over the OSC channel the hooks already use. See docs/agent-hooks.md and docs/ideas/usage-footer.md.

  • Scripts: emit script-output://<wsId>:<kind> ({ line }) + script-done://<wsId>:<kind> ({ code, success }). kind in setup/run.

  • Settings/discovery: settings_load/settings_save/agents_save/discover_repos/detect_clis/list_monospace_fonts/list_font_families (async + spawn_blocking + OnceLock cache — font-kit is 7s synchronous). list_font_families is the unfiltered family list (installed-ness checks); list_monospace_fonts is the is_monospace() subset (picker extras) — the latter trusts the post-table isFixedPitch bit, so it misses real monospace fonts with sloppy metadata.

  • Files: workspace_file_read (text, 2 MB cap) / workspace_file_write, workspace_file_read_base64 (async + spawn_blocking; images only by extension whitelist, 10 MB cap, takes known_fp and returns { unchanged, mime?, data?, fp } for the markdown preview's data: URLs — unchanged: true skips the read+encode when known_fp still matches, the fast path for agent-settle revalidation storms), workspace_path_stat ({ exists, is_dir }, tolerates a missing leaf so link-existence checks don't error — also accepts a path that's exactly a composition member's own root, via resolve_workspace_git_path_ex). task_file_fp is the same resolution with no read at all: it returns just the mtime:len fingerprint, so the PDF pane can tell a real rewrite from an agent-settle tick without pulling a 20 MB file through the IPC it doesn't need (the bytes go over the taskpdf: scheme). All of them are member-aware (resolve_workspace_git_path) and worktree-contained (safe_workspace_path for an existing target, check_workspace_path_existence when the target may legitimately be missing); both file reads run through read_capped_file (TOCTOU-safe: fstat on the open handle, not a separate path stat).

  • Pasted images: clipboard_image_save (async + spawn_blocking) takes the image bytes as the raw request body (invoke(cmd, uint8array)tauri::ipc::Request / InvokeBody::Raw), not as a JSON array of numbers, which for a 3 MB screenshot is the difference between one copy and several million. It sniffs the format from the MAGIC BYTES (PNG/JPEG/GIF/WebP) rather than trusting a caller-supplied extension, writes pasted-<unix>-<8 hex>.<ext> into $TMPDIR/termic-attachments/clipboard/ (attachments_dir, shared with dropped-file staging, and NOT the data dir, which Seatbelt denies outright), prunes anything there older than 7 days, and returns the absolute path. It exists because an image paste cannot reach an agent any other way: xterm.js sends only text down the PTY, and in Docker mode the agent is a Linux process whose clipboard reader shells out to xclip/wl-paste with no route to the Mac's pasteboard. TerminalPane's capture-phase paste listener PASTES the returned path in the image's place (via term.paste(), so it arrives bracketed and claude reads the file into a real [Image #N] attachment instead of echoing a path), backslash-escaped by the same shellEscapePath a dragged-in file uses. It fires for DOCKER tasks only: everywhere else the agent reads the Mac clipboard itself and already gets the real image. Docker mounts that directory READ-ONLY at the identical absolute path, so the pasted string resolves inside the container.

  • Clipboard image, ctrl+V: clipboard_image_capture (async + spawn_blocking) reads the image off the MAC's pasteboard itself (tauri-plugin-clipboard-manager's read_image, off the main thread per its own deadlock warning), re-encodes the raw RGBA it returns as PNG (encode_rgba_png, refusing a buffer shorter than the advertised dimensions) and stores it through the same save_clipboard_image. It exists because ctrl+V is not a paste event: on macOS it is byte 0x16 travelling down the PTY, and it is the gesture claude binds its own image-attach action to - which inside a container can never work. Called ONLY for Docker tasks, and a clipboard holding no image is an ordinary rejection: TerminalPane forwards the original 0x16 to the agent so a text ctrl+V behaves exactly as before. Reading the pasteboard in Rust rather than via navigator.clipboard needs no webview permission and no user gesture.

  • Scratchpads (GH #244, untitled buffers scoped to one task): scratch_list ({ taskId }ScratchRecord[], ordered), scratch_read, scratch_write (create-or-overwrite the buffer, stamp updated_at), scratch_set_meta (index-only; every field optional so the debounced title derivation cannot race a syntax pick into a stale value, and syntax: "" clears a manual pick), scratch_delete, scratch_promote, scratch_promote_target_exists. All async + spawn_blocking.

    Buffers live under <data_dir>/scratch/<task_id>/, never inside the worktree: a scratch file in the repo shows up in git status, in the diff the agent reviews, and eventually in a commit. The index carries the title and syntax because a pad has no filename to re-derive them from on launch. A SCRATCH_LOCK mutex serializes each index read-modify-write — the buffer write and the title derivation are debounced independently on the typing path, so two writes genuinely can interleave.

    scratch_promote is ONE command on purpose. It reads the buffer, resolves the target through the same resolve_task_git_path + containment pair every other write uses, writes it, and only then deletes the pad (a failed write leaves the pad exactly where it was). Doing it as "read here, write there" from TypeScript would re-implement that containment rule in the one place it must not be re-implemented. It refuses an existing target unless overwrite, which is what scratch_promote_target_exists exists to ask first.

    Containment for a target that does NOT exist yet is safe_task_path_for_create, not safe_task_path: the latter canonicalizes the target itself and so errors on a missing file. The new helper walks up to the nearest existing ancestor, canonicalizes and contains-checks THAT, then rebuilds the target underneath the canonical ancestor — so a member symlink is resolved before the remainder is appended and the write lands where the check looked.

    Ids from the renderer become path segments here, so both the task id and the pad id go through scratch_id_ok ([A-Za-z0-9_-], ≤128). Refused, never sanitized: a silently rewritten id reads a different pad than the caller asked for.

  • Git history (issue #199, the Graph section of the right panel's Git tab): task_git_log ({ id, dirName, skip, limit, allBranches, firstParent, grep, refs }GitLogPage { commits, has_more, branch, upstream }) is one git log --topo-order per page; it asks git for limit + 1 rows and drops the extra, which is how has_more is known without a second walk. Commit fields are US (0x1f) separated and RS (0x1e) terminated — a subject can hold anything but a newline, so newline-delimited parsing loses records. task_git_commit_files ({ id, dirName, sha }GitFile[]) runs diff-tree -m --first-parent --root so merges and the initial commit report files instead of nothing. task_file_diff_sides takes scope: "commit:<sha>" for a historical diff (sha^ vs sha, no working-tree side). Every revision argument goes through is_commit_ish (hex only): the graph never passes a user-typed ref, so anything else is a bug or an injection attempt.

    Scope: allBranches is --all and wins over everything; otherwise refs names what to walk (the picker's multi-select) and an empty list means HEAD alone. refs is an ALLOWLIST check, not a syntax check: allowed_refs keeps only names git_refs actually enumerated, so a caller-supplied string can never reach argv as a flag (--upload-pack=…) however it is spelled. A scope whose refs have all been deleted returns an empty page rather than falling back to HEAD, which would answer a different question under the old scope's label. grep is --grep plus --regexp-ignore-case --fixed-strings: FIXED strings because it is fed by a filter box, where [ is a character someone typed and a half-written regex must not become an error mid-keystroke; it is glued into one --grep=<q> argv element, so the query cannot be read as a flag. It narrows the active scope rather than replacing it. firstParent is a separate axis from the ref list: it adds --first-parent so a merged side branch collapses into its merge commit, and is ignored under allBranches (where seeing every tip is the point, and pruning the topology would draw tips with no path to them). task_git_refs ({ id, dirName }GitRef[] { name, sha, kind }) is what the picker lists and what that allowlist is built from; origin/HEAD is dropped because it is an alias for another entry.

  • Blame (the editor's cursor-line annotation): task_git_blame ({ id, path }BlameFile { commits, lines, head, skipped }) is git blame --root --incremental over the WHOLE file, once, async + spawn_blocking (~200 ms on a 15k-line file). Three things about the payload are load-bearing:

    • --incremental, not --porcelain. On this repo's own lib.rs (15,742 lines) --line-porcelain is 7 MB and --porcelain is 1.6 MB, because both echo the file content back; --incremental emits headers only and is 433 KB. It also repeats a commit's header block only on that commit's FIRST group, which is the dedupe already done for us.
    • The wire shape is a commit table + one u32 per line, not a record per line: 169 commits and 63 KB of indices for that same file. This crosses into a WKWebView, so the shape is the difference between instant and janky.
    • It blames the WORKING TREE (no rev argument), so git itself attributes uncommitted lines with the all-zero sha (BlameCommit.uncommitted) and the line numbers already match the file on disk. VS Code instead blames at a sha, which makes its cache immutable, and maps the cursor's line back through a live quick-diff model; we have no such model, and this trade avoids needing one. The cost is that the frontend cache is invalidated by writes (save, external reload, any gitRevision bump) rather than never.

    Files over BLAME_MAX_BYTES (2 MB) come back skipped: true with empty data, which is a distinct signal from "no blame here" (untracked file, or not a repo) so the UI can stay silent rather than look broken. Neither is an error: an untracked file legitimately has no history. That cap deliberately mirrors task_file_read's own text cap instead of inventing a second policy, and it is one metadata call: the index length comes from git's own line numbers, so nothing re-reads the file to count newlines (and a file rewritten between two reads cannot produce an index that disagrees with the blame).

  • One commit's detail: task_git_commit_meta ({ id, path, sha }GitCommit) is git log --no-walk with GIT_LOG_FORMAT, the SAME format constant and parser task_git_log uses, so the blame popup and a History row cannot describe one commit differently. Async + spawn_blocking. Fetched when a blame hover card opens (cached per sha on the frontend), never as part of the blame payload: a file's blame can name 169 commits and the reader hovers one. sha goes through is_commit_ish, so only hex reaches argv, and path resolves the repo member-aware exactly as blame does.

  • Locating one commit: task_git_commit_offset ({ id, dirName, sha }usize) is rev-list --count <sha>..HEAD, i.e. how many commits are newer, which is exactly the skip the history page needs to land on it. It exists so "show this commit in History" is one query rather than paging forward until the sha appears: on a monorepo the target is tens of thousands of rows down. is_commit_ish guards the sha, and a commit not reachable from HEAD is an error rather than 0 (which would scroll to the top and look like success).

  • Push: task_git_push ({ id, dirName }) pushes the repo's current branch without committing, for the Push button beside Commit. It shares git_push with task_commit's push flag, so the set-upstream fallback (push -u <remote> <branch> when a plain push fails on a fresh worktree branch) cannot differ between the two. GitRepo.ahead (rev-list --count @{upstream}..HEAD, 0 with no upstream) is the button's badge.

  • Branch compare (issue #208, the Compare sub-tab of the right panel's Git tab): task_git_compare ({ id, dirName, base, mergeBase }GitCompare) is everything differing between a ref and the WORKING TREE, in one list: git diff --name-status -M -z for the glyphs, --numstat -M -z for the churn, and ls-files --others for untracked paths git's diff cannot see. Both diffs are -z because a path may contain any byte but NUL, and a rename's record carries two paths (the destination is the one kept, being the one git show <sha>:path resolves). mergeBase: true (the default) diffs from merge-base(base, HEAD) rather than the ref's tip, so commits the base gained after the branch point do not render inverted as deletions. Unlike the graph, a user-typed refname DOES reach git here, so base goes through resolve_revis_safe_rev (no leading dash, no rev syntax, no glob) plus --end-of-options, and is resolved to a sha ONCE: everything downstream, task_file_diff_sides' scope: "base:<sha>" in particular, stays hex-only under is_commit_ish. That scope reads the base commit against the live file, which is why Compare keeps the review affordances a historical diff has to drop. The base picker lists task_git_refs (above), which already includes remote-tracking refs and tags.

  • Misc: notify, open_path (handles URLs via macOS open), home_dir, path_exists, log_line.

  • Opening a web link (GH #245): open_external_url ({ url, browser }{ used, reason }) opens a URL in the browser the user configured, where browser is a COMMAND TEMPLATE the frontend has already resolved (resolveBrowserCommand in src/lib/previewBrowser.ts owns the project-overrides-global precedence, and is where its tests live). An empty template takes the OS-default path, byte-identical to open_path, so an unconfigured app behaves exactly as it did before the setting existed. used is "default", "browser" or "fallback", and a fallback carries the reason so the UI can say why instead of leaving a dead link.

    The template is tokenised by split_browser_command and spawned as argv — never through a shell. This is load-bearing, not stylistic: a shell would re-split a preview URL like http://localhost:3000/?a=1&b=2 on the unquoted &, which is the same bug the open_command comment documents for cmd /C start, and the one Debian's eval-based $BROWSER actually has. {url} is substituted if present, otherwise the URL is appended as the final argument.

    Launch failure is detected by watching the child for BROWSER_WATCH_MS (500ms). A launcher that is going to fail does so immediately (open -a "Nonexistent" exits 1 in milliseconds), while a browser that took the URL either exits 0 at once or lives for the whole session — so the bounded watch separates them without ever waiting on a real browser. It runs on spawn_blocking, and is a single wait per user click, NOT the steady-state sleep-poll that docs/performance.md bear trap 9 bans.

  • Validating a browser command: browser_command_check ({ command }) rejects an unparseable template or a launcher missing from PATH, for the Settings field. It can only vouch for the launcher: open -a "Gogle Chrome" passes (because open exists) and still fails at launch, which is exactly why the runtime fallback above also exists.

A public integration surface: an external system (ticket tracker, internal dashboard, shell alias) drives Termic from a link. Two actions:

termic://new?project=web&worktree=1&name=fix-login&p=Fix%20the%20login%20bug
termic://open?project=web&task=fix-login

new (pre-fills the New Task dialog):

parammeaning
projectrequired. Registered project, by id or by name (case-insensitive).
nametask name (max 200 chars)
prompt / pfirst message, pre-filled into the dialog (max 8000 chars, counted after decoding)
agent / cliagent id to pre-select; ignored if this install doesn't offer it
modeworktree or main. worktree=1 is the shorthand. On a multi project main is the host-level shape: the live host checkout with every member linked in, the same task the sidebar quick menu's Main checkout creates.
base"Branch from" ref

open (selects an existing task):

parammeaning
taskrequired. Live task, by id or by name (case-insensitive). Archived tasks don't match.
projectoptional scope. A bare name matching in two projects is ambiguous, not a coin flip.

The rule that separates them, and that any future action must pick a side of:

Navigation is immediate, state change is a modal, destruction is not a link.

open only selects something that already exists, so it just happens. new never creates anything — it fills the form and a human presses Create. That is the whole security model for accepting a prompt: links are authored in the ticket tracker, so whoever can file or edit an issue (in many orgs that includes external reporters) controls the text. It is also why an unregistered project is a hard error rather than a fallback to "the first project" or a silent project add. Do not add an auto-create or skip-confirmation option.

Percent-encoding the prompt

prompt carries free text (a ticket body, a paragraph, newlines) through a URL query string, so it MUST be percent-encoded by whoever builds the link. Termic decodes it once, with the standard URLSearchParams rules, and never tries to repair a malformed value: there is no way to tell a truncated prompt from a short one.

Four characters decide whether a link survives, and all four are ordinary in ticket titles:

rawencodedif you leave it raw
&%26everything after it parses as a NEW query param, so the prompt silently truncates
#%23everything after it becomes the URL fragment and never reaches the app
+%2Bdecodes back as a SPACE, so C++ arrives as C
newline%0Ausually stripped by whatever hands the URL over

Space may be %20 or +; both decode to a space. % itself must be %25, or a literal %2 in the text will eat the next two characters.

Build the value with a real encoder rather than escaping by hand:

# shell
jq -Rr @uri <<<"$BODY"
python3 -c 'import sys,urllib.parse as u; print(u.quote(sys.stdin.read(), safe=""))'
`termic://new?project=web&p=${encodeURIComponent(body)}`   // NOT encodeURI

encodeURI is the wrong function here: it deliberately leaves &, # and + alone, which are exactly the three that break a query value.

The cap is on the decoded text, not the URL. MAX_PROMPT_CHARS is 8000 characters after decoding, so a fully-encoded 8000-character prompt is a URL of roughly 11000 characters, and that is fine. Over the cap the whole link is REJECTED with a toast and no dialog opens, rather than being truncated, because half a prompt is worse than none: the user would have to notice the missing tail themselves. The value is also trimmed, so leading and trailing whitespace never counts toward the cap.

Templating gotcha. A tracker that expands {{issue.summary}} without a URL-encode filter hits the table above at the first & or #, turning Fix login & signup into Fix login . The confirm step is what catches this: the user sees the mangled text in the textarea instead of an agent acting on half a sentence. Template authors should apply the tracker's encode filter (Jira automation's .urlEncode(), and equivalents elsewhere).

A link that seems to do nothing at all is usually not the link. open still exits 0, nothing is queued, and [deeplink] queued never appears in termic-debug.log. Check that log line first, before suspecting the URL. Two causes:

  • The app updated itself under the running process. LaunchServices resolves the scheme to the bundle on disk, so the old process stops receiving links. Restarting the app fixes it.
  • make beta is installed. Both Termic.app and Termic Beta.app register termic://, deliberately: one link should reach whichever of the two is open, and they are mutually exclusive (shared data dir, shared socket, single instance). LaunchServices picks one bundle, and when it picks the one that is NOT holding the socket, that process raises the owner and hands the URL over rather than opening a second window. See "the macOS handoff" below, and handoff_deep_link_then_exit in lib.rs.

The macOS handoff. On Windows and Linux a link spawns a fresh process with the URL in argv, so deep_link_from_argv reads it before the single-instance check and it rides along with the raise. macOS has neither half of that: the URL arrives as an Apple Event that AppKit only delivers once the run loop turns, i.e. strictly AFTER setup returns, and tauri-plugin-deep-link only records it from RunEvent::Opened. So the non-owning process cannot call std::process::exit(0) inside setup the way the other platforms can, or the link dies with it (which is exactly what "opens the other Termic and does nothing" was). Instead it returns from setup with NO window built, drops to ActivationPolicy::Accessory so it takes no dock slot and steals no focus, polls the plugin's captured URL for a 1.5s grace, forwards it over the socket as open_url, and exits either way. Nothing waits on that process: the raise already landed on the way in.

Explicit non-goals: no project/add (a link must never register a repo — that routes around the gate above), and nothing destructive (archive, quit), where no amount of confirmation justifies exposure to a channel any web page can trigger.

Where the pieces live:

  • Scheme registration: tauri.conf.jsonplugins.deep-link.desktop.schemes. The bundler turns this into CFBundleURLTypes; it merges with the hand-written src-tauri/Info.plist rather than replacing it. Deep links only work from a bundled .app — not under npm run tauri:dev, so test with make beta or tauri build.
  • Rust (lib.rs) is a pipe, not a parser: every arriving URL lands in PENDING_DEEP_LINKS and the webview gets a payload-free nudge (termic://deep-link). The queue exists because macOS delivers the launch URL while the webview is still booting; the webview always reads through deep_link_take_pending, which drains atomically, so a link is never handled twice.
  • Parsing + validation is entirely in src/lib/deepLink.ts, because the checks that matter (a registered project, an existing task) need the webview store. initDeepLinks() is chained off loadAll() in App.tsx for the same reason.
  • Raising: queue_deep_link calls leave_windowless when a window already exists. macOS activates the app for a link it routes, but that does not un-hide a window windowless mode put away, and a dialog behind a hidden window is indistinguishable from a link that did nothing. Gated on the window existing so cold-start setup doesn't flip SHOWN_ONCE ahead of normal startup ordering.
  • Second instance: a link that spawns a fresh process is killed by the single-instance preflight, which hands the URL over first via the control socket's unauthenticated open_url verb (proto v11) — that raises and queues in one request. Windows/Linux do this synchronously from argv; macOS cannot, see "the macOS handoff" above. With only one bundle installed macOS never gets here at all, because LaunchServices routes to the running app.

The termic command

One release command, termic, for both the shipped app and make beta. Not a cosmetic choice. The two release flavors share the data dir, therefore the control socket, therefore the single instance that owns it — so a single binary already reaches whichever of them is running, and only one of them ever is. install_name (cli_server.rs) returns termic for every release build and termic-dev only for a debug one, which has a genuinely separate termic_dev data dir and socket.

The beta used to install a termic-beta twin. It was a name and nothing else: the same binary, talking to the same socket, so it drove the shipped app whenever the shipped app was the one up, while its name promised otherwise.

Auto-launch always opens Termic.app (client.rs, open -ga Termic). When neither app is running there is no socket to disambiguate on, and the shipped app is the right default; run make beta yourself to get the other one.

reconcile_link runs on every launch (off the main thread, from cli_server::start) because three things drift silently: the name (a leftover termic-beta), the target (a deleted or moved bundle leaves a dangling link and a "command not found" with no explanation), and the version (a link into the OTHER flavor's older sidecar fails hello's protocol check). It repoints the link at the app you actually launched, in the directory the link is already in.

--help leads with the version (cli_command() in termic-cli/src/lib.rs), on the root, on every subcommand, and through the termic help [command] renderer. One command for two bundles means "which build is this?" is a real question, and the binary's version is the app version it was built with, so one line answers it. clap 3 printed this by default and clap 4 dropped it. It is a literal in the template rather than {version}, because {version} renders empty on a subcommand unless propagate_version is set, and that would add a --version flag to every subcommand — widening the surface machine_help() publishes and the MCP parity test pins, to fix a header. termic help --json already carried version.

It is deliberately narrow, and the narrowness is the contract: reconcile_link only ever REWRITES a link of ours that is already installed, never creates one, and anything that is not our symlink is never touched. An unwritable /usr/local/bin logs and changes nothing; a launch must never raise an admin prompt.

auto_install_user_link is the one thing that CREATES a link, and it runs at most once per profile. cli_enabled defaults to true, and for a long time the only thing that ever created the symlink was the Settings toggle's off-to-on transition, so a fresh Mac rendered a toggle that already said on, above a hint that said "On by default", with no termic command anywhere. Nobody got a CLI unless they happened to switch it off and back on. Every profile the cli_default_migrated flip reached had the same problem, since it sets the flag and installs nothing. Reported from a fresh install, where the system-wide button was the only way to get a working command.

It installs into ~/.local/bin and never /usr/local/bin: writing there needs an admin prompt, and a launch must never raise one, so system-wide stays on the explicit button where the user asked for it and can answer. The tradeoff is real and is not hidden: ~/.local/bin is NOT on a stock macOS PATH (/etc/paths ships /usr/local/bin and not it), so Settings reports whether the command it installed is actually reachable, and offers system-wide when it is not.

The gate is a MARKER (cli_user_link_installed), not "install whenever it is missing", and that distinction is the whole design. On disk the two look identical and mean opposite things: a command that was never installed wants installing, and a command the user DELETED wants leaving alone. The marker is set on the first attempt, success or failure, so a user who removes the link is never re-installed behind their back, and a failing install does not retry on every launch.

Critical shapes (fail silently)

  • pty_spawn payload is { args: SpawnArgs }, NOT SpawnArgs at top level. Wrong shape → "invalid length 0, expected struct SpawnArgs".
  • Listener payload is ev.payload.data / ev.payload.line — Rust emits structs, not bare arrays. Wrong unpack → blank terminals, no error.
  • task_run_script takes { id, which }. Forgetting which → silent no-op.

Long-running IPC discipline

Any IPC doing heavy IO MUST be async fn + tauri::async_runtime::spawn_blocking. Synchronous commands run on the IPC handler thread = same thread driving WKWebView event loop in dev. fs::remove_dir_all on a 50k-inode .venv froze the entire Mac. Already applied to task_archive, task_delete, list_monospace_fonts. Pair with useUI.setBusy("…") overlay so the user knows a multi-second op is in flight — UNLESS the operation is scoped to one task rather than the whole app (both sections below), in which case a global overlay is the wrong tool: the other tasks' agents keep working and the user must be able to reach them.

Non-blocking task creation (GH #242)

task_create/task_create_multi being spawn_blocking on the Rust side keeps the WKWebView event loop free, but that alone doesn't stop the FRONTEND from blocking: the old New Task dialog (and, separately, the sidebar + quick-create row via QuickCreateProgressDialog) each awaited the call with a dialog locked open, so the whole window was unusable until git worktree add + the file copy finished on a big repo. There were TWO of these blocking implementations (the full dialog and the "quick create" inline path in Sidebar.tsx/quickTask.ts) — both needed the same fix, since a caller with the id ahead of time gets the same treatment regardless of which UI it came from. The fix doesn't use useUI.setBusy (that dims the entire app for one in-flight task, the wrong blast radius) — instead:

  1. The caller generates the task's uuid client-side (crypto.randomUUID()), registers a setup-output://<id> listener, adds a src/store/pendingTasks.ts entry, calls setActiveTask(id), and closes/proceeds — all synchronous with the click, before taskCreate's promise resolves. Both NewTaskDialog.tsx's submit() and Sidebar.tsx's inline worktree-row onCommit do this now; quickTask.ts's createQuickTask takes an optional id so the caller can pre-generate it.
  2. Sidebar.tsx's PendingTaskRow and MainArea.tsx's CreatingTaskPane render for that id (no real Task exists in the store yet) — a spinner-badged row and a full-pane live log, not a blocking overlay. The rest of the app stays fully interactive.
  3. Once taskCreate resolves, loadAll() picks up the real task and the pending entry is dropped — PendingTaskRow/CreatingTaskPane are superseded by the normal TaskRow/TaskView at the same id, so the user's click target never moves. On rejection the pending entry flips to an error state instead (dismissible from the pane), no toast, no reopened dialog.

QuickCreateProgressDialog and useUI().taskCreateProgress are gone (deleted, not just unused) — don't resurrect them for a new quick-create variant; extend the pendingTasks flow instead. Any future task-scoped long-running create/import flow should follow this shape rather than reaching for setBusy.

Non-blocking archive (GH #246)

The same bug at the other end of a task's life, and the same fix. archiveTask.ts's runArchive raised setBusy("Archiving …") and held it until task_archive AND the post-archive loadAll had returned — the project's archive script, then git worktree remove, then fs::remove_dir_all over a node_modules-sized tree, with the whole window under a click-blocker and every other task's agent unreachable. Worst exactly when #242 was worst: archiving several finished tasks in a row.

startArchive(taskId, deleteBranch) (exported, also used by the CLI's archive RPC in cliRpc.ts) does everything visible synchronously with the click and returns a promise the UI does not wait on:

  1. src/store/archivingTasks.ts gets the id (a separate store, not useApp — one transient flag has no business re-running every mounted task's selectors, see performance.md bear trap 8). It also doubles as the double-fire guard, which the modal used to provide for free.
  2. The task is deselected immediately if it was active: its pane is in front of the user with its worktree being deleted underneath it.
  3. Sidebar.tsx's TaskRowSlot swaps that one row for ArchivingTaskRow — struck-through name, spinner, no click, no menu, no tab children — until loadAll drops the task. Nothing else in the window changes.

archiveAndRefresh still swallows the IPC rejection (issue #24: the task is already persisted as archived, so the refresh must run regardless), but it now also toasts the cleanup error. In the background that toast is the ONLY signal: a worktree that failed to remove would otherwise vanish from the sidebar with the directory still on disk. Read the task's name for that toast BEFORE loadAll drops it.