Common gotchas
September 4, 2026 · View on GitHub
-
Window opens tiny.
tauri-plugin-window-staterestores prior size before min-size kicks in. Reset:rm "~/Library/Application Support/com.simion.termic/.window-state.json". -
Window on wrong monitor.
position_on_cursor_monitor()in setup hook +visible: false+show()after positioning. -
Terminal blank. Wrong payload shape — see ipc.md. (A terminal that was working and goes black later is the WebGL context-loss bullet below, not this.)
-
Terminal ribbons in TUIs.
lineHeight!= 1.0 or WebglAddon not loaded. -
WebGL crash (
_isDisposed). DisposewebglAddonBEFOREterm.dispose(). -
Theme picker flicker. Radix DropdownMenu has cursor-transit gaps. Use HoverCard with
sideOffset=0. -
A colour that ignores its token and renders as plain white. An unknown CSS custom property is not "empty", it makes the whole declaration invalid at computed-value time, so the property falls back to its inherited value.
color: var(--color-text-faint)on the inline-blame annotation therefore inherited the code's own foreground and the annotation shipped looking like ordinary code. There is no--color-text-faint; the tokens are--color-fg,--color-fg-dim,--color-fg-faint(see the@themeblock inindex.css). Nothing warns: it is valid CSS, valid TypeScript, and the pixel is simply the wrong colour. Grepindex.cssfor the token before inventing one, and treat "my colour did nothing" as a misspelled token first. -
A directory literally named
~appears inside a task worktree. The agent PTY is exec'd directly, with no shell in between, so every shell-ism in a Settings value stays a literal string. A per-agent env block (Settings → Agents → Environment) readingCLAUDE_CONFIG_DIR=~/.next-claudetherefore hands the agent an unexpanded~, and the agent creates that path relative to its cwd, i.e.<worktree>/~/.next-claude/, filling the repo with untracked junk that nothing in the app explains. Rust now expands a leading~in env VALUES at spawn (expand_tilde_env, unit-tested), matching every other path field. The general rule stands for anything else typed into these fields: no$VAR, no globs, no quoting, noVAR=val cmdprefixes. -
Toggle knob escapes track. Hardcode geometry, don't lean on Tailwind transform classes.
-
Footer collapses, files overflow. Grid needs
gridTemplateRows: "minmax(0, 1fr)". -
pty_spawn"invalid length 0". Payload wrap forgotten — wrap SpawnArgs in{ args: ... }. -
Right-click contextmenu.
window.addEventListener("contextmenu", e => e.preventDefault())inmain.tsx. -
App icon missing in dev. Dev runs raw binary, not
.appbundle. Icon appears afternpm run tauri:build. -
Picked system font ignored, Nerd Font glyphs box out.
system:<family>font ids (from the Rust enumeration) must go through the prefix branch instackFor()(prefs.ts) — they're not inMONO_FONT_OPTIONS, and falling back silently uses bundled JetBrains Mono (latin subset, zero PUA glyphs). Confusingly partial symptom: Powerline U+E0A0–E0BF still render because xterm's WebGL renderer custom-draws them without a font. -
Font picker incomplete on first open. The macOS native
<select>popup snapshots its options when opened — options React adds mid-open don't appear, and a value with no matching option renders blank. Hence: warmavailableMonoFontsAsync()at prefs module load, and seed selectedsystem:ids into the picker's initial list (AppearanceSection). -
Mixed letter heights in a terminal; selecting text "fixes" them (GH #70). The bundled JetBrains Mono is a lazy
@font-face— it only starts loading when text first uses the family. If the PTY's first output wins that race, xterm's WebGL atlas caches those glyphs drawn with the fallbackmonospace, keyed by (char, fg, bg, style) with the font only in the atlas config — so the same char keeps its wrong-font glyph indefinitely, while chars first seen after activation render correctly. Selection changes bg → new key → fresh (correct) glyph; changing the font away-and-back resets the atlas. Fix: prefs warms the load at module start, and both panes gate the first fit + PTY spawn onawaitTerminalFonts()(lib/terminalRenderer.ts) so metrics and glyphs come from the real font from the start — normally a microtask, capped at 800ms so a hung load can't stall spawns. Do NOT "fix" this with a naive post-hocclearTextureAtlas()+fit()instead: an async fit can fire mid-spawn (beforeterm.onResizeis registered → PTY cols/rows silently desync) or against a 0x0 collapsed host (PTY shrinks to minimum dims) — the helper's late path guards both. Any future pane that rasterizes text with a bundled font must go through the same gate. -
A DETACHED canvas can't resolve user-installed fonts. In WKWebView, a
<canvas>not connected to the document silently falls back for families under~/Library/Fonts//Library/Fonts:fillTextwith"MesloLGS NF"renders pixel-identical to a nonexistent family, including plain ASCII. System fonts (/System/Library/Fonts, e.g. Menlo) resolve either way, so the bug hides; FontFace-registered webfonts also resolve either way. It DOES affect xterm (see the next bullet), and it silently invalidates any font-probe helper — attach the canvas before measuring. Two aggravators: (1) the resolution is sticky — WebKit short-circuitsctx.font = <identical string>, so a stack resolved while detached stays wrong after reattaching until a different font string is assigned; (2)document.fonts.check()is no help: WebKit returnstruefor every codepoint, even glyphs the font lacks. To test coverage, rasterize and compare pixels against a known-missing font: every missing glyph draws the same tofu box, so identical signatures mean absent. -
The CSS Custom Highlight API paints the wrong text in WKWebView (GH #71). Don't use it.
CSS.highlights+::highlight()looks like the perfect fit for find-in-page (style ranges without touching the DOM), and its registry behaves correctly: right ranges, right text nodes, right rects. The painting is what's broken. Two separate defects: (1) writing to the registry schedules no repaint at all, so nothing appears until something unrelated forces one — which reads as "I have to close and reopen the pane to see my search"; (2) when a repaint does happen, range i is painted over the whole of the i-th<code>/<pre>element in the document, wherever the range actually points. So a doc with no code shows nothing, a doc with code lights up its code spans regardless of the query, and the match counter stays correct throughout because it reads the range list, not the paint.StaticRangepaints nothing; forcing a style recalc + reflow doesn't fix the mapping. The markdown preview now wraps matches in<mark>instead (markFindMatchesin MarkdownPreview.tsx), which has neither problem: a DOM mutation invalidates paint by itself, and there is no range-to-glyph mapping left for the engine to get wrong. Corollary for tests: asserting onCSS.highlightsproves nothing. The specs that did were green for the entire life of this bug. Assert on rendered DOM. -
Terminal font swaps when text is selected (highlight shows another family, e.g. serifed). xterm's WebGL atlas rasterizes all glyphs on ONE hidden scratch canvas shared across same-config terminals; the ASCII warm-up draws container-less and closing the hosting pane orphans it, so rasterization can run DETACHED — where WKWebView fails installed families (previous bullet) and falls down the stack. Glyph cache keys (char, fg, bg, ext) omit the font, so default-colored text keeps the wrong glyphs while selection (new bg → new key → connected draw) shows the right family. Fix:
lib/atlasCanvasGuard.ts(full mechanics in its header), wired inloadTerminalRenderer; reach-ins pinned byxtermInternals.test.ts. -
Every terminal goes black after a long session; restarting the tab is the only cure. Not a layout bug and not the PTY — the shell is alive and the scrollback is intact, which is precisely why a respawn appears to fix it. WKWebView reclaims GPU resources for a webview it considers idle (sleep, hours in the background, memory pressure), so every WebGL context in the process is lost at once and each addon's canvas keeps compositing its last empty frame.
onContextLossused to justdispose()the addon: correct as far as it goes (a renderer on a dead context draws nothing) but it leaves xterm on its DOM fallback with no repaint scheduled, so the pane stays black indefinitely.loadTerminalRenderernow disposes AND re-attaches a fresh addon (new context, new atlas) on aCONTEXT_REATTACH_DELAY_MSbeat — asking for a context in the same task as the loss event returns one that is already lost. Budgeted atCONTEXT_LOSS_MAXlosses perCONTEXT_LOSS_WINDOW_MS; past that the GPU is genuinely gone and retrying just black-flashes the pane on a loop, so it stays on the DOM renderer and force-refresh()es (the fallback only paints rows marked dirty, and a context loss dirties none — skip the refresh and the give-up path looks identical to the bug). The budget is a SLIDING WINDOW, not a latch — losses age out, so a GPU that recovers later gets WebGL back rather than costing a days-long terminal its fast renderer forever — and every path that attaches WebGL must consult it, not just the loss handler. The wake probe below is the one that bites: it sees a null addon, cannot tell "gave up" from "attach failed", and will happily re-attach into a dead GPU once per app switch unless it checks the budget too. The event is not guaranteed: a suspended webview can have its context reaped with nowebglcontextlostdelivered at all, sofocus/visibilitychangeadditionally probegl.isContextLost()and re-attach on the edge where the user is about to look at the terminal. Do NOT reduce the handler back to a baredispose(). Covered byterminalRenderer.test.ts; the_glreach-in is pinned byxtermInternals.test.ts. A RESTORED context is a separate bug with the same symptom, and the one the recovery above is blind to. xterm answerswebglcontextlostwithpreventDefault()plus a 3s timer and firesonContextLossonly if nowebglcontextrestoredarrives first. When one does, it repairs in place:removeTerminalFromCache(terminal)(which disposes the glyph atlas outright when this terminal was its only owner) then_initializeWebGLState(), which builds a fresh GlyphRenderer on the new context and never calls_refreshCharAtlas(). So_charAtlasstill points at the evicted atlas, no texture reaches the new context, and the pane draws nothing. The only path that would rebuild it, the!_isAttachedbranch ofrenderRows(), cannot run:_isAttachedwas set true at construction (screenElement.isConnected— a display:none pane is still connected) and nothing clears it. From outside,onContextLossnever fires (its timer was cleared) andgl.isContextLost()is FALSE, because the context genuinely came back: every signal the recovery is wired to reports a healthy terminal while it paints nothing. HencewatchCanvasbindswebglcontextlostANDwebglcontextrestoredon the addon's own canvas (_renderer._canvas, pinned byxtermInternals.test.ts) and rebuilds the addon either way — a fresh context with a fresh atlas is the only state worth reasoning about. Taking the raw loss event also removes the 3s of black the working path used to cost. One consequence: a single dead context is now reported up to three ways, sorecoverFromContextLossbails when its addon is no longer the live one, or one GPU blip spends the whole loss budget. Second consequence:attach()defers while the pane has no geometry (a process-wide outage hits every mounted terminal at once while at most one is visible; re-attaching them all burns a GL context each to draw nothing, and WebKit caps contexts per process and force-loses the oldest past the cap). A ResizeObserver on the 0 → non-zero edge picks the deferred attach back up, since switching tabs inside termic fires neitherfocusnorvisibilitychange. The fourth signal needs nothing from WebKit at all. Reported on a laptop driven over Screen Sharing with Termic frontmost, caffeinate holding the system awake and the display left to sleep: every Claude pane blank on return, and it never reproduced on a desktop. That setup never blurs the window or hides the document, so the wake probe never ran, and the canvas events had not fired or the pane would have been rebuilt. Whatever WebKit did to the canvas, it did it without a loss, a restore, orisContextLost()turning true, so from the outside a blank renderer is indistinguishable from a healthy one.src/lib/userPresence.tsstops asking: the first key, click, wheel, focus or visibility edge afterAWAY_MSwithout any is a return from absence, and every on-screen renderer rebuilds its addon on it, same task, no flash; a hidden pane is marked stale and rebuilds on its reveal edge, so the keystroke frame pays for the panes that have pixels and no others. The dropped context is released explicitly (WEBGL_lose_context, xterm's dispose never does) so a rebuild does not park a live context against WebKit's cap, a rebuild that cannot build an addon at all retries onATTACH_RETRY_MS(1s, 5s, 30s) and then leaves it to the next edge (the field log showed getContext failing on every pane for 30s+ with no loss event first, so WebGL was unavailable for a while, not for a beat; xterm's DOM renderer carries the pane meanwhile), every attach gate lives insideattach()so any edge can call it, and the renderer kind is read once at load so a rebuild never applies a pref change the docs promise waits for the next spawn. Not a loss, so it spends no budget. SetlocalStorage.ptyDebug = "1"and the whole sequence lands in the per-PTY log, including aback after Ns awayline (with isContextLost/paused/visibility/size) for each rebuild. A FAILED attach leaks a canvas, and enough of them wedge the window permanently (field log 2026-08-23). xterm's WebglRenderer appends its canvas to.xterm-screen, then runs the GL init that throwsvalue must not be falsyon a born-lost context, and only after that registers the disposable that would remove the canvas — so the throw strands a canvas holding a live context that evendispose()cannot reach. Past WebKit's per-process context cap every latergetContextis born lost too, retries included: one GPU outage plus eight panes retrying became dozens of leaked contexts, every fresh terminal failing its attach at spawn, and blank panes on BOTH renderers until a reload.attach()'s catch now diffs.xterm-screen's canvases, force-releases each leaked context (WEBGL_lose_context) and removes the canvas, so a failed attach costs nothing. Covered by the "failed-activate canvas leak" block interminalRenderer.test.ts. -
A view "can't scroll", and its list just runs off the bottom of the window.
flex-1only means anything to a FLEX parent. MainArea mounts the full-screen views (History, Dashboard) inside a plainabsolute inset-0overlay, which is a block box — so a view whose root isflex-1 flex flex-col overflow-hiddensizes to its CONTENT, not to the overlay, and theflex-1 overflow-autoscroller inside it never gets a bounded height to overflow. Nothing clips and nothing scrolls: the archive simply extended past the bottom edge and the rows down there were unreachable. Fix in the view, not in MainArea: the root takesh-full(definite height from the overlay'sinset-0) and the scroller addsmin-h-0, since a flex child'smin-height: autootherwise refuses to shrink below its content and re-creates the same overflow one level down. Dashboard was always fine because it usesh-full overflow-autoon its root. Assert it as geometry (root.height === parent.height, last row reachable after scrolling), never as a class name. -
Code shows through the line-number gutter when you scroll right (GH #161). CM6 makes
.cm-guttersposition: stickywithz-index: 200and the content a flex sibling, so a long line slides underneath it by design — the gutter's own opaque background is the only thing hiding it. CM6's base theme paints one for exactly this reason (#f5f5f5light /#333338dark), andeditorSurfaceTheme's "force every surface transparent" sweep had clobbered it, which also let the selection wash (z-index -1) and caret layer (150) bleed through. Fix: the gutter paintsvar(--color-bg), matching what all four mount hosts paint.!importantis load-bearing — every@uiwsyntax theme setsgutterBackgroundat equal specificity, and CM6's.ͼ-base.cm-dark .cm-guttersis higher. Do NOT "fix" it withbackground-color: inherit: the chain up through.cm-scroller/.cm-editoris alltransparent !important, and DiffPane's CodeMirror parent sets no background at all, so it resolves back to transparent and the bug returns silently in the diff viewer. -
A brand-new agent tab, closed and reopened before the first prompt, comes back with "No conversation found". termic mints the session uuid and passes it as
--session-id <uuid>, but the CLI only writes that session to disk once there IS a conversation. Persisting the uuid at spawn time (the oldRESUME_FAILURE_MStimer did) meant the next spawn passed--resume <uuid>for a session file that never existed, and the agent refused to start from it.TerminalPanenow holds the minted uuid inpendingSessionUuidRefand persists it on the FIRST real submit (keyboard Enter or a broadcast stampinglastInputAt), which is the same moment the capture-based agents (opencode) harvest theirs. A tab closed before any prompt therefore stores no session id, which is correct: there is nothing to resume. -
Anything termic shells out to must get
shell_env::spawn_env(), andsh -lcis NOT a substitute (GH #243, GH #181). A GUI-launched.appinherits launchd's bare PATH (/usr/bin:/bin:/usr/sbin:/sbin), and-lonly sources bash's profile chain, never~/.zshrcor~/.zprofilewhere Homebrew, nvm, volta and opencode's own installer put their PATH export. Sosh -lc "opencode session list"iscommand not foundfrom the shipped app and works from everynpm run tauri:devyou test it in. This has now bitten three sites for the same reason: CLI detection (a freshly installed agent stayed invisible across relaunches, 8b03dbf), the find-in-files backend (#181), andrun_capture_command, where the damage was invisible because an empty capture is indistinguishable from "the agent hasn't created a session yet" — opencode tabs silently never stored a session id and started a fresh conversation on every relaunch. Useshell_env::spawn_env()(PATH + the rc delta, one snapshot) for a spawn,shell_env::resolved_path()when you only need PATH, and pass-crather than-lconce you have: re-sourcing the profile chain on top of an injected env only risks re-stripping it. -
A sidebar folder stuck on "Loading…" forever (GH #159). The tree's settle reload (
FileTree.tsx, driven byfsRevision) re-read root + every expanded dir, dropped the ones whose read rejected, and then REPLACED the whole children map with what was left.task_dir_listrejects on a transient miss (safe_task_pathcanonicalizes, so a dir that is momentarily absent while a build or a generator rewrites it is ENOENT), which is exactly the moment the reload fires. The dir stayed inexpandedwith no listing, nothing in flight, and no retry, and the row rendered "Loading…" offisOpen && !kidsalone, so a dropped listing looked identical to a slow read. Three separate paths could produce that state (the failed reload, a folder expanded WHILE a reload was in flight and clobbered by the replace, the same drop-on-failure shape in the mount effect), so the fix targets the state, not the trigger: reloads MERGE into the cache (mergeReloadkeeps a failed dir's old listing and anything expanded mid-flight, prunes only what was collapsed), and a reconcile effect enforces the invariant that every expanded dir has a listing, a read in flight, or afailedmark. A read gets one automatic retry, then the row says it failed and offers a retry rather than spinning. Logic inlib/explorer/dirCache.tsso it is unit-testable: there was never a repro, the argument is the state machine. -
A terminal that starts blank because its first bytes were emitted to nobody.
pty_spawnstarts the reader and flusher threads before it returns, and the webview can only calllisten("pty://<id>")after the spawn round trip resolves. Tauri events have no buffering: everything emitted in that gap is dropped silently. A CLI that paints a banner plus one OSC title at startup and then blocks on stdin (every agent fixture, and real agents at their first prompt) can lose BOTH, leaving an empty terminal and a tab with no live title, permanently, because nothing ever repaints. It reproduced as an Activity-spec flake on a loaded CI runner, where the spawn round trip lost its race withbash. The fix is an ack:pty_attachedflips a flag under the reader/flusher buffer mutex, and the flusher holds its first emit (wait_for_attach) until then, or 3s, whichever comes first. A newpty_spawncall site must send the ack, see docs/ipc.md. -
A file-tree error a user cannot report (GH #250). The retry row above shipped saying only "Couldn't read this folder", which is exactly as diagnosable as the "Loading…" it replaced: the report that followed had a screenshot and nothing else. The tree now keeps the rejection message (
failedis aMap<rel, message>, not aSet) and the row renders it throughlib/explorer/dirError.ts: a headline ("Permission denied", "This folder links outside the task") plus the raw Rust error underneath and in the title. The Rust side names the path in everysafe_task_path/read_direrror, so an ENOENT says WHICH path went missing, and a containment rejection says where the symlink pointed. Anything that surfaces atask_dir_listrejection to the user goes throughexplainDirError(the sidebar tree andDirListingPaneboth do), or the next report is a screenshot of a sentence again. Note the class the message makes visible: a folder that is a symlink out of the task lists as a directory but can never be read, so its retry is hopeless by construction, and it is the most likely thing behind a permanently failing folder in a real repo. -
We linked
.claude/into the worktree, then refused to read it (GH #250). The self-inflicted case of the trap above, and the one the report turned out to be.link_config_dirsymlinks.claude/,.gemini/,.codex/(Settings.worktree_symlink_paths) from the repo root into every new worktree because they are commonly gitignored, sogit worktree addleaves them out and an agent spawned there would lose its project subagents and skills.safe_task_paththen canonicalizes and rejects the very link we created, so the folder listed and never opened, in the editor as well as the tree. Reads now go throughsafe_task_read_path, which falls back to allowing a symlink at the task root whose target resolves inside the project root. Three things that bound it, all pinned by tests: it is a FALLBACK (anything that resolved strictly still does, unchanged), only a link at the task root qualifies (one buried in the repo does not widen anything), and a link leaving the project — the\.claude -> ~/.sshcase the check exists for — is still refused. Read paths only:task_path_rename,task_path_deleteand every git path keep the strict check, so nothing can mutate the main checkout by writing through a link from a worktree. -
A second window's
invokeworks, but every plugin call in it silently fails.capabilities/default.jsonis scoped"windows": ["main"], so a window created at runtime (the Activity monitor'sprocmon) is granted NOTHING from the ACL. App-defined#[tauri::command]s are outside the ACL and keep working, which is what makes this confusing: the feature's own IPC is fine whiledata-tauri-drag-region,startDragging, window close-from-JS and every othercore:*/ plugin permission are denied. Symptom is a dead drag region rather than an error. Either add the new label to the capability'swindowsarray or design the window not to need one — the Activity window keeps its NATIVE title bar, so there is nothing to drag-region. -
pbsi_commis 15 characters, not 16, so"com.apple.WebKit"never matches anything.MAXCOMLENis 16 INCLUDING the NUL, so libproc's short-info comm for every WebKit XPC service arrives as"com.apple.WebKi"with the trailingtalready gone. Comparing against the full name found zero candidates, which read as "this Mac has no webview processes" rather than as a string bug (and left the Activity monitor under-reporting Termic's own memory by ~100 MB with no error anywhere). Match a PREFIX. The truncation also means comm cannot tell WebContent from GPU from Networking — they are all the same 15 bytes — so the label has to come fromproc_pidpath. -
macOS makes the TERMINAL responsible for a binary you launched from a shell.
responsibility_get_pid_responsible_for_pidis the only supported way to attribute WKWebView's XPC sidecars (they are children of launchd, so no ppid walk can find them), and it is exactly right for a bundled app:/Applications/Termic.appreports itself, and its WebContent process reports the app. Run the same binary from a terminal and both report iTerm2. So the strict test (responsible == our pid) silently attributes nothing in dev, and the loose one ("same responsibility group") would attribute every WebKit app anyone launched from any terminal window to Termic.procmonkeeps the strict test and reportswebkitUnavailablewhen it finds sidecars it cannot prove are ours, so a dev build says "webview processes not attributable in this build" instead of quietly reporting a smaller number. Measured before choosing, with a ctypes probe against both a bundled and a shell-launched instance. -
A freshly created Tauri window appears in
getWindowHandles()before its document loads. A single scan for the new window bylocation.hreftherefore findsabout:blankand misses it, which fails as "the window never opened" while the window is plainly on screen. Poll the lookup (waitForActivityHandleine2e/specs/activity.e2e.ts), do not scan once. -
A
termic://link opens the OTHER Termic and does nothing. Only reproduces withmake betainstalled, and it is not the link. Both bundles register the scheme on purpose (one link, whichever app is open) and they are mutually exclusive, so LaunchServices sometimes launches the bundle that is NOT holding the socket. That process used to raise the owner andstd::process::exit(0)insidesetup— before the URL had arrived. On macOS the URL is never inargv; AppKit delivers it as an Apple Event that only lands once the run loop turns, andtauri-plugin-deep-linkrecords it solely fromRunEvent::Opened, soget_current()insetupis alwaysNonethere and the link died with the process.handoff_deep_link_then_exit(lib.rs) now returns fromsetupwith no window, goesActivationPolicy::Accessoryso the doomed process takes no dock slot and steals no focus, polls the plugin for 1.5s, forwards over the socket and exits either way. The general trap: on macOS nothing you need from the run loop is available insidesetup. If a launch decision depends on it, defer the decision, do not sample it early and find it empty. -
A second command name for the same socket is a lie, not a convenience.
make betaused to installtermic-betanext totermic. Same binary, same data dir, same socket, same single instance — sotermic-betadrove the SHIPPED app whenever the shipped app was the one running, while its name said otherwise, and nothing anywhere reported the mismatch. There is one release command now (install_name), andreconcile_linkmigrates a leftover on launch. Before adding a flavored name, check whether the flavors actually address different things; if they resolve to the same endpoint, the name is decoration over an ambiguity. -
An upgrade gated on the new state can never perform the upgrade.
agent_hooks_syncskipped any agent whosestatus().installedwas false, andinstalledis an ALL over the CURRENT event set, so addingSessionStartto claude made every existing claude install read as not-installed and therefore ineligible for the sync that would have added it. Agents whose sets had NOT changed upgraded fine, which is what makes it so quiet: the feature looks like it works. Gate an unattended upgrade on CONSENT (ours_present: any entry of ours, safe becauseremovedeletes them all), never on conformance to the state you are trying to reach. The unit suite was green throughout, because every piece was right and only the pair was wrong. -
A self-upgrade mechanism that nothing calls is worse than none, because the docs describe it.
agent_hooks_syncshipped complete: versioned schema, staleness detection, in-place replacement, a Rust doc comment arguing why the user should not have to press a button, and a TypeScript type comment promising installs are "kept up to date automatically byagentHooksSync". Nothing invoked it, for two schema bumps. Every existing install stayed pinned at its original version while reportinginstalled: true, so the v3 fix for hooks being DEAD inside Docker reached only new installs. Nobody noticed because every layer described the intended behaviour and the missing piece was one call. When a mechanism has a version number, grep for its call site before trusting it, and pin the wiring in a test rather than the mechanism alone. -
Typing into an agent that has "painted and gone quiet" can KILL it, not just lose the prompt. claude shows
Is this a project you created or one you trust?for a repo it has never run in. Trust resolves through the REPO, not the directory, so a worktree of an already-trusted repo inherits it and never prompts (measured both ways, after the opposite was assumed): the exposure is the FIRST task in a newly added project, not every task. The picker paints and then goes quiet, which is byte-for-byte what a waiting input box looks like, sowaitForAgentReadyreturnedsettled,seedPromptWhenReadytyped the first message into the picker, and the submit 450ms later confirmed the HIGHLIGHTED option, which isNo, exit. Measured: one injection at termic's own 3s floor, agent gone. No hook fires while the picker is up, not evenSessionStart, so the absence of a readiness signal is itself the signal. Two defences, and both are needed because agents without hooks get no signal at all:SessionStartregistered asSignal::Ready, anddeliverMessage({ verifyEcho: true }), which withholds the submit unless the agent echoes what was typed (an input box echoes, a selection list does not). A retry loop is not a fix here and makes it worse: the first attempt lands before the dialog is interactive and is swallowed, the retry lands on a live picker and presses Enter. Never retry an injection without a positive signal that the agent is reading. -
An agent CLI's own policy layer can outrank the flag Termic passes, and it kills the spawn rather than downgrading it (GH #274). codex 0.15x merges a MANAGED requirements layer on top of your config:
/etc/codex/requirements.toml,/etc/codex/managed_config.toml, macOS MDM managed preferences (config/src/loader/macos.rs), andcloud_requirementspushed by the ChatGPT org a work account belongs to. If any of them forbidsdanger-full-access, Termic's shipped codexyolo_args(--dangerously-bypass-approvals-and-sandbox) makes codex exit at startup withapproval_policy = "never" cannot be used because requirements do not allow sandbox_mode = "danger-full-access". It reads as "the Termic update broke codex" because the error names Codex config and nothing points back at the Settings field that passed the flag; the reporter's machine was the only one affected precisely because it was their WORK machine. Nothing on the Termic side changed: the flag has been the codex default since the initial commit (73fe81c), and no migration inload_settingsre-seedsyolo_args. Do NOT change the shipped default to dodge this. The flag's own help text says it is "intended solely for running in environments that are externally sandboxed", which is exactly Termic's model (seatbelt or Docker is the real boundary, see sandbox.md). The intent-preserving per-agent override is-a never -s workspace-write: still no approval prompts, codex's own workspace sandbox instead of none. Clearingyolo_argsaltogether also starts codex, but leaves the YOLO toggle reading as on while doing nothing.YOLO_ARGS_NOTES(src/lib/agents.ts) is where that caveat is surfaced in the Settings hint, keyed bybuiltinBaseIdso clones get it too. Verified on codex-cli 0.153.0: the flag works with no managed layer present, and a repo-local.codex/requirements.tomlis NOT one of those sources (only the managed paths above count), so this cannot be reproduced without root or an org account.
React/Zustand traps
- Don't return new objects/arrays from selectors without memo. Use frozen constants for defaults.
- Async setup in
useEffectwith cleanup — never in component bodies. - Effect deps: stable IDs (
ws.id,tab.id), never ws/tab objects (identity changes every patch). - StrictMode is off. Audit before re-enabling.
visibleis notownsFind, and a pane claiming anything global needs the stricter one (GH #71). MainArea keeps every visited task mounted, and TaskView keeps every tab's content mounted, so "is this laid out" is a per-task answer that several components say yes to at once. A window keydown listener is the usual casualty: the markdown preview's ⌘F handler is capture-phase and stops propagation, so a background task's mounted preview claiming it doesn't just open a stray bar, it swallows the key from the terminal's search overlay and CodeMirror.TaskViewcomputes both flavors:tabActive(per task, fine for a pane that only focuses itself) andownsFind, which additionally ANDs in "this task is up front" andfocusedTabId()for the focused split pane. Store state gets you only that far, and the naming matters — a prop calledactiveon the preview next to anactiveon the sibling panes is an invitation to widen it back. Two things it cannot answer, both handled in the preview's own listener: (1) the bottom split (⌘J) and the right panel are not in the split tree at all, so a focused AuxTerminal or panel input is invisible to it — checkactiveElement.closest(".xterm, .cm-editor, input, textarea")and stand down; (2) a modal leaves the tab underneath stillownsFind. Modals need covering twice over:document.activeElement.closest('[role="dialog"]')catches Radix dialogs that trap focus (hence!trap.contains(container), since the Changelog dialog hosts a preview of its own), but the hand-rolled Settings overlay traps nothing and autofocuses nothing, soactiveElementnever enters it and only the store flag sees it.role="dialog"on that overlay is still right for screen readers, it just isn't load-bearing here.- A retry loop that only advances on
requestAnimationFramenever runs on an occluded window. WebKit freezes rAF when the window is fully covered, on another Space, or minimized — and a retry loop is exactly the code that gets there, because its first attempt is the one most likely to fail (the store update that scheduled it has a React re-render still pending, so the target is oftendisplay:nonefor that instant).lib/tabFocus.tsretried its focus attempts across frames and so gave up silently: expanding the bottom split left the caret in the agent above it, keystrokes went to the wrong terminal, andagent.e2e.tsfailed with focus parked on the wrong.xterm-helper-textareawhile every DOM check said the target was visible and focusable. Retry on a timer instead — a macrotask runs whether the window is on screen or not, and at ~16ms it is the same cadence when it is. Notedocument.visibilityStatewill NOT tell you this is happening: an occluded window still reports "visible".CommandPalette's deferredact()and the e2emouseDraghelper avoid rAF for the same reason. - An unmount flush must ask whether the thing it is flushing still exists (GH #244). A scratchpad's buffer is written on a ~500ms debounce, and
EditorPanealso flushes on unmount so the last few hundred milliseconds of typing survive a tab or task switch. Closing a pad with Discard deletes the record and THEN unmounts that editor: an unconditional final write recreates the pad the user just threw away, and it comes back on the next launch. The flush therefore re-reads the tab from the store first and only writes while it is stilltype: "scratch"— which covers promotion too, since promoting changes the tab's type. The same shape applies to any "save on the way out" path whose subject can be destroyed by the very action that unmounts it. - Never set
preview: trueon a scratchpad tab (GH #244).openPreviewTabrecycles the first tab in the strip carrying that flag, resetting itssyntax/syntaxAutoand retargeting it at a file. Do that to a pad and the buffer is silently swapped for someone's README, with no prompt, because from the recycler's point of view a preview slot is disposable. Pads are created without the flag andpatchTabclears it on the first dirty write anyway; the rule is that nothing may set it back. - A shared persisted namespace may only be pruned by something that can see ALL of it (GH #248).
store/fileViewed.tskeys "mark as viewed" bytaskId → path, and three call sites read it, each seeing a different slice: the Git panel lists UNCOMMITTED files, the Compare panel lists a whole branch diff, andDiffPane's compare walk looks up filesgit statusnever returns at all. The Git panel pruned the map against its own list, which looked like obvious housekeeping and was in fact a wipe: the moment an agent COMMITTED, its uncommitted list emptied and every Compare mark for that task went with it, including files the agent never touched. It presented as "reviewing is randomly reset after an agent run" and was hard to pin down because the trigger is the commit, not the edit. The rule: an entry may expire on its OWN evidence (here, the file'smtime:lenfingerprint moving, which is per-file and needs no list), and the whole namespace may be dropped when its OWNER dies (task archived/deleted, pruned inapp.loadAllnext touseRace.prune). Anything in between needs a list nobody has. Pinned bystore/fileViewed.test.ts, including the empty-working-tree case. - A controlled
<select>whose value matches no<option>does not render blank, it silently re-points at the first option. React'supdateOptionsfalls back to the first non-disabled option when nothing matches, so the control confidently displays a value the app was never set to, and the next pick saves that lie over the real one. Settings → Projects → More → Default CLI is the live case: it stores an agent id, the agent registry it names is edited on a different page (renamed, removed, disabled), and a stale id made the page claim the project defaulted to whichever agent happened to be first. Proven in the real window, not reasoned about: withdefault_cliset to a missing id the select readclaudewhileprojects.jsonsaid otherwise. Reordering the registry does NOT do this (the select re-applies its value on every commit, and the pills are keyed by id) — that part of the report did not reproduce. The fix has two halves and both are load-bearing: render an explicit option for the SAVED value whenever the list does not offer it, so display always equals storage; and keep the stored value valid at the source, soAgentsSectionrepoints every project pinned to an agent it renames or removes. Pinned by the three cases insettings.e2e.ts("project default CLI vs the agent registry"). Any other<select>bound to a user-editable list has the same hole. - A hook below an early return takes down the whole page it lives on, not just its own row (GH #245).
RepositorySectionearly-returns a placeholder when no project is selected (if (!project || !draft) return …), ~170 lines above where its per-field helpers are defined. Addingconst globalBrowser = useApp(s => s.previewBrowser)next to the helper that used it put a hook after that return, so the hook count changed between the no-project and project renders and React tore the component down. The damage is not local: the Settings overlay hosts one section at a time, so a crash there took out Repositories, the tasks-path fields and the named-ports editor as well, and the e2e run reported seven failures in unrelated settings specs plus two in the new one. Nothing pointed at the new field. The tell is that a cluster of specs covering different features on one page all start failing at once in the same commit; treat that shape as "something on this page throws during render" and look for a hook that moved below a return, not for seven separate regressions. Put every hook at the top with the others even when its only consumer is far below.
"Fresh" caches that are fresh by age and stale by content
- The CLI's per-tab snapshot (
resolve_tab_selector). The webview reports tab state into an agent cache the CLI server reads, and the resolver treated the snapshot as authoritative wheneversnap.age <= CACHE_STALE_AFTER. Age is not content: a tab created a second ago is inside the freshness window and absent from the snapshot. Soid=$(termic tab ...); termic logs --tab "$id"answered "no tab matches" for an id the CLI had printed a moment earlier, and the durable fallback that would have resolved it was only consulted when there was NO snapshot at all. An exact id now falls back topersisted_tabseven with a live snapshot present; index and title still require the live strip, which is honest, because they cannot be reconstructed from the record. - The general shape. Any cache whose validity you express as an age answers "is this recent" when the question was "does this contain X". If a caller can learn an identity from one code path (a create that writes to disk) and then use it through another (a read that consults a cache), the cache needs a miss path to the durable source, not a longer TTL.
Split restore invariants
Two things must hold after ensureDefaultTab restores a task, and neither is guaranteed by the persistence rules on their own:
A task always owns at least one MAIN tab. moveTabToPane already refuses to empty main ("main must keep at least one tab"), but that guard counts LIVE main tabs and a plain shell satisfies it. Main-panel shells are deliberately not durable (no session to resume) while split-pane shells ARE, so moving the only agent into a pane and leaving a shell in main persists nothing for main: the guard holds all session and breaks on reopen. The restore path returns before the seed path at the end of the function, so it seeds the default tab itself when restoredMain comes back empty. Seed BEFORE active is derived, or main gets a tab while activeTab stays "" and the pane still renders blank.
A restored split never has an empty leg. pruneLeafTabs drops ids that no longer exist but leaves the leaf standing, so a pane whose tabs did not come back would restore as a blank half of a split that no user action created and only closing the pane by hand clears. dropEmptyLeaves collapses those (the main leaf is exempt: it holds no tabIds by design, its content mirrors activeTab[taskId]). If that leaves a single pane, restore unsplit rather than as a one-legged tree.
Related sharp edge, not currently handled: restoredPaneTabs is only built inside if (task?.split_layout), so a durable pane tab whose layout failed to save is discarded even though its data is sitting in persisted_tabs.
An agent notification does not mean the agent wants you
termic spoofs TERM_PROGRAM=iTerm.app to the PTY, so an agent that supports
iTerm2's notification channel picks it and sends every notification as OSC 9.
That is more than the ones that ask for the user. claude 2.1.251 has eleven
notificationTypes and only five mean needs-you; agent_completed sends
`${label} finished` when a turn SUCCEEDS.
termic badged all of them, so a finished task rang the needs-you bell. It read as an intermittent "random bell" rather than a reliable wrong badge, because claude suppresses that notification for an interrupted or self-driving turn and only sends it on a band change. Intermittency is a reason to go read the agent's own code, not a reason to call it a race.
BUILTIN_NOTIFY_ATTENTION in src/lib/agents.ts is therefore an ALLOW-LIST
per agent, checked before the ignore list: for claude, a body has to say
"needs your" or "needs permission". A deny-list cannot hold, because the
failure mode is a notification type the vendor adds LATER, which is exactly how
this one arrived. With an allow-list a new type is silent until someone looks
at it; with a deny-list it rings.
The user's own capabilities.signals.attention still outranks both, and an
agent with no built-in allow-list stays permissive: for those, a notification
really does just mean the agent wants you.
Custom agent work-done detection (#68)
An agent's working / done / needs-you state is classified from the fastest reliable signal available. For a CUSTOM CLI, use the highest tier it can emit:
- Tier 1 - OSC signals (most reliable, zero config). If the agent emits
OSC 9;4(ConEmu progress),OSC 133;D(FinalTerm command-done),OSC 9/OSC 777(notifications), orBELfrom an idle state, work-done detection already works with no setup. Prefer this if you control the agent. - Tier 2 - title regexes. Settings, Agents, then the Done / Busy / Attention signal fields: one regex per line, matched against the agent's
OSC 0/2title. When any list is set it drives classification (the built-in claude/codex heuristics are the fallback for empty). Precedence: attention > busy > done. Invalid patterns are flagged in the UI and ignored, never crash the terminal. The claude/codex heuristics live inBUILTIN_TITLE_SIGNALSas regex sources, are what the classifier actually runs, and are shown as the placeholder in those fields, so an empty field displays what runs today. Anything added there must stay correct when pasted in as user signals: user patterns are evaluated busy-before-idle, which is why claude's busy source excludes✳even though the old inline code (idle-first) did not have to. - Tier 2b - "still working" screen patterns. The Done/Busy/Attention fields all read the TITLE, and some agents end their turn while work continues, so the title honestly says idle and no byte-stream signal disagrees. Claude does this whenever it backgrounds a subagent: measured, a done badge held for 617s while three subagents ran, with the title on
✳, no OSC 9;4, no bell, and a notification byte-identical to the one it sends when genuinely blocked. The only dissent is the agent's own words on screen (Waiting for 3 background agents to finish). Settings → Agents → "Still working" holds regexes matched against the BOTTOMPENDING_TAIL_ROWS(8) rows of the viewport; while one matches,fireDonebails without spending the one-done-per-submit token and RETURNS FALSE, so the 3s interval demoters re-test and the real done lands on the first tick after the line clears. That retry is the whole design and it only works if every caller honors thefalse(see "A held done still has to land"). Not a fourth state (a match means plain busy) and not a reuse of the busy list, because patterns are input-specific: claude's busy title source^\s*[^A-Za-z0-9\s✳]matches nearly every line it draws. Positional on purpose — that status line stays in the scrollback long after it stops being true (measured on screen 120s after the agent finished), so an anywhere-in-viewport match would pin the tab to working until the 10-minute ceiling. - Tier 3 - output-line scan (opt-in). The "Also scan output lines" toggle matches the same patterns against stdout LINES, for CLIs that print status but set no title. Higher cost on chatty agents; off by default, and read at spawn, so it needs a terminal restart to take effect. Lines break on CR or LF (a status line repainted with a bare
\rnever sends a newline), are ANSI-stripped, and are length-capped.
work_done: false still disables the whole machine (badge, bell, notification) for an agent. The classifier lives in lib/agents.ts (classifyAgentTitle, unit-tested); Tier 3 scanning is in TerminalPane's data sink.
Recording what an agent actually emits
Before writing a pattern (or concluding an agent signals nothing), record it. Three localStorage flags, all read at spawn, so set them in the webview console and then restart the terminal:
debugWorkDone = "1"— state-machine narration to the console: every transition with its reason, plus unrecognised OSC ids.ptyDebug = "1"— per-PTY log toOS_TEMP_DIR/termic-pty-<task>-<cli>-<ptyId>.log(find the dir withpython3 -c 'import tempfile; print(tempfile.gettempdir())'). Timestamped, withdatachunks truncated at 500 B.ptyDebugRaw = "1"— impliesptyDebug, and makes the log lossless: chunks logged whole, plus araw-OSC/raw-DCS/raw-APC/raw-PM/raw-BELline for every control sequence (lib/ctrlSniffer.ts). Use this one for signal archaeology. The other two both lie by omission in the same direction: the 500 B cap slices the trailing escape off a full TUI repaint, and thedebugWorkDonesniffer skips the ids we already consume (0/1/2/9/1337) and only matches sequences that land whole inside one chunk, so "the agent emitted nothing" and "one of our handlers ate it" look identical.
The log holds verbatim terminal output. It is local and opt-in, but delete it when you are done.
Joining a recording to ground truth. Deciding whether a done was false needs a second, independent timeline; the log alone only says what we decided, not whether we were right. The spawn line carries the join keys: t0 / t0iso (every later +Nms is relative to it) plus session and cwd. Claude appends its transcript to ~/.claude/projects/<cwd-with-/-and-.-as-->/<session>.jsonl in real time, and termic minted that uuid via --session-id, so the pairing is exact rather than inferred. Line up our state→done against the transcript's message timestamps and a premature done becomes a measured gap: fired at T, turn actually ran until T+n, and the transcript says what was running in between (a Task tool call, for instance). Codex and opencode have no equivalent stable pairing, so for those the transcript oracle is unavailable and the log stands alone.
Notifications (OSC 9 / OSC 777) are attention, not done
An agent asking the terminal to notify is asking for the user, so these raise attention and carry their verbatim body to the OS banner via unread.message. Three things they are NOT:
- Not a done. They used to settle to
done, on the theory that the notification is a straggler arriving after a turn we already called. It is the opposite: for claude it is the only signal emitted when it is blocked, and it lands a fixed 6.0s after the title goes idle, i.e. always just behind byte-quiet (4s) and the settle timer (5s). Attention is therefore allowed to land on top of a done we already fired (goAttentiondeliberately does not checkdoneFiredSinceSubmitRef). Racing it by slowing the demoters would make every genuine completion feel sluggish. - Not always actionable. Claude sends
Claude is waiting for your input60.0s after any turn you did not reply to.BUILTIN_NOTIFY_IGNOREdrops it; anything else an agent says is badged, since asking to notify states the intent. An agent'sattentionlist acts as an allow-list override. - Not forwarded from the terminal. The body reaches the banner through
unread.messageanduseAttentionNotifier. Forwarding it directly AND marking unread fires two banners for one event, which is why the old code had to keep OSC 9 away frommarkAttentionentirely, and therefore why it never produced a needs-you badge.
Suppressed when the agent is already back at work, or when the Tier 2b check says work is outstanding: a background-subagent wait emits a byte-identical notification to a real permission prompt, so the body alone cannot separate them.
BEL means xterm's onBell, never a byte scan
u8.indexOf(0x07) cannot tell a bell from the terminator of an OSC: ESC ] 0 ; title BEL ends in 0x07 and claude repaints its title about once a second. That is what the old "agents emit BEL every ~1s during their spinner" comment was describing — not a bell, a title. It was also chunk-dependent: across three byte-identical OSC 9 sequences in one recording it fired on two and missed the third, so needs-you badges appeared at random. In 1300 lines of recorded claude output there was not one genuine bell. Let the VT parser decide.
A held done still has to land
Holding a done (Tier 2b) is only safe because something retries it. Two things quietly removed that retry, and together they pinned tabs to "working" until the user clicked them, which is the opposite failure from the one the hold exists to prevent:
- A demoter that gives up its tick on a held done. Byte-quiet used to
returnas soon as it calledfireDone, fired or not. An idle agent that stops painting is byte-quiet on EVERY tick, so thatreturnran every 3s and the two ceilings below it never executed — including the 10-minute absolute one, the only path that outranks a hold (force: true). A status line that never clears (a background shell that outlives the turn, or the words still sitting in the tail after the work landed) then held the tab forever. - A demoter that latches itself off on a held done. Settled-hash and scrollback-stability set
markedafter firing and only clear it when the screen CHANGES. A hold leaves exactly the screen that never changes, so latching on a held done means that path never runs again for the rest of the turn.
So: fireDone returns false only when the hold caught it, and every caller treats that as "not done yet" — no return, no marked. The absolute ceiling is the backstop, and localStorage.workDoneCeilingMs shortens it so a test can actually reach it (ten minutes is not a thing a spec can wait out, which is why it stayed broken).
A done we got wrong must not outlive the evidence
Every heuristic here can read a stage boundary in a long multi-stage turn as the end of it. done used to be permanently sticky (if (cur.workState === "done" && state === "working") return s), so a premature one could not be undone by anything the agent did: no spinner for the rest of the turn, the turn's one done token already spent so the real completion badged nothing, and the only way out was clicking the tab.
STICKY_DONE_MS (8s, lib/agents.ts) makes it a window instead. Inside it, a busy signal is claude's post-response ✳ ↔ spinner flicker and is still ignored. Past it, the agent is working and the tab goes back to "working", the stale done bullet is dropped (an attention badge stays: the agent asked for the user in its own words), and TerminalPane hands the turn its done token back so the real ending can badge. Both halves read the same constant, or the spinner and the token come back at different times.
One turn is allowed to interrupt the user ONCE, and that is not the same thing as one done per turn (GH #276). The paragraph above is the fix that created this bug. Taking a premature done back means clearing its unread mark, and the turn's real ending then sets it again, so the tab's unread goes null → set → null → set across a long turn. That transition IS the OS notification: useAttentionNotifier fires on the rising edge. Every stage boundary therefore cost one banner, which a user reported as "notifications for what seems like every action". The notifier's own 8s debounce is no defence against a turn measured in minutes.
Both paths reach it and neither shared a guard with the other, so both had to be fixed at once. The heuristic path re-earns the right to notify from goWorking, which hands the done token back past STICKY_DONE_MS; the hook path never needed the token at all, because a 133;D calls fireDone with fromHook, which bypasses it outright. (Codex is now a hooks agent too, which removes its exposure to the heuristic half entirely rather than merely bounding it: see the codex section of agent-hooks.md. The bound still matters for every agent without hooks, and for any agent whose hook transport dies mid-session.) Real claude with shell integration emits CDCDCDCDCD on an ordinary task, so this was the common case, not an exotic one: a captured termic-workstate.log showed WDWDWDWDWDWD with watching=false on every done.
The fix is Tab.unread.repeat, set by the PRODUCER, because only the producer knows where the turn began. TerminalPane keys a latch on lastInputAt (the one field every send path already stamps: keyboard Enter, the queue, a broadcast, seedPrompt, runPrompt, sendComments, the CLI and deep-link paths), so "the turn moved on" needs no reset hook of its own and cannot be missed by a path added later. Nothing the agent does touches it. A repeat still marks the sidebar dot and does not raise a banner.
Two things about it are load-bearing and easy to undo by accident. The flag must not suppress the DOT, only the banner: the dot tracks state and both stage boundaries are real. And the notifier's edge is measured on newsworthiness, not truthiness (lib/attentionNotify.ts) — a suppressed repeat leaves unread SET, so a plain if (prev.unread) continue would swallow a genuine needs-you landing on top of it, i.e. the agent asking for permission in silence. Both are pinned: attentionNotify.test.ts, store/unreadRepeat.integration.test.ts, and the #stage / #hookstage fixture drills in agent.e2e.ts, which assert one newsworthy edge and two dots for the same turn.
Any tier that classifies a state must write senderStateRef. The interval demoters (byte-quiet, settled-hash, scrollback) read it for two decisions: a busy value suppresses them entirely, and a null value means "this agent has never signalled anything", which downgrades their verdict from done to attention. A classifying path that skips the ref leaves a title-less agent looking mute, so byte-quiet fires at QUIET_MS (4s, under SETTLE_MS) through any silent think and rings the attention bell mid-turn, which is the bug #68 opened about.
A notification termic ITSELF installed must not be filtered like agent chatter. notifyAttention exists to sort an agent's own notifications into needs-you and noise, and two of its guards are actively wrong for termic's agent hook (docs/agent-hooks.md). A user's attention list is an ALLOW-LIST, so teaching termic what THEIR agent says when it needs them silences our hook, which says something else. And the "agent already back at work" check reads workState, which goIdle does not clear: it arms the settle and leaves the tab reading "working" for the whole SETTLE_MS. Claude paints its idle title about 20ms BEFORE the hook fires, so a hook notification always lands inside that window and was dropped every single time. Both are skipped when OSC 777's title field is HOOK_OSC_TITLE (lib/agentHooks.ts), which is how a signal termic installed is told apart from one the agent chose to send. The e2e fixture seeds an attention allow-list for fakeagent precisely so this stays caught.
And an agent's idle state must actually classify, or the ref latches busy forever. senderStateRef is only assigned when classifyAgentTitle returns non-null (if (state) senderStateRef.current = state) and is only reset on PTY spawn. So an agent whose BUSY title matches but whose IDLE title does not gets the worst of both: one spinner frame sets the ref to busy, nothing ever clears it, and because all three demoters are gated on !senderBusy the tab can never leave "working". Measured on Codex 0.142.5, which dropped the status words the built-in patterns were written for: its title is the cwd basename when idle (proj) and a Braille frame plus the basename when working (⠋ proj), so \bReady\b never matched and every Codex tab latched on its first turn. Fixed by giving codex a general idle pattern (a title whose first non-space character is not a spinner frame) alongside Ready. When adding or revising an agent's signals, check that its idle title classifies, not just its busy one.
A general idle pattern needs the same submit gate the busy branch has. Codex paints its spinner during startup, so an ungated busy→idle transition arms a settle on a tab nobody has typed into and badges a "done" for a turn that never happened. lastTitleState is recorded even when the busy branch is suppressed, so gating only the busy side is not enough.
Neither Claude nor Codex emits OSC 9;4 any more (checked across ten PTY captures on Claude Code 2.1.250 and Codex 0.142.5, including a 150s run). Tier 1 above still describes the protocol correctly and the handler stays for agents that do emit it, but for these two the title is the only busy/idle source in practice.