SLOPSHOPPER

mascota

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.

guardpromptprocesstimer
A shopper browsing a rack in a slop shop
README

hammerspoon-zonas

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.

What it does

  • Zones from the keyboard: ⌃⌥ + a zone's key moves the focused window into that zone.
  • Zones with the mouse: drag a window while you hold Shift, then drop it on a zone. Also hold Ctrl to span several zones.
  • Visual editor (▦ menu → Edit zones and shortcuts…): split zones into columns or rows, drag the lines to resize, add floating zones that sit on top, and set each zone's key.
  • One layout per screen: the ultrawide and the laptop use different layouts. A screen with no choice gets the default layout if it is ultrawide, and Halves if it is not.
  • Configurable shortcuts, a gap between zones, and a Spanish or English interface (or the system language).
  • Translator: select a word in any app and press ⌘⌥T. It uses the Oxford Spanish-English dictionary that comes with macOS, with no network.

Claude Code pet

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 only shows up when you are not already in that session's window.
  • Click it to go to the window. In default permission mode, Approve and Deny answer the prompt from the pop-up; in auto mode it only points you to a real prompt.
  • The ▦ menu has a block of its own for the pet: turn it on or off, its Mode, the editor Open folders in uses, and Pet settings…, the editor's Pet tab (the pet, the events, its size and how it moves).
  • Drag it somewhere and the next pets on that screen come there instead of to their window.
  • Mode › Focus keeps it still: it only moves when a new message comes.
  • The bubble shows the session's folder, with a button that opens it in your code editor.

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).

Default shortcuts

On a Windows keyboard, ⌘ is the Windows key and ⌥ is Alt.

ShortcutWhat it does
⌃⌥← / ⌃⌥→Left / right free side
⌃⌥1 ⌃⌥2 ⌃⌥3The three center zones (together, the width of a 34" monitor)
⌃⌥4Floating reading zone: a 16:9 24" monitor, on top of the others
⌃⌥↩Full screen
Shift + dragDrop the window on a zone (+ Ctrl: span zones)
⌘⌥TTranslate the selected word

Install

  1. Install Hammerspoon: brew install --cask hammerspoon
  2. Clone this repo into ~/.hammerspoon: git clone https://github.com/jordybc1503/hammerspoon-zonas.git ~/.hammerspoon
  3. Build the translator: swiftc -O ~/.hammerspoon/bin/dict.swift -o ~/.hammerspoon/bin/dict
  4. Open Hammerspoon and give it Accessibility access (System Settings → Privacy & Security → Accessibility).

The ▦ icon appears in the menu bar. It lists the shortcuts, the layout of each screen and the editor.

Files

FileWhat it is
init.luaThe translator, and it starts the zones
zonas.luaZones, templates, shortcuts, dragging, the ▦ menu, and the Spanish and English texts
zonas.htmlThe editor, opened from the ▦ menu
zonas.jsonWhat you save in the editor. Without it, the defaults apply
bin/dict.swiftThe program that reads the macOS dictionary
mascota.luaThe 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.jsonMakes this repo a Claude Code marketplace for that plugin

License

MIT

Source 2 files
hooks/register.ts 181 lines
1import 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};
181
hooks/text.ts 28 lines
1// 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