From 05275d036b9e2288c36327b10ea6892139565b5a Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Tue, 21 Jul 2026 10:37:00 -0700 Subject: [PATCH] Make ZZZ parking visible and resumable --- SCORE.md | 1 + slab/bin/codex-slab | 43 +- slab/bin/zzz | 376 ++++++++++++++++++ slab/menubar-swift/README.md | 29 ++ .../Sources/SlabMenubar/AppDelegate.swift | 59 +++ .../Sources/SlabMenubar/MenuBuilder.swift | 59 +++ .../Sources/SlabMenubar/ZzzManager.swift | 259 ++++++++++++ slab/test/codex-slab.test.mjs | 69 ++++ slab/test/zzz.test.mjs | 143 +++++++ 9 files changed, 1034 insertions(+), 4 deletions(-) create mode 100755 slab/bin/zzz create mode 100644 slab/menubar-swift/Sources/SlabMenubar/ZzzManager.swift create mode 100644 slab/test/codex-slab.test.mjs create mode 100644 slab/test/zzz.test.mjs diff --git a/SCORE.md b/SCORE.md index d5a7ba799c..05f45bb349 100644 --- a/SCORE.md +++ b/SCORE.md @@ -137,6 +137,7 @@ This is critical because `lib/pmove.mjs` is shared physics: client (lith) and se - **Prompt rocks** (`slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift`) — the tumbling little stones parked at the top-right of each terminal window, one per live Claude session. Each rock is a 3D sigil rendered from the session's `sessionId + prompt` seed (so it re-forms when the session moves to a new prompt), lit by a shared global sun that tracks local time of day, wearing a pet name in bubble lettering. Its *motion* is the status channel — spin speed and direction encode working/awaiting/complete; being read by a peer makes it blink and rattle. Hovering one reveals a bubble summarizing the prompt (a cached one-line `claude -p haiku` inference). They're borderless click-through `.floating` windows, so hit-testing has to check occlusion by hand: a rock only answers the pointer while it's on screen *and* its terminal is still the topmost normal window under the cursor. - **Prompt rocks MCP** (`slab/bin/prox-mcp.mjs`, registered as `prox` in `.mcp.json`) — an MCP over the fleet handle ledger (`Ledger.swift`; `~/.config/slab/ledger/{local,peers/*}.json`, served per-machine on tailnet-only `:5252`) so any agent can `prox_list` every live session across machines, `prox_find` a `host:name` reference (e.g. `neo:regif`) to its status/subject/cwd/seed, `prox_poke` one (`POST /poke` → the target rock blinks + rattles), `prox_wake` a local one with a bounded steering prompt, send `prox_artifact_ready` the output paths from an asynchronous render, and `prox_launch` a new Claude/Codex Terminal on a prompt host. Wake uses the same poke + TTY reactivation pattern as Loopboy; `prox_artifact_ready` supplies the standard inspect/iterate/integrate/continue prompt itself. Launch is deliberately not a remote shell: the target accepts only those two fixed agents, a bounded prompt, and a cwd beneath that user's home. `prox_close` reads the session marker for tty+pid and closes locally only (refuses the calling session). This is how a `machine:promptname` handle resolves without an SSH crawl. - **Loopboy** (`~/.config/slab/loopboy.json`, surfaced in the Slab menubar) — the primary client-loop interface. Each route maps one private iMessage contact key to one stable local prox session; new inbound messages poke and optionally wake only that contact's rock. Loopboy never replies by itself. Armed rocks spin faster, glow pink, and identify themselves as Loopboy on hover. Create or replace a route with `prox_bind_notification(handle, contact)`. +- **ZZZ** (`slab/bin/zzz`, surfaced in the Slab menubar) — the global name for parking an idle prompt: persist its agent/provider thread, cwd, subject, and tty under `~/.local/share/slab/state/zzz/`, end its process, and leave its terminal open with a colored `zzz resume ` receipt. Automatic parking is opt-in with a 60-minute default, and never parks working, awaiting, rendering, remote, subagent-owning, or Loopboy-bound prompts. Run `zzz` for the mouse-aware harness; tap a sleeping row (or use arrows + Return) to resume it through the tracked Claude/Codex launcher. `zzz list` prints a `zzz resume N` command for each row, plain `zzz resume` wakes the newest, and `zzz park ` parks explicitly; `--force` is required when a prompt is not known to be idle. - **Generated artifacts are not task completion** — when an image, PDF, audio file, video, or other render lands, open it automatically, inspect it against the request, iterate if needed, move the accepted artifact into the project, and connect it to its intended consumer before ending the task. If an asynchronous render finishes after the originating rock has gone idle, its completion handler should call `prox_artifact_ready` with that stable session and the output paths; prox wakes the rock and supplies the bounded continuation instruction. - **Paper MCP** (`slab/bin/paper-mcp.mjs`, registered as `paper`) — one searchable front door to the public AC paper tree and the private vault paper stacks, including `fuser/papers`. `paper_list`/`paper_find` resolve a stable paper id, `paper_read` returns bounded TeX/Markdown/PDF text, `paper_build` runs XeLaTeX or Tectonic in the source directory, and `paper_open` raises the result through `slab-pdf`. The shared loopback daemon is `:7777`; `toolchain/mcp/install-daemons.sh` registers it for Claude and Codex. A build may pass `notifyHandle` to return its PDF to the originating rock through `prox_artifact_ready`. - `slab/bin/ac-passphrase` — pinentry-free secret fetch from the daemon (see Development Environment below). diff --git a/slab/bin/codex-slab b/slab/bin/codex-slab index b1988c3f6a..28806303e3 100755 --- a/slab/bin/codex-slab +++ b/slab/bin/codex-slab @@ -35,7 +35,7 @@ fi sid=$(uuidgen) begin=$(date +%s) # The wrapper (this bash process) is the parent of codex and holds the tab's -# controlling tty — use it for liveness + terminal-close, matching claude_pid. +# controlling tty — use it for liveness and graceful prompt parking. tty=$(ps -o tty= -p $$ 2>/dev/null | tr -d ' ') cwd=$PWD ts=$(date -u +%Y-%m-%dT%H:%M:%SZ) @@ -45,7 +45,7 @@ jq -n \ --arg sid "$sid" --arg cwd "$cwd" --arg tty "$tty" \ --arg pid "$$" --arg ts "$ts" \ '{session_id:$sid, cwd:$cwd, subject:"codex session", summary:"codex", - tty:$tty, agent_pid:($pid|tonumber), agent_type:"codex", + tty:$tty, agent_pid:($pid|tonumber), wrapper_pid:($pid|tonumber), agent_type:"codex", updated:$ts, state:"blank"}' \ > "$ACTIVE_DIR/$sid" 2>/dev/null @@ -61,6 +61,41 @@ cleanup() { [[ -n "$watcher" ]] && kill "$watcher" 2>/dev/null rm -f "$ACTIVE_DIR/$sid" "$AWAITING_DIR/$sid" "$RUNNING_DIR/$sid" } -trap cleanup EXIT INT TERM +agent_pid="" +stop_agent() { + trap - INT TERM + if [[ -n "$agent_pid" ]] && kill -0 "$agent_pid" 2>/dev/null; then + kill -TERM "$agent_pid" 2>/dev/null || true + for _ in {1..10}; do + kill -0 "$agent_pid" 2>/dev/null || break + sleep 0.05 + done + kill -KILL "$agent_pid" 2>/dev/null || true + wait "$agent_pid" 2>/dev/null || true + fi + exit 143 +} +trap cleanup EXIT +trap stop_agent INT TERM -codex "$@" +# Keep Codex on this wrapper's foreground process group and explicitly attach +# its streams to the controlling TTY. Running it as a managed child lets a ZZZ +# SIGTERM reach the actual agent before the wrapper exits, without closing the +# terminal that will carry the resume receipt. +codex "$@" < "/dev/$tty" > "/dev/$tty" 2>&1 & +agent_pid=$! +# From this point on, lifecycle managers must signal the actual Codex process, +# not this Bash wrapper. Bash defers TERM traps while it waits for a child; +# tracking the wrapper here could therefore orphan Codex when ZZZ parks it. +marker_tmp="$ACTIVE_DIR/.$sid.$$.tmp" +if jq --arg pid "$agent_pid" --arg wrapper "$$" \ + '.agent_pid=($pid|tonumber) | .wrapper_pid=($wrapper|tonumber)' \ + "$ACTIVE_DIR/$sid" > "$marker_tmp" 2>/dev/null; then + mv "$marker_tmp" "$ACTIVE_DIR/$sid" +else + rm -f "$marker_tmp" +fi +wait "$agent_pid" +status=$? +agent_pid="" +exit "$status" diff --git a/slab/bin/zzz b/slab/bin/zzz new file mode 100755 index 0000000000..0e9686724c --- /dev/null +++ b/slab/bin/zzz @@ -0,0 +1,376 @@ +#!/usr/bin/env node +// zzz — park prompt processes and wake their provider threads later. +// +// zzz tappable sleeping-prompt harness +// zzz list [--json] non-interactive inventory +// zzz resume [id|number|text] resume one sleeping prompt (latest by default) +// zzz wake [id|number|text] alias for resume +// zzz park park one tracked idle prompt +// zzz park --force explicitly park an untracked/working prompt + +import { + chmod, mkdir, readFile, readdir, rename, rm, stat, writeFile, +} from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { basename, dirname, join } from "node:path"; +import { homedir } from "node:os"; + +const execFileAsync = promisify(execFile); +const HOME_DIR = homedir(); +const SLAB_HOME = process.env.SLAB_HOME || join(HOME_DIR, ".local", "share", "slab"); +const SLAB_BIN = process.env.SLAB_BIN || join(HOME_DIR, ".local", "bin"); +const STATE = join(SLAB_HOME, "state"); +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 sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const readJson = async (path) => { + try { return JSON.parse(await readFile(path, "utf8")); } catch { return null; } +}; +const exists = async (path) => { try { await stat(path); return true; } catch { return false; } }; +const shellQuote = (value) => `'${String(value).replaceAll("'", "'\\''")}'`; +const clean = (value) => String(value || "").replace(/\s+/g, " ").trim(); +const short = (value, n = 60) => { + const s = clean(value); + return s.length <= n ? s : `${s.slice(0, n - 1)}…`; +}; + +async function entries() { + let names = []; + try { names = await readdir(ZZZ_DIR); } catch { return []; } + const rows = (await Promise.all(names.filter((name) => name.endsWith(".json")) + .map((name) => readJson(join(ZZZ_DIR, name))))).filter(Boolean); + return rows.sort((a, b) => Date.parse(b.parkedAt || 0) - Date.parse(a.parkedAt || 0)); +} + +async function atomicWrite(path, value) { + await mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.${process.pid}.tmp`; + await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + await rename(tmp, path); +} + +function resolve(rows, query) { + const q = clean(query).toLowerCase(); + if (!q) return []; + let hits = rows.filter((row) => String(row.id).toLowerCase() === q); + if (hits.length) return hits; + hits = rows.filter((row) => String(row.id).toLowerCase().startsWith(q) + || String(row.providerSessionId || "").toLowerCase().startsWith(q)); + if (hits.length) return hits; + if (/^\d+$/.test(q)) { + const index = Number(q) - 1; + if (rows[index]) return [rows[index]]; + } + return rows.filter((row) => clean(row.subject).toLowerCase().includes(q)); +} + +function wakeCommand(entry) { + const cwd = shellQuote(entry.cwd); + if (entry.agentType === "codex") { + const launcher = join(SLAB_BIN, "codex-slab"); + return `cd ${cwd} && exec ${shellQuote(launcher)} resume ${shellQuote(entry.providerSessionId)}`; + } + const launcher = join(SLAB_BIN, "claude"); + return `cd ${cwd} && exec ${shellQuote(launcher)} -r ${shellQuote(entry.providerSessionId || entry.id)}`; +} + +async function wake(entry) { + if (!entry?.cwd || !entry?.providerSessionId) throw new Error("sleeping entry lacks cwd/provider session id"); + const command = wakeCommand(entry); + if (process.env.ZZZ_DRY_RUN === "1") return { command, dryRun: true }; + + const launchDir = join(SLAB_HOME, "launches"); + await mkdir(launchDir, { recursive: true }); + const launchPath = join(launchDir, `zzz-${process.pid}-${Date.now()}.command`); + const script = `#!/bin/zsh\nrm -f ${shellQuote(launchPath)}\n${command}\n`; + await writeFile(launchPath, script, { mode: 0o700 }); + await chmod(launchPath, 0o700); + try { + await execFileAsync("/usr/bin/open", ["-a", "Terminal", launchPath]); + } catch (error) { + await rm(launchPath, { force: true }); + throw new Error(`Terminal rejected wake: ${error.message}`); + } + await rm(join(ZZZ_DIR, `${entry.id}.json`), { force: true }); + return { command, dryRun: false }; +} + +const pidAlive = (pid) => { + try { process.kill(pid, 0); return true; } + catch (error) { return error?.code === "EPERM"; } +}; + +async function ancestorPids(pid = process.pid) { + const result = new Set(); + let current = pid; + for (let i = 0; i < 40 && current > 1; i++) { + try { + const { stdout } = await execFileAsync("/bin/ps", ["-o", "ppid=", "-p", String(current)]); + const parent = Number(stdout.trim()); + if (!parent || parent <= 1 || result.has(parent)) break; + result.add(parent); + current = parent; + } catch { break; } + } + return result; +} + +async function loopboyIds() { + const obj = await readJson(LOOPBOY); + return new Set(Object.values(obj?.loops || {}).map((loop) => loop?.sessionId).filter(Boolean)); +} + +async function trackedPrompts() { + let names = []; + try { names = await readdir(ACTIVE_DIR); } catch { return []; } + const rows = []; + for (const name of names.filter((value) => !value.startsWith("."))) { + const marker = await readJson(join(ACTIVE_DIR, name)); + if (!marker) continue; + const awaiting = await readFile(join(AWAITING_DIR, name), "utf8").catch(() => ""); + const running = await exists(join(RUNNING_DIR, name)); + let status = marker.state === "blank" ? "blank" : "working"; + if (awaiting.toLowerCase().startsWith("turn complete")) status = "complete"; + else if (awaiting) status = "awaiting"; + else if (!running && Date.now() - Date.parse(marker.updated || 0) > 45_000) status = "interrupted"; + rows.push({ ...marker, session_id: marker.session_id || name, status }); + } + return rows; +} + +async function providerFromProcess(pid) { + const { stdout = "" } = await execFileAsync("/bin/ps", ["-o", "command=", "-p", String(pid)]).catch(() => ({})); + return stdout.match(/\bcodex(?:-slab)?\s+resume\s+([0-9a-f-]{36})\b/i)?.[1] || ""; +} + +async function markerForPid(pid) { + const { stdout = "" } = await execFileAsync("/bin/ps", ["-o", "tty=,command=", "-p", String(pid)]).catch(() => ({})); + const line = stdout.trim(); + if (!line) return null; + const firstSpace = line.search(/\s/); + const tty = firstSpace < 0 ? "" : line.slice(0, firstSpace); + const command = firstSpace < 0 ? line : line.slice(firstSpace).trim(); + const agentType = /\bcodex\b/i.test(command) ? "codex" : (/\bclaude\b/i.test(command) ? "claude" : ""); + if (!agentType) return null; + const providerSessionId = agentType === "codex" ? await providerFromProcess(pid) : ""; + let cwd = ""; + try { + const { stdout: lsof } = await execFileAsync("/usr/sbin/lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"]); + cwd = lsof.split("\n").find((value) => value.startsWith("n"))?.slice(1) || ""; + } catch {} + return { + session_id: providerSessionId || `pid-${pid}`, + agent_type: agentType, + agent_pid: pid, + codex_session_id: providerSessionId, + cwd, + tty, + subject: command, + summary: agentType, + updated: new Date().toISOString(), + status: "untracked", + }; +} + +async function leaveResumeReceipt(entry) { + if (process.env.ZZZ_DRY_RUN === "1") return; + const ttyName = basename(String(entry.tty || "")); + if (!/^tty[a-zA-Z0-9._-]+$/.test(ttyName)) return; + const resume = `zzz resume ${String(entry.id).slice(0, 8)}`; + const receipt = [ + "\r\n\x1b[1;38;5;213m💤 ZZZ parked this prompt\x1b[0m", + `\x1b[2m${short(entry.subject, 72)}\x1b[0m`, + `\x1b[1;38;5;51mResume: ${resume}\x1b[0m`, + "", + ].join("\r\n"); + const ttyRoot = process.env.ZZZ_TTY_ROOT || "/dev"; + await writeFile(join(ttyRoot, ttyName), receipt, { flag: "a" }).catch(() => {}); +} + +async function park(marker, { force = false } = {}) { + const sid = marker.session_id; + const pid = Number(marker.agent_pid || marker.claude_pid || 0); + 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"); + 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; + if (!providerSessionId) throw new Error("Codex provider thread is unknown; refresh tracking before zzz"); + + const entry = { + version: 1, + id: sid, + providerSessionId, + agentType: marker.agent_type || "claude", + cwd: marker.cwd, + subject: marker.subject || `${marker.agent_type || "agent"} session`, + summary: marker.summary || marker.agent_type || "agent", + tty: marker.tty, + parkedAt: new Date().toISOString(), + lastActiveAt: marker.updated || new Date().toISOString(), + reason: force ? "manual-force" : "manual", + }; + await mkdir(ZZZ_DIR, { recursive: true }); + await atomicWrite(join(ZZZ_DIR, `${sid}.json`), entry); + if (process.env.ZZZ_DRY_RUN !== "1") { + if (pidAlive(pid)) { try { process.kill(pid, "SIGTERM"); } catch {} } + for (let i = 0; i < 6 && pidAlive(pid); i++) await sleep(200); + if (pidAlive(pid)) { try { process.kill(pid, "SIGKILL"); } catch {} } + await sleep(100); + if (pidAlive(pid)) { + await rm(join(ZZZ_DIR, `${sid}.json`), { force: true }); + throw new Error(`could not stop pid ${pid}; wake record rolled back`); + } + // Keep the terminal surface as the durable receipt. Closing it made the + // parked prompt look like a crash and erased the only useful next step. + await leaveResumeReceipt(entry); + await Promise.all([ + rm(join(ACTIVE_DIR, sid), { force: true }), + rm(join(AWAITING_DIR, sid), { force: true }), + rm(join(RUNNING_DIR, sid), { force: true }), + ]); + } + return entry; +} + +function age(when) { + const seconds = Math.max(0, Math.floor((Date.now() - Date.parse(when || 0)) / 1000)); + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`; + return `${Math.floor(seconds / 86400)}d`; +} + +async function harness() { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + await printList(false); + return; + } + let selected = 0; + let rows = []; + let busy = false; + let restored = false; + const rowStart = 5; + const write = (value) => process.stdout.write(value); + const restore = () => { + if (restored) return; + restored = true; + write("\x1b[?1000l\x1b[?1006l\x1b[?25h\x1b[?1049l"); + if (process.stdin.isRaw) process.stdin.setRawMode(false); + }; + const render = async (message = "") => { + rows = await entries(); + selected = Math.max(0, Math.min(selected, rows.length - 1)); + write("\x1b[2J\x1b[H"); + write("\x1b[1;35mzzz\x1b[0m sleeping prompt harness\n"); + write("tap a row or use ↑/↓ + Return · r refresh · q quit\n\n"); + write(`\x1b[2mSleeping prompts (${rows.length})\x1b[0m\n`); + if (!rows.length) write(" nothing sleeping — Slab will park eligible idle prompts here\n"); + rows.forEach((entry, index) => { + const cursor = index === selected ? "\x1b[7m" : ""; + const cwd = basename(entry.cwd || "") || entry.cwd || "?"; + write(`${cursor} ${String(index + 1).padStart(2)} ${entry.agentType === "codex" ? "C" : "A"} ${short(entry.subject, 54).padEnd(54)} ${cwd} ${age(entry.parkedAt)} \x1b[0m\n`); + }); + if (message) write(`\n\x1b[36m${message}\x1b[0m`); + }; + const wakeSelected = async () => { + if (busy || !rows[selected]) return; + busy = true; + try { + const entry = rows[selected]; + await render(`waking ${short(entry.subject, 45)}…`); + await wake(entry); + await render(`woke ${short(entry.subject, 45)}`); + } catch (error) { + await render(`wake failed: ${error.message}`); + } finally { busy = false; } + }; + + write("\x1b[?1049h\x1b[?25l\x1b[?1000h\x1b[?1006h"); + process.stdin.setRawMode(true); + process.stdin.resume(); + await render(); + process.on("exit", restore); + process.on("SIGINT", () => { restore(); process.exit(0); }); + process.on("SIGTERM", () => { restore(); process.exit(0); }); + process.stdin.on("data", async (chunk) => { + const input = chunk.toString("utf8"); + if (input === "q" || input === "\u0003") { restore(); process.exit(0); } + if (input.includes("\x1b[A")) { selected = Math.max(0, selected - 1); await render(); return; } + if (input.includes("\x1b[B")) { selected = Math.min(rows.length - 1, selected + 1); await render(); return; } + if (input === "\r" || input === "\n") { await wakeSelected(); return; } + if (input === "r") { await render("refreshed"); return; } + const mouse = input.match(/\x1b\[<0;\d+;(\d+)[Mm]/); + if (mouse) { + const index = Number(mouse[1]) - rowStart; + if (index >= 0 && index < rows.length) { + selected = index; + await render(); + await wakeSelected(); + } + } + }); +} + +async function printList(json) { + const rows = await entries(); + if (json) { process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`); return; } + if (!rows.length) { console.log("zzz: no sleeping prompts"); return; } + rows.forEach((entry, index) => { + console.log(`${String(index + 1).padStart(2)} ${entry.agentType.padEnd(6)} ${age(entry.parkedAt).padStart(4)} ${short(entry.subject)} · ${entry.cwd}`); + console.log(` resume: zzz resume ${index + 1}`); + }); + console.log("\nresume the newest: zzz resume"); +} + +function usage() { + console.log("usage: zzz [list [--json] | resume [id|number|text] | wake [id|number|text] | park [--force]]"); +} + +async function main() { + const [command, query, ...rest] = process.argv.slice(2); + if (!command) { await harness(); return; } + if (command === "list") { await printList(process.argv.includes("--json")); return; } + if (command === "wake" || command === "resume") { + const rows = await entries(); + const hits = query ? resolve(rows, query) : rows.slice(0, 1); + if (!hits.length) throw new Error(`no sleeping prompt matches «${query || ""}»`); + if (hits.length > 1) throw new Error(`ambiguous sleeping prompt: ${hits.map((row) => short(row.subject, 24)).join(", ")}`); + await wake(hits[0]); + console.log(`woke ${hits[0].agentType} · ${short(hits[0].subject)}`); + return; + } + if (command === "park") { + const force = rest.includes("--force") || process.argv.includes("--force"); + let marker; + if (/^\d+$/.test(query || "")) marker = await markerForPid(Number(query)); + else { + const prompts = await trackedPrompts(); + const matches = prompts.filter((row) => row.session_id === query + || row.session_id.toLowerCase().startsWith(String(query || "").toLowerCase()) + || clean(row.subject).toLowerCase().includes(String(query || "").toLowerCase())); + if (matches.length > 1) throw new Error(`ambiguous live prompt (${matches.length} matches)`); + marker = matches[0]; + } + if (!marker) throw new Error(`no live prompt matches «${query || ""}»`); + const entry = await park(marker, { force }); + console.log(`zzz'd ${entry.agentType} · ${short(entry.subject)}`); + console.log(`resume: zzz resume ${String(entry.id).slice(0, 8)}`); + return; + } + usage(); + process.exitCode = 2; +} + +main().catch((error) => { + console.error(`zzz: ${error.message}`); + process.exitCode = 1; +}); diff --git a/slab/menubar-swift/README.md b/slab/menubar-swift/README.md index b56caba747..0833fc312f 100644 --- a/slab/menubar-swift/README.md +++ b/slab/menubar-swift/README.md @@ -87,6 +87,35 @@ rock; Loopboy never replies on its own. Armed Loopboy rocks spin faster, wear a pink glow, and identify themselves in their hover bubble. The Slab menu lists all active client loops and their prox targets. +## ZZZ — resumable prompt parking + +`zzz` is Slab's cold tier for prompt processes. Automatic parking is opt-in; +when enabled, its default idle window is 60 minutes. A local prompt that is +explicitly complete or interrupted is recorded under +`~/.local/share/slab/state/zzz/` and terminated. Its terminal stays open with +a colored `zzz resume ` receipt, and its provider conversation is not +deleted. Working, awaiting, +rendering, remote, subagent-owning, and Loopboy-bound prompts are protected. +The timeout and enabled state live in the host-private +`~/.config/slab/zzz.json`. + +The Slab menu can zzz an idle prompt immediately, toggle the automatic reaper, +or wake a sleeping prompt. It also opens the terminal harness: + +```sh +zzz # mouse/touch + keyboard harness +zzz list # inventory with a resume command for every row +zzz list --json # machine-readable sleeping prompt inventory +zzz resume # resume the newest sleeping prompt +zzz resume # resume a selected prompt (wake is an alias) +zzz park # park a tracked idle prompt +zzz park --force # explicit escape hatch for an untracked prompt +``` + +In the harness, tap a row or use arrow keys and Return to reinitialize that +prompt in a fresh tracked Terminal window with the same provider thread and +working directory. + ## Passphrase modal (IPC server) The app listens on a Unix domain socket at `~/.ac-daemon.sock` (mode 0600). Any script on this user's account can request a passphrase. diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift index 80987309e8..ef91441bd9 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -356,6 +356,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { return s } } + // `zzz` is the prompt lifecycle's cold tier: once an unbound, + // resumable local prompt has been genuinely idle for the configured + // window, persist its wake record, stop the agent, and leave its + // terminal open with the resume receipt. + ZzzManager.shared.tick(sessions: snapshot.claudeSessions) DispatchQueue.main.async { self.gathering = false self.state = snapshot @@ -1490,6 +1495,60 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } } + @objc func toggleAutoZzz() { + let config = ZzzStore.configuration() + ZzzStore.setEnabled(!config.enabled) + refresh() + } + + @objc func zzzIdleSession(_ sender: NSMenuItem) { + guard let sid = sender.representedObject as? String, !sid.isEmpty else { return } + ZzzManager.shared.parkIdle(sessionId: sid, sessions: state.claudeSessions) { + [weak self] message in + self?.notify(title: "slab", subtitle: "zzz", body: message) + self?.refresh() + } + } + + @objc func wakeZzzSession(_ sender: NSMenuItem) { + guard let sid = sender.representedObject as? String, !sid.isEmpty else { return } + ShellRunner.runAsync(Paths.zzzHelper, args: ["wake", sid]) { [weak self] in + DispatchQueue.main.async { self?.refresh() } + } + } + + /// Open the keyboard-and-mouse `zzz` harness in a terminal. Rows in the + /// harness accept arrow/Return or a direct click/tap to wake a prompt. + @objc func openZzzHarness() { + let helper = Paths.zzzHelper + guard FileManager.default.isExecutableFile(atPath: helper) else { + notify(title: "slab", subtitle: "zzz", body: "zzz helper is not installed.") + return + } + let quoted = "'" + helper.replacingOccurrences(of: "'", with: "'\\''") + "'" + let escaped = quoted.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + let app = Self.preferredTerminalApp() + let script: String + if app == "Terminal" { + script = """ + tell application "Terminal" + activate + do script "\(escaped)" + end tell + """ + } else { + script = """ + tell application id "com.googlecode.iterm2" + activate + create window with default profile + tell current session of current window to write text "\(escaped)" + end tell + """ + } + ShellRunner.runAsync("/usr/bin/osascript", args: ["-e", script]) + } + @objc func toggleAutoTile() { let path = Paths.autoTileFlag let fm = FileManager.default diff --git a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift index 8925e39329..8813ba7ed8 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift @@ -56,6 +56,7 @@ enum MenuBuilder { let agents = NSMenu() appendPopRenders(to: agents, state: state) appendClaude(to: agents, state: state, target: target) + agents.addItem(buildZzz(state: state, target: target)) agents.addItem(info("Subagents in flight: \(state.activeSubagents)")) agents.addItem(.separator()) agents.addItem(buildWindowLayout(state: state, target: target)) @@ -190,6 +191,64 @@ enum MenuBuilder { submenu: sub) } + /// `zzz` is a resumable cold tier for prompts: idle live rows can be + /// parked now; sleeping rows wake back into their original provider thread. + private static func buildZzz(state: StateSnapshot, target: AppDelegate) -> NSMenuItem { + let sub = NSMenu() + let config = ZzzStore.configuration() + let sleeping = ZzzStore.entries() + + let automatic = item( + "Auto zzz after \(Int(config.idleMinutes)) min", + selector: #selector(AppDelegate.toggleAutoZzz), + target: target + ) + automatic.state = config.enabled ? .on : .off + automatic.toolTip = "Opt-in. Only complete/interrupted local prompts are parked. Working, awaiting, rendering, Loopboy-bound, remote, and subagent-owning prompts are protected." + sub.addItem(automatic) + sub.addItem(item("Open zzz harness…", + selector: #selector(AppDelegate.openZzzHarness), + target: target)) + + if !sleeping.isEmpty { + sub.addItem(.separator()) + sub.addItem(info("Wake sleeping prompt" + (sleeping.count == 1 ? "" : "s"))) + for entry in sleeping { + let row = NSMenuItem( + title: "☀︎ \(entry.shortSubject) · \((entry.cwd as NSString).lastPathComponent)", + action: #selector(AppDelegate.wakeZzzSession(_:)), + keyEquivalent: "" + ) + row.target = target + row.representedObject = entry.id + row.toolTip = "Resume \(entry.agentType) thread \(entry.providerSessionId)" + sub.addItem(row) + } + } + + let idle = state.claudeSessions.filter { + !$0.isRemote && ($0.state == .complete || $0.state == .interrupted) + } + if !idle.isEmpty { + sub.addItem(.separator()) + sub.addItem(info("zzz now")) + for session in idle { + let row = NSMenuItem( + title: "☾ \(session.shortSubject) · \(session.cwdLabel)", + action: #selector(AppDelegate.zzzIdleSession(_:)), + keyEquivalent: "" + ) + row.target = target + row.representedObject = session.sessionId + row.toolTip = "Park this idle prompt now; its provider thread remains resumable." + sub.addItem(row) + } + } + + let title = sleeping.isEmpty ? "zzz: ready" : "zzz: \(sleeping.count) sleeping" + return section(title, symbol: "moon.zzz.fill", submenu: sub) + } + /// Consistent first-level navigation: every subsystem is represented by /// one titled, symbol-bearing parent instead of spilling its controls into /// the root menu. Deeper builders keep ownership of their own details. diff --git a/slab/menubar-swift/Sources/SlabMenubar/ZzzManager.swift b/slab/menubar-swift/Sources/SlabMenubar/ZzzManager.swift new file mode 100644 index 0000000000..a2e5f9f3de --- /dev/null +++ b/slab/menubar-swift/Sources/SlabMenubar/ZzzManager.swift @@ -0,0 +1,259 @@ +import Darwin +import Foundation + +/// Persistent description of one prompt that has been parked with `zzz`. +/// The provider thread survives; its process sleeps while the terminal remains +/// open with a colored, copyable resume receipt. +struct ZzzEntry: Codable { + let version: Int + let id: String + let providerSessionId: String + let agentType: String + let cwd: String + let subject: String + let summary: String + let tty: String + let parkedAt: Date + let lastActiveAt: Date + let reason: String + + var shortSubject: String { + let clean = subject.trimmingCharacters(in: .whitespacesAndNewlines) + if clean.count <= 60 { return clean } + return String(clean.prefix(57)) + "…" + } +} + +struct ZzzConfiguration: Codable { + // Process termination must be an explicit user choice. A missing config + // file used to opt every fresh install into auto-zzz, which made Codex + // windows disappear after only 20 minutes with no visible receipt. + var enabled: Bool = false + var idleMinutes: Double = 60 + + var idleSeconds: TimeInterval { max(60, idleMinutes * 60) } +} + +/// Small JSON store shared by Slab and the `zzz` harness. One file per prompt +/// keeps writes atomic and makes a sleeping entry recoverable after a crash. +enum ZzzStore { + private static let decoder: JSONDecoder = { + let d = JSONDecoder() + d.dateDecodingStrategy = .iso8601 + return d + }() + + private static let encoder: JSONEncoder = { + let e = JSONEncoder() + e.dateEncodingStrategy = .iso8601 + e.outputFormatting = [.prettyPrinted, .sortedKeys] + return e + }() + + static func configuration() -> ZzzConfiguration { + guard let data = FileManager.default.contents(atPath: Paths.zzzConfig), + let config = try? decoder.decode(ZzzConfiguration.self, from: data) + else { return ZzzConfiguration() } + return config + } + + static func setEnabled(_ enabled: Bool) { + var config = configuration() + config.enabled = enabled + let dir = (Paths.zzzConfig as NSString).deletingLastPathComponent + try? FileManager.default.createDirectory(atPath: dir, + withIntermediateDirectories: true) + guard let data = try? encoder.encode(config) else { return } + try? data.write(to: URL(fileURLWithPath: Paths.zzzConfig), options: .atomic) + } + + static func entries() -> [ZzzEntry] { + let fm = FileManager.default + guard let names = try? fm.contentsOfDirectory(atPath: Paths.zzzDir) else { return [] } + return names.compactMap { name in + guard name.hasSuffix(".json"), !name.hasPrefix(".") else { return nil } + let path = "\(Paths.zzzDir)/\(name)" + guard let data = fm.contents(atPath: path) else { return nil } + return try? decoder.decode(ZzzEntry.self, from: data) + }.sorted { $0.parkedAt > $1.parkedAt } + } + + static func write(_ entry: ZzzEntry) -> Bool { + let fm = FileManager.default + do { + try fm.createDirectory(atPath: Paths.zzzDir, withIntermediateDirectories: true) + let data = try encoder.encode(entry) + try data.write(to: URL(fileURLWithPath: path(for: entry.id)), options: .atomic) + return true + } catch { + NSLog("💤 [zzz] could not persist %@: %@", entry.id, String(describing: error)) + return false + } + } + + static func remove(_ id: String) { + try? FileManager.default.removeItem(atPath: path(for: id)) + } + + private static func path(for id: String) -> String { + let safe = id.replacingOccurrences(of: "/", with: "_") + return "\(Paths.zzzDir)/\(safe).json" + } +} + +/// Central idle reaper. `zzz` deliberately means "park", not "forget": write +/// the wake record first, then end the process and leave its terminal receipt. +final class ZzzManager { + static let shared = ZzzManager() + + private let queue = DispatchQueue(label: "slab.zzz") + private var parking: Set = [] + private var lastSweep = Date.distantPast + private let sweepInterval: TimeInterval = 30 + + private init() {} + + /// Called by Slab's ordinary refresh. Work is serialized off-main and + /// throttled so the feature adds no per-frame or per-window polling cost. + func tick(sessions: [ClaudeSession]) { + let now = Date() + guard now.timeIntervalSince(lastSweep) >= sweepInterval else { return } + lastSweep = now + let config = ZzzStore.configuration() + guard config.enabled else { return } + queue.async { [weak self] in + self?.autoPark(sessions: sessions, config: config) + } + } + + /// Manual Slab-menu sibling of auto-zzz. It is intentionally limited to + /// already-idle sessions; the explicit CLI has `--force` for emergencies. + func parkIdle(sessionId: String, sessions: [ClaudeSession], completion: @escaping (String) -> Void) { + guard let session = sessions.first(where: { $0.sessionId == sessionId }) else { + completion("Prompt is no longer live.") + return + } + queue.async { [weak self] in + guard let self else { return } + let result = self.park(session, reason: "manual", requireAge: nil) + DispatchQueue.main.async { completion(result) } + } + } + + private func autoPark(sessions: [ClaudeSession], config: ZzzConfiguration) { + for candidate in sessions { + guard eligible(candidate, minimumIdle: config.idleSeconds) == nil else { continue } + + // Re-read markers immediately before the destructive half. A user + // may have submitted a prompt since the background snapshot; a + // fresh working/awaiting state must always win that race. + guard let live = ClaudeSessionReader.active().first(where: { + $0.sessionId == candidate.sessionId + }), eligible(live, minimumIdle: config.idleSeconds) == nil else { continue } + _ = park(live, reason: "idle-\(Int(config.idleMinutes))m", + requireAge: config.idleSeconds) + } + } + + /// Returns nil when eligible, otherwise a human-readable reason. + private func eligible(_ session: ClaudeSession, minimumIdle: TimeInterval?) -> String? { + if session.isRemote { return "remote prompt" } + if session.state != .complete && session.state != .interrupted { return "prompt is not idle" } + if session.subagentCount > 0 { return "subagents are still running" } + if session.claudePid <= 0 || session.tty.isEmpty { return "missing pid/tty" } + if session.cwd.isEmpty { return "missing working directory" } + if session.agentType == "codex" && session.providerSessionId.isEmpty { + return "Codex provider thread is not known yet" + } + if loopboySessionIds().contains(session.sessionId) { return "Loopboy-bound prompt" } + if let seconds = minimumIdle, + Date().timeIntervalSince(session.updated) < seconds { return "idle timer has not elapsed" } + return nil + } + + private func park(_ session: ClaudeSession, reason: String, + requireAge: TimeInterval?) -> String { + if let why = eligible(session, minimumIdle: requireAge) { return "Not zzz'd: \(why)." } + if parking.contains(session.sessionId) { return "Prompt is already entering zzz." } + parking.insert(session.sessionId) + defer { parking.remove(session.sessionId) } + + let provider = session.agentType == "codex" + ? session.providerSessionId : session.sessionId + let entry = ZzzEntry( + version: 1, + id: session.sessionId, + providerSessionId: provider, + agentType: session.agentType, + cwd: session.cwd, + subject: session.subject, + summary: session.summary, + tty: session.tty, + parkedAt: Date(), + lastActiveAt: session.updated, + reason: reason + ) + guard ZzzStore.write(entry) else { return "Could not write the zzz wake record." } + + let pid = pid_t(session.claudePid) + if Self.pidAlive(pid) { + _ = kill(pid, SIGTERM) + Thread.sleep(forTimeInterval: 1.2) + } + if Self.pidAlive(pid) { + _ = kill(pid, SIGKILL) + Thread.sleep(forTimeInterval: 0.15) + } + if Self.pidAlive(pid) { + ZzzStore.remove(session.sessionId) + return "Could not stop pid \(session.claudePid); wake record was rolled back." + } + + // The old implementation closed the whole tab here, which erased all + // evidence that ZZZ acted. Keep the terminal and leave a colored, + // copyable command after the agent has restored normal terminal mode. + Self.leaveResumeReceipt(entry) + let fm = FileManager.default + try? fm.removeItem(atPath: "\(Paths.activePromptsDir)/\(session.sessionId)") + try? fm.removeItem(atPath: "\(Paths.awaitingPromptsDir)/\(session.sessionId)") + try? fm.removeItem(atPath: "\(Paths.runningToolsDir)/\(session.sessionId)") + NSLog("💤 [zzz] parked %@ %@ (%@)", session.agentType, + session.sessionId, reason) + let selector = String(session.sessionId.prefix(8)) + return "zzz'd \(session.shortSubject). Resume with: zzz resume \(selector)" + } + + private func loopboySessionIds() -> Set { + guard let data = FileManager.default.contents(atPath: Paths.loopboyConfig), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let loops = obj["loops"] as? [String: Any] else { return [] } + return Set(loops.values.compactMap { + ($0 as? [String: Any])?["sessionId"] as? String + }) + } + + private static func pidAlive(_ pid: pid_t) -> Bool { + guard pid > 0 else { return false } + return kill(pid, 0) == 0 || errno == EPERM + } + + private static func leaveResumeReceipt(_ entry: ZzzEntry) { + let ttyName = (entry.tty as NSString).lastPathComponent + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) + guard ttyName.hasPrefix("tty"), + ttyName.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { return } + let selector = String(entry.id.prefix(8)) + let receipt = """ + \r + \u{001B}[1;38;5;213m💤 ZZZ parked this prompt\u{001B}[0m\r + \u{001B}[2m\(entry.shortSubject)\u{001B}[0m\r + \u{001B}[1;38;5;51mResume: zzz resume \(selector)\u{001B}[0m\r + + """ + guard let data = receipt.data(using: .utf8), + let handle = FileHandle(forWritingAtPath: "/dev/\(ttyName)") else { return } + handle.seekToEndOfFile() + handle.write(data) + try? handle.close() + } +} diff --git a/slab/test/codex-slab.test.mjs b/slab/test/codex-slab.test.mjs new file mode 100644 index 0000000000..cb43374989 --- /dev/null +++ b/slab/test/codex-slab.test.mjs @@ -0,0 +1,69 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { + chmod, mkdtemp, mkdir, readFile, readdir, writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const wrapper = join(here, "..", "bin", "codex-slab"); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitFor(fn, timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const value = await fn(); + if (value) return value; + await sleep(50); + } + throw new Error("timed out waiting for Codex wrapper state"); +} + +test("SIGTERM reaches Codex while its terminal remains available", async () => { + const root = await mkdtemp(join(tmpdir(), "codex-slab-test-")); + const bin = join(root, "bin"); + const active = join(root, "state", "active-prompts"); + const signalPath = join(root, "codex-signal"); + const readyPath = join(root, "codex-ready"); + const transcript = join(root, "terminal.txt"); + await mkdir(bin, { recursive: true }); + await writeFile(join(bin, "codex"), `#!/bin/bash +trap 'printf term > "$FAKE_CODEX_SIGNAL"; exit 0' TERM INT +printf ready > "$FAKE_CODEX_READY" +while :; do sleep 0.05; done +`); + await chmod(join(bin, "codex"), 0o755); + + const child = spawn("/usr/bin/script", ["-q", transcript, "/bin/bash", wrapper], { + env: { + ...process.env, + PATH: `${bin}:${process.env.PATH}`, + SLAB_HOME: root, + SLAB_BIN: bin, + FAKE_CODEX_SIGNAL: signalPath, + FAKE_CODEX_READY: readyPath, + }, + stdio: "ignore", + }); + const marker = await waitFor(async () => { + const names = await readdir(active).catch(() => []); + if (!names.length) return null; + let value; + try { value = JSON.parse(await readFile(join(active, names[0]), "utf8")); } + catch { return null; } + return value.wrapper_pid && value.agent_pid !== value.wrapper_pid ? value : null; + }); + await waitFor(async () => (await readFile(readyPath, "utf8").catch(() => "")) === "ready"); + process.kill(marker.agent_pid, "SIGTERM"); + await waitFor(async () => (await readFile(signalPath, "utf8").catch(() => "")) === "term"); + if (child.exitCode === null && child.signalCode === null) { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("PTY did not close after wrapper exit")), 5000); + child.once("exit", () => { clearTimeout(timer); resolve(); }); + }); + } + assert.equal(await readFile(signalPath, "utf8"), "term"); +}); diff --git a/slab/test/zzz.test.mjs b/slab/test/zzz.test.mjs new file mode 100644 index 0000000000..3d61e88af2 --- /dev/null +++ b/slab/test/zzz.test.mjs @@ -0,0 +1,143 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const execFileAsync = promisify(execFile); +const here = dirname(fileURLToPath(import.meta.url)); +const zzz = join(here, "..", "bin", "zzz"); +const providerId = "019f8087-6e5b-7a92-aebc-288cf603bdae"; + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "slab-zzz-test-")); + const state = join(root, "state"); + await mkdir(join(state, "zzz"), { recursive: true }); + await mkdir(join(state, "active-prompts"), { recursive: true }); + await mkdir(join(state, "awaiting-prompts"), { recursive: true }); + await mkdir(join(state, "running-tools"), { recursive: true }); + return { root, state }; +} + +function env(root) { + return { + ...process.env, + SLAB_HOME: root, + SLAB_BIN: "/opt/slab/bin", + ZZZ_DRY_RUN: "1", + }; +} + +test("lists sleeping prompts and dry-run wake preserves their record", async () => { + const { root, state } = await fixture(); + const entry = { + version: 1, + id: "rock-one", + providerSessionId: providerId, + agentType: "codex", + cwd: "/tmp/project", + subject: "measure the prompt wall", + summary: "measure prompt wall", + tty: "ttys099", + parkedAt: "2026-07-20T20:00:00Z", + lastActiveAt: "2026-07-20T19:30:00Z", + reason: "idle-20m", + }; + const path = join(state, "zzz", "rock-one.json"); + await writeFile(path, JSON.stringify(entry)); + + const listed = await execFileAsync(process.execPath, [zzz, "list", "--json"], { env: env(root) }); + assert.deepEqual(JSON.parse(listed.stdout).map((row) => row.id), ["rock-one"]); + + const humanList = await execFileAsync(process.execPath, [zzz, "list"], { env: env(root) }); + assert.match(humanList.stdout, /resume: zzz resume 1/); + assert.match(humanList.stdout, /resume the newest: zzz resume/); + + const woke = await execFileAsync(process.execPath, [zzz, "resume"], { env: env(root) }); + assert.match(woke.stdout, /woke codex/); + assert.equal(JSON.parse(await readFile(path, "utf8")).providerSessionId, providerId); +}); + +test("park refuses a working prompt unless explicitly forced", async () => { + const { root, state } = await fixture(); + const marker = { + session_id: "working-rock", + codex_session_id: providerId, + agent_type: "codex", + agent_pid: 999999, + tty: "ttys098", + cwd: "/tmp/project", + subject: "still running", + updated: new Date().toISOString(), + state: "working", + }; + await writeFile(join(state, "active-prompts", marker.session_id), JSON.stringify(marker)); + + await assert.rejects( + execFileAsync(process.execPath, [zzz, "park", marker.session_id], { env: env(root) }), + /refusing to zzz working prompt/, + ); +}); + +test("park writes a resumable cold-tier record for a completed prompt", async () => { + const { root, state } = await fixture(); + const marker = { + session_id: "complete-rock", + codex_session_id: providerId, + agent_type: "codex", + agent_pid: 999999, + tty: "ttys097", + cwd: "/tmp/project", + subject: "finished turn", + summary: "finished turn", + updated: "2026-07-20T19:30:00Z", + state: "working", + }; + await writeFile(join(state, "active-prompts", marker.session_id), JSON.stringify(marker)); + await writeFile(join(state, "awaiting-prompts", marker.session_id), "turn complete\n"); + + const parked = await execFileAsync( + process.execPath, [zzz, "park", marker.session_id], { env: env(root) }, + ); + assert.match(parked.stdout, /zzz'd codex/); + const entry = JSON.parse(await readFile(join(state, "zzz", "complete-rock.json"), "utf8")); + assert.equal(entry.providerSessionId, providerId); + assert.equal(entry.reason, "manual"); +}); + +test("park keeps the target TTY and writes a colored stable resume receipt", async () => { + const { root, state } = await fixture(); + const marker = { + session_id: "12345678-receipt-rock", + codex_session_id: providerId, + agent_type: "codex", + agent_pid: 999999, + tty: "ttys096", + cwd: "/tmp/project", + subject: "leave a visible receipt", + summary: "visible receipt", + updated: "2026-07-20T19:30:00Z", + state: "working", + }; + await writeFile(join(state, "active-prompts", marker.session_id), JSON.stringify(marker)); + await writeFile(join(state, "awaiting-prompts", marker.session_id), "turn complete\n"); + const ttyPath = join(root, marker.tty); + await writeFile(ttyPath, "existing terminal output\n"); + + const receiptEnv = { + ...env(root), + ZZZ_DRY_RUN: "0", + ZZZ_TTY_ROOT: root, + }; + const parked = await execFileAsync( + process.execPath, [zzz, "park", marker.session_id], { env: receiptEnv }, + ); + assert.match(parked.stdout, /zzz resume 12345678/); + const tty = await readFile(ttyPath, "utf8"); + assert.match(tty, /\u001b\[1;38;5;213m💤 ZZZ parked this prompt/); + assert.match(tty, /\u001b\[1;38;5;51mResume: zzz resume 12345678/); + assert.match(tty, /^existing terminal output/m); +}); -- 2.51.2