From e5215d71ba09ce1d4e4060891f1c71dda075def4 Mon Sep 17 00:00:00 2001 From: prompt.ac/@jeffrey Date: Wed, 15 Jul 2026 22:58:00 +0000 Subject: [PATCH] slab: stabilize Codex sessions and prox notifications --- slab/bin/codex-session-watch.mjs | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------- slab/bin/codex-slab | 2 +- slab/bin/imsg.mjs | 16 ++++++++++------ slab/bin/lid-ambient.sh | 21 ++++++++++++++------- slab/bin/prox-mcp.mjs | 49 +++++++++++++++++++++++++++++++++++++++++++++---- slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift | 215 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------------- slab/menubar-swift/Sources/SlabMenubar/IconRenderer.swift | 7 ++++--- slab/menubar-swift/Sources/SlabMenubar/Ledger.swift | 16 ++++++++++++---- slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift | 2 +- slab/menubar-swift/Sources/SlabMenubar/Paths.swift | 4 ++++ slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift | 13 +++++++------ slab/menubar-swift/Sources/SlabMenubar/SigilRenderer.swift | 12 ++++++++++-- slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift | 8 ++++---- slab/menubar-swift/Sources/SlabMenubar/TerminalFontZoomGuard.swift | 221 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 14 file(s) changed, 580 insertion(s)(+), 106 deletion(s)(-) diff --git a/slab/bin/codex-session-watch.mjs b/slab/bin/codex-session-watch.mjs --- a/slab/bin/codex-session-watch.mjs +++ b/slab/bin/codex-session-watch.mjs @@ -8,16 +8,17 @@ // use, so the menubar reducer stays agent-agnostic: // task_started → working (rewrite active marker, drop awaiting, touch running-tools) // task_complete → complete (write awaiting "turn complete", drop running-tools) // -// Usage: node codex-session-watch.mjs +// Usage: node codex-session-watch.mjs // Launched (and killed) by codex-slab.sh. Exits when the wrapper pid dies. -import { readdir, readFile, writeFile, stat, unlink, utimes } from "node:fs/promises"; +import { readFile, writeFile, stat, unlink, utimes } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import { join } from "node:path"; import { homedir } from "node:os"; -const [sid, beginArg, wrapperArg] = process.argv.slice(2); +const [sid, _beginArg, wrapperArg, tty = "", cwd = ""] = process.argv.slice(2); if (!sid) process.exit(1); -const beginSec = Number(beginArg) || Math.floor(Date.now() / 1000); const wrapperPid = Number(wrapperArg) || 0; const SLAB_HOME = process.env.SLAB_HOME || join(homedir(), ".local", "share", "slab"); @@ -25,37 +26,47 @@ const ACTIVE = join(SLAB_HOME, "state", "active-prompts", sid); const AWAITING = join(SLAB_HOME, "state", "awaiting-prompts", sid); const RUNNING = join(SLAB_HOME, "state", "running-tools", sid); const SESSIONS = join(process.env.CODEX_HOME || join(homedir(), ".codex"), "sessions"); +const execFileAsync = promisify(execFile); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const nowISO = () => new Date().toISOString().replace(/\.\d+Z$/, "Z"); const wrapperAlive = () => { if (!wrapperPid) return true; - try { process.kill(wrapperPid, 0); return true; } catch { return false; } -}; - -async function walk(dir, out = []) { - let entries; - try { entries = await readdir(dir, { withFileTypes: true }); } catch { return out; } - for (const e of entries) { - const p = join(dir, e.name); - if (e.isDirectory()) await walk(p, out); - else if (e.isFile() && e.name.startsWith("rollout-") && e.name.endsWith(".jsonl")) out.push(p); + try { process.kill(wrapperPid, 0); return true; } + catch (error) { + // Sandboxed launch contexts may forbid signalling an otherwise-live + // sibling. EPERM proves the PID exists; ESRCH is the actual dead case. + return error?.code === "EPERM"; } - return out; -} +}; -// Find the rollout file for THIS codex session: the newest one created at/after -// the wrapper's start. Poll until it appears (codex writes it on session start). +// Find the rollout file for THIS Codex process. Concurrent windows often share +// cwd and start within the same second, so "newest file" cross-wires their +// rocks. The wrapper and Codex are parent/child; Codex keeps its own rollout +// open for writing, giving us an exact, resume-safe association via lsof. async function findRollout() { for (let i = 0; i < 40 && wrapperAlive(); i++) { - const files = await walk(SESSIONS); - let best = null, bestM = 0; - for (const f of files) { - let m; - try { m = (await stat(f)).mtimeMs; } catch { continue; } - if (m >= (beginSec - 3) * 1000 && m > bestM) { best = f; bestM = m; } + try { + // BSD/macOS ps has no Linux `-P ` selector. Read its compact + // PID/PPID table and select the wrapper's children ourselves. + const { stdout: processes } = await execFileAsync( + "/bin/ps", ["-axo", "pid=,ppid="]); + const pids = processes.split("\n").map((line) => line.trim().split(/\s+/)) + .filter((parts) => parts.length >= 2 && Number(parts[1]) === wrapperPid) + .map((parts) => parts[0]) + .filter((p) => Number(p) !== process.pid); + for (const pid of pids) { + const { stdout } = await execFileAsync("/usr/sbin/lsof", ["-Fn", "-p", pid]); + const rollout = stdout.split("\n") + .filter((line) => line.startsWith("n")) + .map((line) => line.slice(1)) + .find((p) => p.startsWith(SESSIONS + "/") + && p.includes("/rollout-") && p.endsWith(".jsonl")); + if (rollout) return rollout; + } + } catch { + // Codex may not have opened its rollout yet; retry below. } - if (best) return best; await sleep(500); } return null; @@ -63,8 +74,21 @@ } // Read the marker, merge fields, write it back (atomic-ish). async function updateMarker(patch) { - let obj = {}; - try { obj = JSON.parse(await readFile(ACTIVE, "utf8")); } catch {} + // Keep enough launch metadata here to reconstruct a marker if an external + // janitor removes it while Codex is still alive. Without this baseline the + // next rollout event recreated only `{state, updated}`, losing the tty and + // agent type that Slab needs to theme the terminal. + let obj = { + session_id: sid, + cwd, + subject: "codex session", + summary: "codex", + tty, + agent_pid: wrapperPid, + agent_type: "codex", + state: "blank", + }; + try { Object.assign(obj, JSON.parse(await readFile(ACTIVE, "utf8"))); } catch {} Object.assign(obj, patch, { updated: nowISO() }); try { await writeFile(ACTIVE, JSON.stringify(obj)); } catch {} } @@ -122,19 +146,22 @@ async function main() { const file = await findRollout(); if (!file) process.exit(0); - // Start tailing from EOF — historical turns already happened; we only care - // about live transitions from here on. (A fresh `co` launch has an empty log.) + // Replay once from the beginning so a resumed Codex window immediately + // inherits its real last state (usually complete) instead of sitting blank + // or aging into interrupted until the user submits another prompt. After + // that first pass `offset` makes this an ordinary incremental tail. let offset = 0; - try { offset = (await stat(file)).size; } catch {} const ctx = { lastUser: "", pending: [] }; while (wrapperAlive()) { let size = offset; try { size = (await stat(file)).size; } catch { break; } if (size > offset) { - let chunk = ""; - try { chunk = await readFile(file, { encoding: "utf8" }); } catch { chunk = ""; } - // Re-read whole file (rollouts are small) and process only new tail. - const tail = chunk.slice(offset); + let chunk = Buffer.alloc(0); + try { chunk = await readFile(file); } catch { chunk = Buffer.alloc(0); } + // Offsets from stat are bytes, not JavaScript UTF-16 character counts. + // Slice the Buffer first so emoji/non-ASCII output can never skew the + // tail boundary or cause an already-seen completion to be replayed. + const tail = chunk.subarray(offset).toString("utf8"); offset = chunk.length; for (const line of tail.split("\n")) if (line.trim()) handleLine(line, ctx); // Apply transitions in order; last one wins the visible state. @@ -145,4 +172,7 @@ await sleep(600); } } -main().catch(() => process.exit(0)); +main().catch((error) => { + console.error(`codex-session-watch: ${error?.stack || error}`); + process.exit(1); +}); diff --git a/slab/bin/codex-slab b/slab/bin/codex-slab --- a/slab/bin/codex-slab +++ b/slab/bin/codex-slab @@ -52,7 +52,7 @@ # Background watcher: derives per-turn state from the rollout JSONL. watcher="" if command -v node >/dev/null 2>&1 && [[ -f "$SLAB_BIN/codex-session-watch.mjs" ]]; then - node "$SLAB_BIN/codex-session-watch.mjs" "$sid" "$begin" "$$" \ + node "$SLAB_BIN/codex-session-watch.mjs" "$sid" "$begin" "$$" "$tty" "$cwd" \ >/dev/null 2>&1 & watcher=$! fi diff --git a/slab/bin/imsg.mjs b/slab/bin/imsg.mjs --- a/slab/bin/imsg.mjs +++ b/slab/bin/imsg.mjs @@ -139,7 +139,11 @@ function sqlite(query) { // Read-only via URI; -json so blobs ride out as hex strings safely. const r = spawnSync( SQLITE3, - ["-readonly", "-json", `file:${CHAT_DB}?mode=ro`, query], + // Messages briefly takes an exclusive lock while committing. A short busy + // timeout lets the passive watcher ride through that normal write window + // instead of reporting a false failure; the surrounding Slab poll still + // has an 8-second hard timeout. + ["-readonly", "-cmd", ".timeout 1500", "-json", `file:${CHAT_DB}?mode=ro`, query], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }, ); if (r.status !== 0) { @@ -376,13 +380,13 @@ if (!st.primed) { // First run: baseline silently, never blast the bell for history. st.primed = true; st.lastNotifiedRowid = s.maxInbound; - } else if (s.maxInbound > st.lastNotifiedRowid && s.unread > 0) { - newSinceLast = true; - st.lastNotifiedRowid = s.maxInbound; - ringBell(cfg); } else if (s.maxInbound > st.lastNotifiedRowid) { - // New inbound that's already marked read elsewhere — track, don't ring. + // Arrival is an event even if another Apple device marked the thread read + // before this poll. Slab/prox consumers still need the edge; only the + // audible bell remains conditional on the message being unread here. + newSinceLast = true; st.lastNotifiedRowid = s.maxInbound; + if (s.unread > 0) ringBell(cfg); } saveState(st); diff --git a/slab/bin/lid-ambient.sh b/slab/bin/lid-ambient.sh --- a/slab/bin/lid-ambient.sh +++ b/slab/bin/lid-ambient.sh @@ -99,13 +99,20 @@ fi pkill -f slab-monitor.sh 2>/dev/null } -claude_running() { - # Three shapes of "claude is running": +agent_running() { + # Tracked prompts can belong to Claude or Codex. Keep the global stale- + # marker sweep agent-aware: treating "no Claude" as "no work" deletes + # live Codex markers, which also makes the menubar stop re-theming those + # terminal tabs when macOS flips appearance. + # + # Three shapes of "Claude is running": # 1. compiled bundled CLI — process name is literally "claude" # 2. legacy node-based CLI — node .../@anthropic-ai/claude-code/cli.js # 3. desktop-app embed — .../claude.app/Contents/MacOS/claude - # pgrep -x catches (1) cheaply; the regex catches (2) and (3). + # Codex's compiled CLI is likewise named literally "codex". pgrep -x + # catches both native CLIs cheaply; the regex catches Claude's other two. pgrep -x claude >/dev/null 2>&1 \ + || pgrep -x codex >/dev/null 2>&1 \ || ps -eo command 2>/dev/null | grep -qE 'claude\.app/Contents/MacOS/claude |@anthropic-ai/claude-code/.*cli\.js' } @@ -137,11 +144,11 @@ while true; do lid_state=$(ioreg -r -k AppleClamshellState -d 4 | awk '/AppleClamshellState/{print $NF; exit}') sleep_disabled=$(pmset -g | awk '/SleepDisabled/{print $2; exit}') sleep_disabled=${sleep_disabled:-0} - claude_alive=0 - claude_running && claude_alive=1 + agent_alive=0 + agent_running && agent_alive=1 active_count=$(active_work_count) - # drop stale markers if no Claude process is around at all - if (( claude_alive == 0 && active_count > 0 )); then + # Drop stale markers only when no supported agent process is around at all. + if (( agent_alive == 0 && active_count > 0 )); then rm -f "$ACTIVE_DIR"/* "$SUBAGENT_DIR"/* 2>/dev/null active_count=0 fi diff --git a/slab/bin/prox-mcp.mjs b/slab/bin/prox-mcp.mjs --- a/slab/bin/prox-mcp.mjs +++ b/slab/bin/prox-mcp.mjs @@ -5,8 +5,9 @@ // menubar parks over every live Claude session across the fleet. // // A "rock" is one live session (or headless agent), advertised by its machine // as `host:name` — e.g. neo:regif, blueberry:flock, panda:iris. The name is the -// sigil pet-name (deterministic from the session's prompt seed), so it matches -// exactly what you see rendered on that machine's overlay. This is how a +// stable pet-name (deterministic from the session/thread id), so it matches +// exactly what you see rendered on that machine's overlay even as the rock's +// prompt-driven texture and form evolve. This is how a // `machine:promptname` reference resolves without an SSH+find crawl. // // The data source is the fleet ledger the menubar already publishes + caches: @@ -19,7 +20,7 @@ // server (:5252 /poke {by,id,name}), which makes its rock blink + rattle. // // Hand-rolled JSON-RPC over stdio, matching the house style of the sibling // frame-mcp / puppet-mcp — no SDK, only node builtins + the shared front. -import { readFile, readdir } from "node:fs/promises"; +import { readFile, readdir, writeFile, mkdir } from "node:fs/promises"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { join } from "node:path"; @@ -33,6 +34,7 @@ const LEDGER_DIR = join(homedir(), ".config", "slab", "ledger"); const LOCAL_FILE = join(LEDGER_DIR, "local.json"); const PEERS_DIR = join(LEDGER_DIR, "peers"); const PORT = 5252; // the menubar's LedgerHTTPServer port on every machine +const IMSG_BINDING = join(homedir(), ".config", "slab", "imsg-prox.json"); // Per-session marker files (written by the slab claude hooks) carry the tty + // pid a rock is running on — the same source the menubar overlay reads. Keyed @@ -113,7 +115,10 @@ [host, name] = h.split(":", 2); host = host === "local" ? null : host; // "local:foo" → any host with name foo on self } const inHost = (r) => !host || r.host.toLowerCase() === host || (host === "local" && r.self); - // exact name first, then prefix, then substring — so `neo:reg` finds regif. + // Stable session id is the strongest identity; then exact pet name, prefix, + // and substring — so `neo:reg` still finds regif. + const id = rocks.filter((r) => inHost(r) && r.id.toLowerCase() === name); + if (id.length) return id; const exact = rocks.filter((r) => inHost(r) && r.name.toLowerCase() === name); if (exact.length) return exact; const prefix = rocks.filter((r) => inHost(r) && r.name.toLowerCase().startsWith(name)); @@ -231,6 +236,27 @@ }).catch((e) => { throw new Error(`poke to ${r.host} (${r.ip}) failed: ${e.message}`); }); return [{ type: "text", text: `poked ${r.host}:${r.name} as «${poker}» — its rock should blink + rattle (HTTP ${res.status}).` }]; } +async function toolBindNotification({ handle, event = "imessage", wake = true }) { + if (event !== "imessage") throw new Error("only the `imessage` Slab notification is supported"); + if (!handle) throw new Error("`handle` is required (use the stable host:name or session id)"); + const hits = resolve(await allRocks(), handle); + if (!hits.length) throw new Error(`no rock resolves «${handle}» to bind.`); + if (hits.length > 1) throw new Error(`«${handle}» is ambiguous (${hits.map((r) => `${r.host}:${r.name}`).join(", ")}).`); + const r = hits[0]; + if (!r.self) throw new Error("iMessage notification wake targets must be a local prox on this machine"); + const binding = { + event: "imessage", + sessionId: r.id, + host: r.host, + name: r.name, + wake: wake !== false, + assignedAt: new Date().toISOString(), + }; + await mkdir(join(homedir(), ".config", "slab"), { recursive: true }); + await writeFile(IMSG_BINDING, JSON.stringify(binding, null, 2) + "\n", { mode: 0o600 }); + return [{ type: "text", text: `bound Slab iMessage arrivals to ${r.host}:${r.name} (${r.id}) — poke${binding.wake ? " + reactivate" : " only"}.` }]; +} + async function toolClose({ handle }) { if (!handle) throw new Error("`handle` is required (a `host:name` or fuzzy name; see prox_find)."); const hits = resolve(await allRocks(), handle); @@ -317,6 +343,20 @@ }, required: ["handle"], }, }, + { + name: "prox_bind_notification", + description: + "Assign a Slab notification to one stable local prox. For event `imessage`, every new inbound pokes the rock and, by default, reactivates its terminal session with a steering prompt. The binding uses session id, so it survives prompt/title/visual changes for that thread. This does not send or react to the incoming message.", + inputSchema: { + type: "object", + properties: { + handle: { type: "string", description: "Stable local host:name, session id, or an unambiguous subject fragment." }, + event: { type: "string", enum: ["imessage"], default: "imessage" }, + wake: { type: "boolean", default: true, description: "Also reactivate the agent session; false means visual poke only." }, + }, + required: ["handle"], + }, + }, ]; async function callTool(name, args) { @@ -324,6 +364,7 @@ switch (name) { case "prox_list": return toolList(args || {}); case "prox_find": return toolFind(args || {}); case "prox_poke": return toolPoke(args || {}); + case "prox_bind_notification": return toolBindNotification(args || {}); case "prox_close": return toolClose(args || {}); default: throw new Error(`Unknown tool: ${name}`); } diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -39,6 +39,10 @@ private let wallpaperProbeRetry: TimeInterval = 20 /// One-shot guard so the status-defaults gen is kicked once per launch. private var defaultsKicked = false private var refreshTimer: Timer? + /// Dedicated contact-awareness clock. iMessage must not depend on the + /// heavier fleet snapshot completing; a stalled system probe should never + /// make Slab miss an incoming message. + private var imsgTimer: Timer? private var animTimer: Timer? /// Drives the attention-blink on `.complete` / `.awaiting` terminal /// backgrounds — both states need the user's eyes, and a static blue vs. @@ -87,11 +91,14 @@ /// iMessage bridge state. Polled faster than mail (a chat wants low /// latency) but still off-main; the helper itself rings the bell when a /// NEW inbound arrives. `imsgUnread` is exposed so the theme-by-status /// pipeline can treat "she texted" as a first-class status accent. - private var imsgTickCount = 0 private var imsgPending = false private var imsgStatus = "—" private var imsgConfigured = false private var imsgUnread = 0 + /// Keeps a just-arrived message visible to Slab even if Messages marks it + /// read before our next snapshot. Unread messages keep the signal alive; + /// this short edge pulse covers the actual arrival moment. + private var imsgArrivalVisibleUntil = Date.distantPast /// Asana task state. Polled off-main on a slow cadence (tasks don't change /// second-to-second); the helper itself talks to the Asana REST API and /// the token lives only in the untracked config (see slab-public-repo PII). @@ -106,7 +113,7 @@ private var deployPending = false private var deployState = DeployStatusState() private var state = StateSnapshot() private let passphraseServer = PassphraseServer() - /// System-wide ⌘⌥T → re-tile claude terminals. Kept alive for the app's + /// System-wide ⌘⌥T → re-tile agent terminals. Kept alive for the app's /// lifetime; unregistered in `applicationWillTerminate`. private var tileHotkey: GlobalHotkey? /// System-wide ⌘⌥S → scatter every session into tiny confetti windows. @@ -121,6 +128,11 @@ /// wall (see WindowNav). Four hotkeys, one per arrow; held for the app's /// lifetime and unregistered in `applicationWillTerminate`. private var navHotkeys: [GlobalHotkey] = [] + /// Keeps the focused terminal's pixel frame fixed while its native ⌘+/- + /// command changes only that window's font zoom. This watches Terminal and + /// iTerm2 themselves, so Claude and Codex windows behave identically. + private var terminalFontZoomGuard: TerminalFontZoomGuard? + /// ⌃⌃ → magnify the window under the pointer. Not a `GlobalHotkey`: Carbon /// can only register a keycode+modifier chord, and a bare modifier tapped /// twice isn't one. See CtrlDoubleTap. @@ -153,6 +165,15 @@ self?.refresh() } refreshTimer = timer RunLoop.main.add(timer, forMode: .common) + + // Contact awareness has its own lightweight clock instead of riding + // the fleet refresh. Poll once at launch, then every three seconds. + refreshImsgCount() + let contactTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { + [weak self] _ in self?.refreshImsgCount() + } + imsgTimer = contactTimer + RunLoop.main.add(contactTimer, forMode: .common) // Title-component hygiene for the Slab-* Terminal profiles: the // working-dir and active-process checkboxes are NOT scriptable and @@ -211,6 +232,13 @@ navHotkeys.append(hk) } } + // Terminal.app sizes windows in character cells, so its native font + // zoom also changes the pixel frame. Preserve the frame around that + // native action; the terminal remains responsible for its per-window + // zoom state, and Slab only prevents the geometry side effect. + let fontGuard = TerminalFontZoomGuard() + if fontGuard.start() { terminalFontZoomGuard = fontGuard } + // ⌃⌃ zooms in on the window under the pointer; ⌃⌃ again zooms back out. // The tap listens always — the flag is checked at fire time, not here, so // toggling the feature from the menu doesn't need to tear a tap down. @@ -252,6 +280,8 @@ tileHotkey?.unregister() scatterHotkey?.unregister() appearanceHotkey?.unregister() navHotkeys.forEach { $0.unregister() } + terminalFontZoomGuard?.stop() + imsgTimer?.invalidate() zoomLensTap?.stop() // Compositor zoom outlives us — never quit leaving the screen magnified. if ZoomLens.isZoomed { ZoomLens.zoomOut() } @@ -313,9 +343,12 @@ self.gathering = false self.state = snapshot // gather() doesn't know about iMessage; fold the cached poll // result in here so the icon + decor read one consistent - // picture. Gated on theme-by-status per the accent's contract. - self.state.messageWaiting = - self.imsgUnread > 0 && snapshot.themeByStatus + // picture. This awareness is independent of theme-by-status: + // the menubar should notice an arrival even on an unthemed + // wall. Unread keeps it present; the edge pulse catches a + // message that another device marks read almost immediately. + self.state.messageWaiting = self.imsgUnread > 0 + || Date() < self.imsgArrivalVisibleUntil self.updateIcon() self.updateAnimTimer() @@ -325,15 +358,6 @@ self.mailTickCount += 1 if self.mailTickCount >= 15 && !self.mailPending && !self.mailSyncing { self.mailTickCount = 0 self.refreshMailCount() - } - - // Chat wants lower latency than mail — poll ~10 s. The helper - // detects new inbound and rings the bell itself; we only pull - // the summary back for the menu label + theme accent. - self.imsgTickCount += 1 - if self.imsgTickCount >= 5 && !self.imsgPending { - self.imsgTickCount = 0 - self.refreshImsgCount() } // Asana tasks change on a human cadence, not a chat one — @@ -371,7 +395,7 @@ let acCount = AXTiler.windows(bundleId: "computer.aesthetic.app", requireStandardSubrole: false).count if acCount != self.lastAcWindowCount { self.lastAcWindowCount = acCount - self.tileNowImpl(resetZoom: false) + self.tileNowImpl(resetZoom: true) } } // Pulled `frame` screenshots open in FramePreview, badged with @@ -575,22 +599,120 @@ let line = out.split(separator: "\n").last.map(String.init) ?? "" var label = "iMessage: —" var configured = false var unread = 0 + var newSinceLast = false + var helperError: String? + var lastText = "" if let data = line.data(using: .utf8), let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { label = (obj["label"] as? String) ?? label configured = (obj["configured"] as? Bool) ?? false unread = (obj["unread"] as? Int) ?? 0 + newSinceLast = (obj["newSinceLast"] as? Bool) ?? false + helperError = obj["error"] as? String + if let last = obj["last"] as? [String: Any] { + lastText = (last["text"] as? String) ?? "" + } } DispatchQueue.main.async { - self?.imsgStatus = label - self?.imsgConfigured = configured - self?.imsgUnread = unread - self?.imsgPending = false + guard let self = self else { return } + if let helperError = helperError { + NSLog("💬 [imsg] watcher error: \(helperError)") + } + let wasWaiting = self.state.messageWaiting + self.imsgStatus = label + self.imsgConfigured = configured + self.imsgUnread = unread + if newSinceLast { + self.imsgArrivalVisibleUntil = Date().addingTimeInterval(15) + self.bumpBoundProx(displayLabel: label, message: lastText) + } + self.state.messageWaiting = unread > 0 + || Date() < self.imsgArrivalVisibleUntil + self.imsgPending = false + self.updateIcon() + self.updateAnimTimer() + if wasWaiting != self.state.messageWaiting { + self.applyTerminalDecor() + } } } } + /// Poke the prox explicitly assigned to iMessage awareness and optionally + /// submit a small steering prompt to its live TTY. This is deliberately + /// opt-in via an untracked binding file; Slab never guesses which agent to + /// wake and never sends a message back to the contact. + private func bumpBoundProx(displayLabel: String, message: String) { + guard let data = FileManager.default.contents(atPath: Paths.imsgProxBinding), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let sid = obj["sessionId"] as? String, !sid.isEmpty else { return } + let wake = (obj["wake"] as? Bool) ?? false + LedgerStore.shared.pokeLocal(sessionId: sid, by: "slab:imessage") + guard wake, let tty = ttyForSession(sid), !tty.isEmpty else { return } + + let clean = message.replacingOccurrences(of: "\n", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + let excerpt = String(clean.prefix(240)) + let prompt = excerpt.isEmpty + ? "Slab received a new iMessage from Alex. Check Alex's latest messages and handle the request." + : "Slab received a new iMessage from Alex: \(excerpt) — check Alex's latest messages and handle the request." + wakeTerminal(tty: tty, prompt: prompt) + NSLog("💬 [imsg] poked + woke prox \(sid.prefix(8)) on \(tty) (\(displayLabel))") + } + + private func ttyForSession(_ sid: String) -> String? { + if let tty = state.claudeSessions.first(where: { $0.sessionId == sid })?.tty, + !tty.isEmpty { return tty } + for dir in [Paths.activePromptsDir, Paths.awaitingPromptsDir] { + let path = "\(dir)/\(sid)" + guard let data = FileManager.default.contents(atPath: path), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let tty = obj["tty"] as? String, !tty.isEmpty else { continue } + return tty + } + return nil + } + + private func wakeTerminal(tty: String, prompt: String) { + func esc(_ s: String) -> String { + s.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } + let t = esc((tty as NSString).lastPathComponent) + let p = esc(prompt) + let script = """ + tell application "Terminal" + repeat with w in windows + repeat with tabRef in tabs of w + try + if (tty of tabRef) ends with "\(t)" then + do script "\(p)" in tabRef + return "terminal" + end if + end try + end repeat + end repeat + end tell + try + tell application id "com.googlecode.iterm2" + repeat with w in windows + repeat with tabRef in tabs of w + repeat with sessionRef in sessions of tabRef + if (tty of sessionRef) ends with "\(t)" then + tell sessionRef to write text "\(p)" + return "iterm" + end if + end repeat + end repeat + end repeat + end tell + end try + return "tty-not-found" + """ + ShellRunner.runAsync("/usr/bin/osascript", args: ["-e", script]) + } + /// Pull the Asana task tree off-main via `slab/bin/asana status`. The /// helper always exits 0 and prints one JSON line: `{configured, label, /// projects:[{name, tasks:[{name,url,due,overdue,today}]}]}`. We decode it @@ -967,9 +1089,9 @@ ShellRunner.runAsync("/usr/bin/osascript", args: ["-e", script]) { [weak self] in DispatchQueue.main.async { guard let self = self else { return } if self.state.autoTile { - // Re-pin geometry only — skip the focus-stealing zoom - // reset on this frequent automatic path. - self.tileNowImpl(resetZoom: false) + // A tile is a normalization boundary: remaining windows + // return to one grid-derived font size after the close. + self.tileNowImpl(resetZoom: true) } self.refresh() } @@ -1530,6 +1652,22 @@ static func statusDecor( for state: ClaudeSession.State, dark: Bool, blink: Bool = false, agentType: String = "claude" ) -> (palette: Palette, glyph: String) { + // Codex completion is a stronger attention cue than Claude's calm + // slate: coral/red, distinct from approval/elicitation amber. + if agentType == "codex", state == .complete { + if dark { + return blink + ? (Palette(bg: (23500, 3200, 4200), text: (65535, 48000, 46000), + bold: (65535, 57000, 55000), cursor: (65535, 15000, 14000)), "✓ complete") + : (Palette(bg: (17000, 1900, 3000), text: (65535, 45000, 43000), + bold: (65535, 55000, 53000), cursor: (65535, 11000, 10000)), "✓ complete") + } + return blink + ? (Palette(bg: (65535, 39000, 38000), text: (30000, 1200, 1800), + bold: (21000, 300, 800), cursor: (62000, 5000, 5000)), "✓ complete") + : (Palette(bg: (65535, 45500, 44000), text: (30000, 1200, 1800), + bold: (21000, 300, 800), cursor: (62000, 5000, 5000)), "✓ complete") + } let base = baseStatusDecor(for: state, dark: dark, blink: blink) guard agentType == "codex" else { return base } return (palette: codexTint(base.palette, dark: dark), glyph: base.glyph) @@ -2451,10 +2589,12 @@ } return ScatterLayout(frames: frames, fontSize: scatterFontSize) } - /// Tile every currently-open iTerm2 *and* Terminal.app window into one + /// Tile every currently-open iTerm2 *and* Terminal.app agent window into one /// shared grid. Independent of the auto-tile flag — this is the /// "I forgot to enable it" / "I want to re-pack what's open" button. - /// iTerm2 windows fill the grid first, then Terminal.app windows. + /// iTerm2 windows fill the grid first, then Terminal.app windows. The pass + /// is deliberately agent-agnostic: Claude and Codex hosts share the same + /// geometry and Far/Near/Tiny font calculation. /// Terminal gets bounds only — AppleScript can't set per-session decor /// or wallpaper on Terminal.app, so those windows tile but stay /// un-themed (the iTerm2-only port intentionally dropped Terminal decor). @@ -2496,16 +2636,13 @@ // text size catches up asynchronously, and only when needed: // the profile-font write + Default-Font-Size menu dance is the // slow, focus-stealing part of the old tiler. guard pass.nTerm > 0, resetZoom || prevFont != pass.fontSize else { return } - // ONE fast bulk font-set, no `activate` / z-order reshuffle / View ▸ - // Default Font Size menu dance / delays — that dance is what made an - // explicit tile feel heavy (it steals focus and touches each window - // serially). Setting the settings font reflows a normal window on - // its own; the only thing the dance added was clearing a MANUAL - // per-window zoom (Cmd +/-), which isn't part of the slab workflow. - // If a hand-zoomed window ever refuses to resize, that's the case to - // revisit — otherwise both tile and scatter now stay focus-free. - let lines: [String] = [ + // Set the shared profile size first. An explicit/automatic tile is + // also a normalization boundary: clear Terminal's invisible + // per-window Cmd +/- override via View ▸ Default Font Size so all + // Claude and Codex panes actually render at the same size. + var lines: [String] = [ "tell application \"Terminal\"", + " set _slabIds to id of (every window whose miniaturized is false)", " repeat with _w in (every window whose miniaturized is false)", " try", " set font size of current settings of _w to \(pass.fontSize)", @@ -2513,6 +2650,18 @@ " end try", " end repeat", "end tell", ] + if resetZoom { + lines.append(contentsOf: [ + "tell application \"Terminal\" to activate", + "repeat with _wid in _slabIds", + " try", + " tell application \"Terminal\" to set index of (first window whose id is (contents of _wid)) to 1", + " delay 0.04", + " tell application \"System Events\" to tell process \"Terminal\" to click menu item \"Default Font Size\" of menu 1 of menu bar item \"View\" of menu bar 1", + " end try", + "end repeat", + ]) + } ShellRunner.runAsync("/usr/bin/osascript", args: ["-e", lines.joined(separator: "\n")]) { // Terminal sizes by character CELLS, so the font change reflows // each window to a new pixel size — and that reflow can land a diff --git a/slab/menubar-swift/Sources/SlabMenubar/IconRenderer.swift b/slab/menubar-swift/Sources/SlabMenubar/IconRenderer.swift --- a/slab/menubar-swift/Sources/SlabMenubar/IconRenderer.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/IconRenderer.swift @@ -20,8 +20,9 @@ let name: String let weight: NSFont.Weight if state.messageWaiting { - // She texted (and theme-by-status is on) — this outranks the - // ambient / idle glyphs. Template so the menubar still tints it. + // The watched contact texted — this outranks the ambient / idle + // glyphs even when terminal theming is off. Template so the + // menubar still tints it. name = "message.fill" weight = .semibold } else if state.ambientActive { @@ -320,7 +321,7 @@ } } /// Composite a pulsing magenta dot bottom-left so the colored polygon - /// also carries the "she texted" + /// also carries the "new message" /// accent — the menubar and the themed wall then read one picture. The /// hue is deliberately off the working-green / awaiting-amber / /// complete-slate / stale-gray axis so it never reads as a session state. diff --git a/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift b/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift --- a/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift @@ -20,9 +20,9 @@ // The overlay stays local-only: rocks are never rendered for remote machines. // This is a data channel, not a display one. import Foundation -// One advertised handle. `name` is the sigil pet-name (deterministic from the -// prompt, so it matches the rock on that machine's own overlay); `seed` carries -// the same identity as hex so a reference can be re-rendered anywhere. +// One advertised handle. `name` is the stable session/thread pet-name (and +// matches the local overlay); `seed` is the evolving prompt-sensitive visual +// identity in hex so the current rock can be re-rendered anywhere. struct LedgerEntry: Codable, Equatable { var id: String var host: String @@ -144,6 +144,14 @@ object: nil, userInfo: ["id": sid]) } } + /// Local event sources (iMessage, timers, host integrations) use the same + /// observed path as a fleet `/poke`, without an HTTP loopback. Binding by + /// session id keeps the target stable even while its prompt and rock form + /// evolve. + func pokeLocal(sessionId: String, by: String) { + receivePoke(["id": sessionId, "by": by]) + } + /// Live observed record for a session, or nil once the window has decayed. /// Thread-safe; the overlay controller calls this each frame. func observation(for sessionId: String) -> (by: String, remaining: TimeInterval)? { @@ -183,7 +191,7 @@ let seed = SigilRenderer.seed(for: s.sessionId + "\u{1}" + s.subject) return LedgerEntry( id: s.sessionId, host: selfHost, - name: SigilRenderer.name(seed: seed), + name: SigilRenderer.name(forSessionId: s.sessionId), subject: s.titleString, status: statusName(s.state), kind: "session", diff --git a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift --- a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift @@ -479,7 +479,7 @@ sub.addItem(bright) let sigils = item("PromptRocks", selector: #selector(AppDelegate.togglePromptSigils), target: target) sigils.state = state.promptSigils ? .on : .off - sigils.toolTip = "Pin a PromptRock — a named little stone hashed from the prompt text — to each session's terminal top-right, so prompts are distinguishable at a glance (shape + name per prompt; colour still follows status; hover or click a rock for its subject summary)" + sigils.toolTip = "Pin a PromptRock to each session's terminal top-right: its pet name stays fixed for the thread while its prompt-shaped form can evolve; colour follows status, and hovering or clicking reveals its subject summary" sub.addItem(sigils) let lens = item("Zoom lens (⌃⌃)", selector: #selector(AppDelegate.toggleZoomLens), target: target) diff --git a/slab/menubar-swift/Sources/SlabMenubar/Paths.swift b/slab/menubar-swift/Sources/SlabMenubar/Paths.swift --- a/slab/menubar-swift/Sources/SlabMenubar/Paths.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/Paths.swift @@ -31,6 +31,10 @@ /// Generic iMessage bridge (contact lives in the untracked config below, /// never in tracked code). Mirrors the slab-wallpaper wrapper convention. static var imsgHelper: String { "\(slabBin)/imsg" } static var imsgConfig: String { "\(home)/.config/slab/imsg.json" } + /// Optional prox binding written by `prox_bind_notification`. It contains + /// only a stable local session id and wake flag; contact identity remains + /// in the separate private iMessage config. + static var imsgProxBinding: String { "\(home)/.config/slab/imsg-prox.json" } /// Asana bridge for the task submenu. The Personal Access Token lives in /// the untracked config below (never in tracked code) — same convention as diff --git a/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift b/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift --- a/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift @@ -174,9 +174,9 @@ private let shadowMask = CALayer() // plays the same frames → the shadow's tumbling shape private let nameLayer = CALayer() // the rock's pet name, under the rock (pixel-text bitmap) private var boxCenter = CGPoint.zero - /// The rock's pet name (deterministic from the prompt seed) and the hover - /// bubble copy — refreshed by the controller each sync so the bubble - /// always speaks the session's current subject. + /// The rock's pet name (deterministic from its session/thread id) and the + /// hover copy. The name stays fixed while the visual seed and copy evolve + /// with the session's current subject. private(set) var name: String = "" var tooltipTitle: String = "" var tooltipBody: String = "" @@ -983,12 +983,13 @@ let (period, cw) = motion(for: s.state) ov.setMotion(period: period, clockwise: cw) ov.setShadowColor(statusColor(for: s.state, agentType: s.agentType)) ov.setLighting(drop: sun.drop) - // Name + hover copy. The name is the seed's, so it re-forms with - // the rock on a new prompt. The bubble body prefers a cached + // Name + hover copy. The name belongs to the session/thread and + // stays fixed while the visual rock re-forms on a new prompt. + // The bubble body prefers a cached // haiku-inferred sentence; until that lands it shows the hook // summary and prompt excerpt, deduped (the hook line is usually // the prompt's own first words — repeating both said nothing). - ov.setName(SigilRenderer.name(seed: seed), dark: dark) + ov.setName(SigilRenderer.name(forSessionId: s.sessionId), dark: dark) ov.tooltipTitle = s.emoji.isEmpty ? ov.name : "\(s.emoji) \(ov.name)" ov.tooltipBody = RockSummaries.shared.sentence(seed: seed, subject: s.subject) ?? Self.fallbackBody(summary: s.titleString, subject: s.shortSubject) diff --git a/slab/menubar-swift/Sources/SlabMenubar/SigilRenderer.swift b/slab/menubar-swift/Sources/SlabMenubar/SigilRenderer.swift --- a/slab/menubar-swift/Sources/SlabMenubar/SigilRenderer.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/SigilRenderer.swift @@ -29,8 +29,9 @@ return h == 0 ? 0x9e37_79b9_7f4a_7c15 : h } /// The rock's pet name: 3–6 pronounceable characters, deterministic from - /// the same seed as the shape — so the name IS the rock, stable across - /// restarts and re-renders. Alternating consonant/vowel starting on a + /// a caller-chosen identity seed. Session rocks use the session id here, + /// independently of the evolving prompt seed that shapes their texture. + /// Alternating consonant/vowel starting on a /// consonant (CVC … CVCVCV), which lands on sayable pebble-names like /// "gop", "miva", "tazok". static func name(seed: UInt64) -> String { @@ -45,6 +46,13 @@ let set = i % 2 == 0 ? consonants : vowels out.append(set[rng.int(0, set.count - 1)]) } return out + } + + /// A sticky pet name for one session/thread. Keep subject text out of this + /// seed: the rock may visually evolve with new prompts, but its spoken and + /// fleet-visible handle must remain recognizable for the session lifetime. + static func name(forSessionId sessionId: String) -> String { + name(seed: seed(for: sessionId)) } /// SplitMix64 — a tiny, well-distributed PRNG. Seeding it from the diff --git a/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift b/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift --- a/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift @@ -143,10 +143,10 @@ var claudeSessions: [ClaudeSession] = [] /// Live /pop renders with progress heartbeats — one temporary /// progress bar each in the menu (audio / illy / video). var popRenders: [PopRender] = [] - /// The configured iMessage contact has unread inbound AND theme-by-status - /// is on. Set by AppDelegate (not gather()) from the imsg poll — the - /// whole status surface (polygon icon + themed terminals) then carries a - /// shared "she texted" accent until the thread is read. + /// The configured iMessage contact has unread inbound, or a message just + /// arrived. Set by AppDelegate (not gather()) from the imsg poll — the + /// menubar always carries the signal, while themed terminals also receive + /// the shared message accent. This is passive awareness only. var messageWaiting: Bool = false /// Deskflow KVM — present only on machines with a deskflow.json; the /// menu shows a status line + Start/Stop/Restart for the LaunchAgent. diff --git a/slab/menubar-swift/Sources/SlabMenubar/TerminalFontZoomGuard.swift b/slab/menubar-swift/Sources/SlabMenubar/TerminalFontZoomGuard.swift new file mode 100644 --- /dev/null +++ b/slab/menubar-swift/Sources/SlabMenubar/TerminalFontZoomGuard.swift @@ -0,0 +1,221 @@ +import AppKit +import ApplicationServices +import Carbon.HIToolbox +import CoreGraphics + +/// Lets Terminal/iTerm2 handle their native Command-Plus/Minus font zoom, then +/// restores the focused window's exact pixel frame. Terminal normally expresses +/// its frame in character cells, so changing the font also grows or shrinks the +/// window; Slab's tiled agent wall wants typography and geometry to be separate. +/// +/// The event tap is observational: it never consumes or synthesizes a key event. +/// Consequently Command-Plus/Minus keeps its native per-window semantics, and +/// shortcuts in every non-terminal application remain completely untouched. +final class TerminalFontZoomGuard { + private var tap: CFMachPort? + private var source: CFRunLoopSource? + private var frameObserver: AXObserver? + private var frameObserverSource: CFRunLoopSource? + private var lockedWindow: AXUIElement? + private var lockedFrame: CGRect? + private var lockGeneration = 0 + + @discardableResult + func start() -> Bool { + guard tap == nil else { return true } + let mask: CGEventMask = 1 << CGEventType.keyDown.rawValue + let callback: CGEventTapCallBack = { _, type, event, refcon in + guard let refcon else { return Unmanaged.passUnretained(event) } + let guarder = Unmanaged + .fromOpaque(refcon).takeUnretainedValue() + guarder.handle(type: type, event: event) + return Unmanaged.passUnretained(event) + } + + guard let port = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .listenOnly, + eventsOfInterest: mask, + callback: callback, + userInfo: Unmanaged.passUnretained(self).toOpaque() + ) else { + NSLog("slab terminal font zoom: event tap creation failed") + return false + } + tap = port + let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, port, 0) + source = runLoopSource + CFRunLoopAddSource(CFRunLoopGetMain(), runLoopSource, .commonModes) + CGEvent.tapEnable(tap: port, enable: true) + return true + } + + func stop() { + endFrameLock() + if let source { CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) } + if let tap { CGEvent.tapEnable(tap: tap, enable: false) } + source = nil + tap = nil + } + + /// Runs at the head of the session event stream, before Terminal receives + /// the key. That timing is essential: an NSEvent global monitor fires late + /// enough that Terminal may already have changed its character-cell frame. + private func handle(type: CGEventType, event: CGEvent) { + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + if let tap { CGEvent.tapEnable(tap: tap, enable: true) } + return + } + guard type == .keyDown else { return } + let flags = event.flags + let keyCode = event.getIntegerValueField(.keyboardEventKeycode) + guard flags.contains(.maskCommand), + !flags.contains(.maskAlternate), !flags.contains(.maskControl), + Self.isFontZoomKey(keyCode), + let app = NSWorkspace.shared.frontmostApplication, + let bundle = app.bundleIdentifier, + bundle == "com.apple.Terminal" || bundle == "com.googlecode.iterm2", + let window = Self.focusedWindow(pid: app.processIdentifier), + let frame = Self.frame(of: window) + else { return } + + beginFrameLock(window: window, frame: frame, pid: app.processIdentifier) + } + + private static func isFontZoomKey(_ keyCode: Int64) -> Bool { + keyCode == Int64(kVK_ANSI_Equal) + || keyCode == Int64(kVK_ANSI_Minus) + || keyCode == Int64(kVK_ANSI_KeypadPlus) + || keyCode == Int64(kVK_ANSI_KeypadMinus) + } + + private static func focusedWindow(pid: pid_t) -> AXUIElement? { + let app = AXUIElementCreateApplication(pid) + var ref: CFTypeRef? + guard AXUIElementCopyAttributeValue( + app, kAXFocusedWindowAttribute as CFString, &ref + ) == .success, let ref else { return nil } + return (ref as! AXUIElement) + } + + private static func frame(of window: AXUIElement) -> CGRect? { + var posRef: CFTypeRef? + var sizeRef: CFTypeRef? + guard AXUIElementCopyAttributeValue( + window, kAXPositionAttribute as CFString, &posRef) == .success, + AXUIElementCopyAttributeValue( + window, kAXSizeAttribute as CFString, &sizeRef) == .success, + let posRef, let sizeRef, + CFGetTypeID(posRef) == AXValueGetTypeID(), + CFGetTypeID(sizeRef) == AXValueGetTypeID() + else { return nil } + var origin = CGPoint.zero + var size = CGSize.zero + guard AXValueGetValue(posRef as! AXValue, .cgPoint, &origin), + AXValueGetValue(sizeRef as! AXValue, .cgSize, &size) + else { return nil } + return CGRect(origin: origin, size: size) + } + + private static func setFrame(_ frame: CGRect, of window: AXUIElement) { + var origin = frame.origin + var size = frame.size + if let value = AXValueCreate(.cgPoint, &origin) { + AXUIElementSetAttributeValue( + window, kAXPositionAttribute as CFString, value) + } + if let value = AXValueCreate(.cgSize, &size) { + AXUIElementSetAttributeValue( + window, kAXSizeAttribute as CFString, value) + } + } + + /// Hold one window at its pre-shortcut frame while Terminal performs its + /// character-cell reflow. AX resize/move notifications arrive as Terminal + /// attempts each geometry write, letting us reject the write immediately + /// instead of visibly snapping back on a polling schedule. + private func beginFrameLock(window: AXUIElement, frame: CGRect, pid: pid_t) { + // Repeated presses (and keyboard auto-repeat) must extend the existing + // lock. Tearing down/recreating the AX observer on every key leaves a + // small unguarded gap in which Terminal can resize on the second hit. + if let currentWindow = lockedWindow, + frameObserver != nil, + CFEqual(currentWindow, window) { + lockGeneration += 1 + scheduleFrameUnlock(generation: lockGeneration) + return + } + + endFrameLock() + lockedWindow = window + lockedFrame = frame + lockGeneration += 1 + let generation = lockGeneration + + let callback: AXObserverCallback = { _, _, _, refcon in + guard let refcon else { return } + let guarder = Unmanaged + .fromOpaque(refcon).takeUnretainedValue() + guarder.restoreLockedFrame() + } + var observer: AXObserver? + guard AXObserverCreate(pid, callback, &observer) == .success, + let observer + else { + // AX trust can go stale after a signing change. Keep one final + // correction as a graceful fallback instead of doing nothing. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak self] in + guard self?.lockGeneration == generation else { return } + self?.restoreLockedFrame() + self?.endFrameLock() + } + return + } + frameObserver = observer + let refcon = Unmanaged.passUnretained(self).toOpaque() + AXObserverAddNotification( + observer, window, kAXMovedNotification as CFString, refcon) + AXObserverAddNotification( + observer, window, kAXResizedNotification as CFString, refcon) + let observerSource = AXObserverGetRunLoopSource(observer) + frameObserverSource = observerSource + CFRunLoopAddSource(CFRunLoopGetMain(), observerSource, .commonModes) + + scheduleFrameUnlock(generation: generation) + } + + /// A held key advances the generation for every repeat. Only the most + /// recent hit may release the observer, after Terminal has gone quiet. + private func scheduleFrameUnlock(generation: Int) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.70) { [weak self] in + guard self?.lockGeneration == generation else { return } + self?.restoreLockedFrame() + self?.endFrameLock() + } + } + + private func restoreLockedFrame() { + guard let window = lockedWindow, let target = lockedFrame else { return } + if let current = Self.frame(of: window), + abs(current.minX - target.minX) < 0.5, + abs(current.minY - target.minY) < 0.5, + abs(current.width - target.width) < 0.5, + abs(current.height - target.height) < 0.5 { + return + } + Self.setFrame(target, of: window) + } + + private func endFrameLock() { + if let source = frameObserverSource { + CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) + } + frameObserverSource = nil + frameObserver = nil + lockedWindow = nil + lockedFrame = nil + } + + deinit { stop() } +} -- tangled.sh