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 @@ -12,6 +12,7 @@ // Usage: node codex-session-watch.mjs // Launched (and killed) by codex-slab.sh. Exits when the wrapper pid dies. import { readFile, writeFile, stat, unlink, utimes } from "node:fs/promises"; +import { realpathSync } from "node:fs"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { join } from "node:path"; diff --git a/slab/bin/imsg.mjs b/slab/bin/imsg.mjs --- a/slab/bin/imsg.mjs +++ b/slab/bin/imsg.mjs @@ -1182,6 +1182,39 @@ fallbackFrom: "backend", backendError: error.message, }; } + const rowid = Number(lastRow?.rowid) || 0; + throw new Error( + `Messages queued row ${rowid || "unknown"} via ${route.appleService} but did not confirm sending within ${timeoutMs / 1000}s`, + ); +} + +async function sendMessage(handles, body) { + const route = chooseMessagesRoute(handles, latestSuccessfulRoute(handles)); + let result = await sendAttempt(handles, body, route); + + // A phone number may retain a stale iMessage handle. Only retry when + // Messages explicitly rejects that attempt; never retry an ambiguous + // pending send, which could create a duplicate later. + if (shouldRetryViaSms(route, result)) { + result = await sendAttempt(handles, body, { + handle: route.handle, + appleService: "SMS", + observedService: "SMS fallback", + }); + if (result.status === "failed") { + throw new Error( + `Messages rejected row ${result.rowid} via ${result.service || "SMS"} (error ${result.error})`, + ); + } + return { ...result, fallbackFrom: "iMessage" }; + } + + if (result.status === "failed") { + throw new Error( + `Messages rejected row ${result.rowid} via ${result.service || route.appleService} (error ${result.error})`, + ); + } + return result; } const TAPBACKS = new Map([ 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 @@ -585,7 +585,7 @@ const body = JSON.stringify({ agent: agentName, prompt: launchPrompt, ...(cwd ? { cwd: String(cwd) } : {}), - ...(loopboyContact ? { loopboyContact: String(loopboyContact).toLowerCase() } : {}), + ...(contactKey ? { loopboyContact: contactKey } : {}), by: launcher, }); const res = await fetch(`http://${target.ip}:${PORT}/launch`, { @@ -599,13 +599,10 @@ let result; try { result = JSON.parse(text); } catch { throw new Error(`launch on ${target.host} returned invalid JSON (HTTP ${res.status}).`); } if (!res.ok || !result.ok) throw new Error(`launch on ${target.host} failed: ${result.error || `HTTP ${res.status}`}`); let binding = ""; - if (loopboyContact) { + if (contactKey) { if (String(target.host).toLowerCase() !== String(self).toLowerCase()) { throw new Error("Loopboy contact routes can only be launched on this local iMessage host"); } - if (!result.nudgeScreen) { - throw new Error("Loopboy launch did not return a nudge screen; prompt host needs the updated Slab build"); - } let marker = null; for (let attempt = 0; attempt < 20 && !marker; attempt++) { for (const dir of MARKER_DIRS) { @@ -613,7 +610,8 @@ let names = []; try { names = await readdir(dir); } catch {} for (const name of names) { const value = await readJson(join(dir, name)); - if (value?.nudge_screen === result.nudgeScreen) { + const id = value?.session_id || name; + if (!existingMarkerIds.has(id) && value?.loopboy_contact === contactKey) { marker = { id: value.session_id || name, value }; break; } @@ -626,14 +624,13 @@ if (!marker) throw new Error("Loopboy launched but its live marker did not appear"); await mkdir(join(homedir(), ".config", "slab"), { recursive: true }); const cfg = (await readJson(LOOPBOY_CONFIG)) || { version: 1, loops: {} }; cfg.version = 1; cfg.loops ||= {}; - cfg.loops[String(loopboyContact).toLowerCase()] = { - event: "imessage", contact: String(loopboyContact).toLowerCase(), + cfg.loops[contactKey] = { + event: "imessage", contact: contactKey, sessionId: marker.id, host: result.host || target.host, - name: "pending-ledger-name", agent: agentName, wake: true, - nudgeScreen: result.nudgeScreen, assignedAt: new Date().toISOString(), + agent: agentName, wake: true, assignedAt: new Date().toISOString(), }; await writeFile(LOOPBOY_CONFIG, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 }); - binding = ` and bound Loopboy contact ${loopboyContact}`; + binding = ` and bound Loopboy contact ${contactKey}`; } return [{ type: "text", diff --git a/slab/bin/zzz b/slab/bin/zzz --- a/slab/bin/zzz +++ b/slab/bin/zzz @@ -25,7 +25,8 @@ const ZZZ_DIR = join(STATE, "zzz"); const ACTIVE_DIR = join(STATE, "active-prompts"); const AWAITING_DIR = join(STATE, "awaiting-prompts"); const RUNNING_DIR = join(STATE, "running-tools"); -const LOOPBOY = join(HOME_DIR, ".config", "slab", "loopboy.json"); +const LOOPBOY = process.env.SLAB_LOOPBOY_CONFIG + || join(HOME_DIR, ".config", "slab", "loopboy.json"); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const readJson = async (path) => { @@ -199,7 +200,9 @@ if (!sid || !pid || !marker.tty || !marker.cwd) throw new Error("prompt lacks resumable session/pid/tty/cwd metadata"); if (!force && marker.status !== "complete" && marker.status !== "interrupted") { throw new Error(`refusing to zzz ${marker.status || "working"} prompt without --force`); } - if (!force && (await loopboyIds()).has(sid)) throw new Error("refusing to zzz a Loopboy-bound prompt"); + // A live client route is stronger than --force. Force exists to override + // activity/idle checks, never to sever a Loopboy's notification target. + if ((await loopboyIds()).has(sid)) throw new Error("refusing to zzz a Loopboy-bound prompt"); if ((await ancestorPids()).has(pid)) throw new Error("refusing to zzz the session running this harness"); const providerSessionId = marker.agent_type === "codex" ? (marker.codex_session_id || await providerFromProcess(pid)) : sid; 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 @@ -195,14 +195,17 @@ return ["ok": false, "error": "\(agent) launcher is not installed"] } var command: String - var nudgeScreen = "" + let nudgeScreen = "" if !loopboyContact.isEmpty { - nudgeScreen = "loopboy-\(UUID().uuidString.prefix(8).lowercased())" + // Keep Loopboys on the real Terminal PTY. GNU Screen forwards + // Terminal focus-report sequences (ESC [ I / ESC [ O) as literal + // Codex input, corrupting the prompt whenever focus changes. Slab + // already knows how to focus the exact tty and type the wake prompt + // through trusted CGEvents, so an extra terminal layer is harmful. command = "cd \(shellQuote(cwd)) && " + "SLAB_TERMINAL_TTY=$(basename \"$(tty)\") " - + "SLAB_NUDGE_SCREEN=\(shellQuote(nudgeScreen)) " + "SLAB_LOOPBOY_CONTACT=\(shellQuote(loopboyContact)) " - + "exec /usr/bin/screen -S \(shellQuote(nudgeScreen)) \(shellQuote(binary))" + + "exec \(shellQuote(binary))" } else { command = "cd \(shellQuote(cwd)) && exec \(shellQuote(binary))" } @@ -491,7 +494,11 @@ static func selfIdentity() -> (host: String, ip: String) { // Host is the SHORT OS hostname (neo, blueberry) — the name the fleet // references, not tailscale's device label ("Jeffrey's MacBook Neo"). // IP is this machine's tailscale v4, for binding + advertising. - let raw = ProcessInfo.processInfo.hostName + let fleetName = ShellRunner.output( + "/usr/sbin/scutil", args: ["--get", "LocalHostName"], timeout: 2 + )?.trimmingCharacters(in: .whitespacesAndNewlines) + let raw = fleetName.flatMap { $0.isEmpty ? nil : $0 } + ?? ProcessInfo.processInfo.hostName let host = raw.split(separator: ".").first.map { $0.lowercased() } ?? raw.lowercased() var ip = "" if let ts = Tools.resolve("tailscale"), diff --git a/slab/test/codex-slab.test.mjs b/slab/test/codex-slab.test.mjs --- a/slab/test/codex-slab.test.mjs +++ b/slab/test/codex-slab.test.mjs @@ -2,7 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { - chmod, mkdtemp, mkdir, readFile, readdir, writeFile, + chmod, mkdtemp, mkdir, readFile, readdir, symlink, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -10,6 +10,7 @@ import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); const wrapper = join(here, "..", "bin", "codex-slab"); +const watcher = join(here, "..", "bin", "codex-session-watch.mjs"); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function waitFor(fn, timeoutMs = 5000) { @@ -21,6 +22,19 @@ await sleep(50); } throw new Error("timed out waiting for Codex wrapper state"); } + +test("Codex watcher runs when invoked through its installed symlink", async () => { + const root = await mkdtemp(join(tmpdir(), "codex-watch-symlink-test-")); + const link = join(root, "codex-session-watch.mjs"); + await symlink(watcher, link); + const child = spawn(process.execPath, [link, "test-rock", "0", String(process.pid), "ttys099", root], { + stdio: "ignore", + }); + await sleep(250); + assert.equal(child.exitCode, null, "watcher exited instead of following the symlink target"); + child.kill("SIGTERM"); + await new Promise((resolve) => child.once("exit", resolve)); +}); test("SIGTERM reaches Codex while its terminal remains available", async () => { const root = await mkdtemp(join(tmpdir(), "codex-slab-test-")); diff --git a/slab/test/zzz.test.mjs b/slab/test/zzz.test.mjs --- a/slab/test/zzz.test.mjs +++ b/slab/test/zzz.test.mjs @@ -27,6 +27,7 @@ return { ...process.env, SLAB_HOME: root, SLAB_BIN: "/opt/slab/bin", + SLAB_LOOPBOY_CONFIG: join(root, "loopboy.json"), ZZZ_DRY_RUN: "1", }; } @@ -79,6 +80,32 @@ await assert.rejects( execFileAsync(process.execPath, [zzz, "park", marker.session_id], { env: env(root) }), /refusing to zzz working prompt/, + ); +}); + +test("park refuses a Loopboy-bound prompt even with --force", async () => { + const { root, state } = await fixture(); + const marker = { + session_id: "alex-loopboy", + codex_session_id: providerId, + agent_type: "codex", + agent_pid: 999999, + tty: "ttys095", + cwd: "/tmp/project", + subject: "Alex Loopboy", + updated: new Date().toISOString(), + state: "working", + }; + await writeFile(join(state, "active-prompts", marker.session_id), JSON.stringify(marker)); + await writeFile(join(root, "loopboy.json"), JSON.stringify({ + loops: { alex: { sessionId: marker.session_id, wake: true } }, + })); + + await assert.rejects( + execFileAsync(process.execPath, [zzz, "park", marker.session_id, "--force"], { + env: env(root), + }), + /refusing to zzz a Loopboy-bound prompt/, ); });