Your Codex pet (drawn by Hammerspoon) runs to the terminal window whose Claude session finished or needs you, with Approve / Deny buttons where an ask is real.

FancyZones-style window zones for macOS, built on Hammerspoon. Made for a 49" super-ultrawide screen (Dell U4924DW, 5120 × 1440), with a separate layout for each screen.
⌃⌥ + a zone's key moves the focused window into that zone.⌘⌥T. It uses the Oxford Spanish-English dictionary that comes with macOS, with no network.A pet runs to the terminal window whose Claude Code session finished, has a question or needs your permission. It uses your Codex pet from ~/.codex/pets (it runs in, waves, jumps, droops and looks at your mouse), or a drawn creature if you have none.
It has two halves: mascota.lua here draws the pet, and a Claude Code plugin tells it what happens. Install the plugin once, at the prompt of a Claude Code session:
/plugin install mascota --marketplace jordybc1503/hammerspoon-zonas
Answer y to add the marketplace, then pick a scope (user makes it work in every project).
On a Windows keyboard, ⌘ is the Windows key and ⌥ is Alt.
| Shortcut | What it does |
|---|---|
⌃⌥← / ⌃⌥→ | Left / right free side |
⌃⌥1 ⌃⌥2 ⌃⌥3 | The three center zones (together, the width of a 34" monitor) |
⌃⌥4 | Floating reading zone: a 16:9 24" monitor, on top of the others |
⌃⌥↩ | Full screen |
| Shift + drag | Drop the window on a zone (+ Ctrl: span zones) |
⌘⌥T | Translate the selected word |
brew install --cask hammerspoon~/.hammerspoon: git clone https://github.com/jordybc1503/hammerspoon-zonas.git ~/.hammerspoonswiftc -O ~/.hammerspoon/bin/dict.swift -o ~/.hammerspoon/bin/dictThe ▦ icon appears in the menu bar. It lists the shortcuts, the layout of each screen and the editor.
| File | What it is |
|---|---|
init.lua | The translator, and it starts the zones |
zonas.lua | Zones, templates, shortcuts, dragging, the ▦ menu, and the Spanish and English texts |
zonas.html | The editor, opened from the ▦ menu |
zonas.json | What you save in the editor. Without it, the defaults apply |
bin/dict.swift | The program that reads the macOS dictionary |
mascota.lua | The Claude Code pet: draws it, moves it, answers approvals |
claude-plugin/mascota/ | The Claude Code plugin that tells the pet what happens |
.claude-plugin/marketplace.json | Makes this repo a Claude Code marketplace for that plugin |
MIT
hooks/register.ts 181 lines1import type { EngineInterface, Register } from "claude-code";
2import { describe, excerpt } from "./text.ts";
3
4// The mascot itself lives in Hammerspoon (~/.hammerspoon/mascota.lua). This plugin drops one JSON
5// request per event in its inbox; Hammerspoon picks it up by deleting the file. For an approval or
6// a question the script then waits for Hammerspoon's answer file, so the hook waits without
7// spending its budget. A reply typed in the bubble comes back as replies/<session>.reply.
8const SCRIPT = `
9d="$HOME/Library/Caches/mascota"; f="$d/$1"; mkdir -p "$d"
10printf '%s' "$2" > "$f.tmp" && mv "$f.tmp" "$f.json"
11i=0; while [ -f "$f.json" ] && [ $i -lt 15 ]; do sleep 0.2; i=$((i+1)); done
12if [ -f "$f.json" ]; then rm -f "$f.json"; echo off; exit 0; fi
13[ "$3" = 1 ] || exit 0
14i=0; while [ ! -f "$f.answer" ] && [ $i -lt 1160 ]; do sleep 0.5; i=$((i+1)); done
15[ -f "$f.answer" ] && cat "$f.answer" && rm -f "$f.answer"
16`;
17
18// A reload starts a new id: Hammerspoon learns the window again at the next prompt.
19const session = crypto.randomUUID();
20let count = 0;
21let label: string | undefined; // the project folder, for the bubble
22// A background session's short id (`claude attach <id>`), or null: Hammerspoon opens it by this.
23let bg: string | null | undefined;
24const ASKING_MODES = new Set(["default", "acceptEdits", "plan"]);
25let mode: string | undefined;
26let lastAsk: { tool: string; detail: string } | undefined;
27
28// What the status line shows, for the bubble: model, branch, context, cost and time.
29async function statusLine($: EngineInterface): Promise<object> {
30 const cwd = await $.session.cwd();
31 const [model, usage, git, repo] = await Promise.all([
32 $.session.model(),
33 $.session.usage(),
34 $.process.run(["git", "-C", cwd, "branch", "--show-current"]).catch(() => undefined),
35 $.session.repo().catch(() => null),
36 ]);
37 return {
38 model,
39 branch: git?.stdout.trim() || undefined,
40 // Named like the status line: the origin remote's repository, else the repository's folder.
41 repo: (repo?.remote ?? repo?.root ?? cwd).replace(/\/+$/, "").split(/[/:]/).pop()?.replace(/\.git$/, ""),
42 pct: Math.round(usage.context.percent ?? 0),
43 usd: usage.cost?.usd,
44 minutes: Math.floor((Date.now() - usage.startedAt) / 60000),
45 };
46}
47
48async function send($: EngineInterface, kind: string, fields: object, wait = false): Promise<string> {
49 const id = `${session}-${++count}`;
50 const cwd = await $.session.cwd(); // the bubble opens it in your code editor
51 label ??= cwd.split("/").pop() ?? "";
52 // Claude's own id for the session (it changes on /clear): the bubble resumes it once it ended.
53 const claude = await $.session.id().catch(() => undefined);
54 if (bg === undefined) {
55 const job = (await $.env.get("CLAUDE_CODE_SESSION_KIND")) === "bg" && (await $.env.get("CLAUDE_JOB_DIR"));
56 bg = job ? (job.split("/").pop() ?? null) : null;
57 }
58 // Only a message the bubble shows carries it: bind, working and end never reach the bubble.
59 const status = ["done", "question", "ask", "needs"].includes(kind) ? await statusLine($).catch(() => undefined) : undefined;
60 const request = JSON.stringify({ kind, session, id, label, cwd, claude, wait, status, ...(bg ? { bg } : {}), ...fields });
61 const run = await $.process.run(["/bin/sh", "-c", SCRIPT, "mascota", id, request, wait ? "1" : "0"], {
62 timeoutMs: 600_000,
63 });
64 return run.stdout.trim();
65}
66
67// A turn's end and its Stop hook come in either order, and only Stop knows whether background
68// work (a shell, a subagent, a workflow) will wake the session again: "done" waits for both, and
69// a session with work in flight is still working.
70let ended: { text: string; seconds: number } | undefined;
71let busy: boolean | undefined;
72
73function settle($: EngineInterface): void {
74 if (!ended || busy === undefined) return;
75 const { text, seconds } = ended;
76 const working = busy;
77 ended = busy = undefined;
78 send($, working ? "working" : "done", working ? {} : { text, seconds, mode }).catch(() => {});
79}
80
81let home: string | undefined;
82
83async function pollReply($: EngineInterface): Promise<void> {
84 home ??= (await $.process.run(["/bin/sh", "-c", 'printf %s "$HOME"'])).stdout;
85 const file = `${home}/Library/Caches/mascota/replies/${session}.reply`;
86 if (!(await $.fs.exists(file))) return;
87 const run = await $.process.run(["/bin/sh", "-c", 'cat "$1" && rm -f "$1"', "mascota", file]);
88 const text = run.stdout.trim();
89 if (text) await $.prompt.submit({ text, asUser: true });
90}
91
92export const register: Register = (on) => {
93 on("session.start", ($, e, next) => {
94 $.clock.every(1500, () => pollReply($));
95 return next(e);
96 });
97
98 on("session.end", ($, e, next) => {
99 send($, "end", {}).catch(() => {});
100 return next(e);
101 });
102
103 // You just pressed Enter in this session's window: Hammerspoon remembers which window that is.
104 // A reply sent from the bubble is the plugin's own prompt: the window you are in is not this one.
105 on("prompt.submit", ($, e, next) => {
106 if (!e.isMeta && e.origin?.kind !== "plugin") send($, "bind", {}).catch(() => {});
107 return next(e);
108 });
109
110 on("turn.start", ($, e, next) => {
111 if (e.agentId === undefined) {
112 ended = busy = undefined;
113 send($, "working", {}).catch(() => {});
114 }
115 return next(e);
116 });
117
118 on("turn.complete", async ($, e, next) => {
119 const result = await next(e);
120 if (e.agentId === undefined && e.reason === "answer") {
121 const mine = (ended = { text: excerpt(e.answer), seconds: Math.round(e.durationMs / 1000) });
122 settle($);
123 // Without a Stop hook to say otherwise, the turn's end alone means done.
124 $.clock.sleep(3000).then(() => {
125 if (ended !== mine) return;
126 busy ??= false;
127 settle($);
128 }).catch(() => {});
129 }
130 return result;
131 });
132
133 on("classic.Stop", ($, e, next) => {
134 busy = (e.background_tasks?.length ?? 0) > 0;
135 settle($);
136 return next(e);
137 });
138
139 // The bubble shows the question and its options; an answer from it fills the tool's `answers`,
140 // as the terminal dialog would. "terminal" or no answer leave the dialog to you.
141 on("tool.call", { tool: "AskUserQuestion" }, async ($, e, next) => {
142 const questions = e.questions.map((q) => ({
143 question: q.question,
144 header: q.header,
145 multiSelect: q.multiSelect,
146 options: q.options.map((o) => ({ label: o.label, description: o.description })),
147 }));
148 const answer = await send($, "question", { questions }, true).catch(() => "");
149 if (!answer.startsWith("{")) return next(e);
150 return next({ ...e, answers: JSON.parse(answer) });
151 });
152
153 on("classic.UserPromptSubmit", ($, e, next) => {
154 mode = e.permission_mode;
155 return next(e);
156 });
157
158 // An "ask" only reaches you in these modes. In auto mode the classifier settles most asks after
159 // this hook, so the mascot waits for the real prompt (the Notification below) instead.
160 on("tool.check", async ($, e, next) => {
161 const verdict = await next(e);
162 if (verdict.decision !== "ask" || e.tool_use_id === undefined) return verdict;
163 lastAsk = { tool: e.tool, detail: describe(e.tool, e.input) };
164 if (!ASKING_MODES.has(mode ?? "")) return verdict;
165 // "terminal" (you went to the window), "off" (Hammerspoon is not running) or no answer leave the normal prompt.
166 const answer = await send($, "ask", lastAsk, true).catch(() => "");
167 if (answer === "allow") return { decision: "allow", reason: "Approved from the mascot" };
168 if (answer === "deny") return { decision: "deny", reason: "The user denied this from the mascot pop-up." };
169 return verdict;
170 });
171
172 // Claude Code is showing you a permission prompt now: the mascot points you to it.
173 on("classic.Notification", ($, e, next) => {
174 mode = e.permission_mode ?? mode;
175 if (e.notification_type === "permission_prompt") {
176 send($, "needs", lastAsk ?? { detail: e.message }).catch(() => {});
177 }
178 return next(e);
179 });
180};
181hooks/text.ts 28 lines1// The short texts the mascot shows: kept pure so the tests can read them.
2
3const MAX = 160;
4
5export function clip(text: string, max = MAX): string {
6 const flat = text.replace(/\s+/g, " ").trim();
7 return flat.length > max ? flat.slice(0, max - 1) + "…" : flat;
8}
9
10// The whole answer for the bubble, which scrolls: line breaks kept, cut at `max` characters.
11export function excerpt(answer: string, max = 4000): string {
12 const text = answer.trim();
13 return text.length > max ? text.slice(0, max - 1) + "…" : text;
14}
15
16
17// What the tool is about to do, in one line: the command, the file, or the arguments.
18export function describe(tool: string, input: unknown): string {
19 const i = (input ?? {}) as Record<string, unknown>;
20 const pick = i.command ?? i.file_path ?? i.notebook_path ?? i.url ?? i.pattern ?? i.path;
21 if (typeof pick === "string") return clip(pick);
22 try {
23 return clip(JSON.stringify(input));
24 } catch {
25 return tool;
26 }
27}
28