SLOPSHOPPER

x-mod-holds

no two sessions write the same file: a session's first edit of a file holds it until it is committed, the session ends or sits idle 30 min; another session's…

newguardpromptprocess
A shopper browsing a rack in a slop shop
README

🎞️ the frame: a place where most of the machine's setup lives as data.

<a href="https://github.com/dvakatsiienko/frame/actions/workflows/ci.yml"><img src="https://raw.githubusercontent.com/dvakatsiienko/frame/badges/ci.svg" alt="ci"></a> <img src="assets/badges/tests.svg" alt="tests"> <img src="assets/badges/renovate.svg" alt="renovate"> <img src="assets/badges/node.svg" alt="node"> <img src="assets/badges/pnpm.svg" alt="pnpm"> <img src="assets/badges/skills.svg" alt="skills"> <img src="assets/badges/mirrored.svg" alt="mirrored">

<img src="assets/banner.svg" align="left" width="100%" alt="frame — the inventory of one mac"> <img src="assets/mac.svg" align="right" width="36%" alt="an 80s mac showing ~ frame">

🧭 toc

  • fleet — skills, memory, sline, hotkeys
  • chords — the keyboard map, live
  • mirror — home/ is ~
  • machine — brew, defaults, launchd
  • link it — a fresh mac install

🛸 fleet

home/.claude/ is the claude code setup every session reads: memories, rules, skills (plugin-x), hooks, output styles, and sline, the statusline. cclio/ is the coordinator's home, the session that plans and routes work to background coders. x/ is x, the agent-first cli in go + charm: one verb registry, json for agents and boards for humans (x lane commits and pushes from a sandboxed worktree). hotkeys/ maps every keyboard chord on the machine and serves chords, the map's app. x-speak (a schedule/ daemon) reads the selected text aloud on F4 (with nothing selected it pauses / resumes, as ⇧F4 always does) and stops on F5, rewriting ids, versions, paths and code into something a voice can say; speak/ is its voice admin (pnpm speak:admin).

<img src="home/.claude/sline/showcase/sline.svg" width="100%" alt="sline, the statusline, as a session climbs from fresh to heavy">

🎹 chords

chords is a keyboard map app: every key binding on every modifier layer, how often each one fires, and which keys are still free. powered by launchd daemon that counts key presses.

<img src="hotkeys/chords/showcase.png" width="100%" alt="chords: the hyper layer on a NuPhy Air75, each bound key with its app and press count">

🪞 mirror

the mirror links dotfiles and configs. a path under home/ is the same path under ~. the link map is derived by walking the tree, so adding a file to home/ auto-tracks it.

pnpm frame:link                        # status
pnpm frame:link apply                  # link everything not linked yet
pnpm frame:link register ~/.foo        # move a file into the mirror and link it back
pnpm frame:link untrack ~/.gitconfig   # hand a file back to ~

<img src="assets/frame-link.gif" width="720" alt="pnpm frame:link, ending on everything mirrored">

💻 machine

the mac's setup is kept as data, because data does not rot and scripts do: the Brewfile, the macos defaults, the duti file bindings, and the launchd jobs under schedule/.

pnpm macos:setup   # brew bundle, macos defaults, duti, vim-plug

🔗 link it

on a fresh machine, install the command line tools first — the clone itself needs git, and macos ships only a shim that opens the install dialog. then clone to ~/frame and run the seed, or hand it to an agent («seed this mac from frame»):

xcode-select --install      # stop 0: the dialog, ~2 min
git clone https://github.com/dvakatsiienko/frame ~/frame && cd ~/frame
script/seed.sh                     # command line tools → brew → fnm, pnpm, node → pnpm i → macos:setup → sline → claude cli → frame:link apply
script/seed.sh --claude            # the same, with ~/.claude linked
script/seed.sh --without-appstore  # skip the app store apps and their sign-in
script/seed.sh --dry-run           # what it would do, nothing changed

one status line per step, safe to re-run. it stops with needs your hands: … (exit 2) where only a human can act — the command line tools dialog, the homebrew password, files in the way of a link, the app store and 1password sign-ins — and the next run picks up from there. when it ends, open a new terminal: a shell opened before the seed never sources the zsh stubs it wrote.

Source 1 files
hooks/register.ts 344 lines
1import type { EngineInterface, Register } from 'claude-code';
2
3// x-mod-holds: no two sessions write the same file. a session's first Edit or Write of a file holds it until the file
4// is committed, the session ends or sits idle; another session's edit of it, or a Bash write to it, is refused and
5// names the holder. x-mod-guard refuses a Bash write to a held file from this store.
6
7const errorText = (err: unknown) =>
8    err instanceof Error ? err.message : String(err);
9
10// this session's claude process, so a rival can tell a dead holder from a live one
11let proc: Proc | undefined;
12
13// holds: a session's first edit of a file holds it; another session's edit of it is refused.
14// $.store has no compare-and-set, so each session writes only its own keys and the earliest claim wins.
15
16// file is the real path in its own case, for git; landed once the edit went through
17type Hold = { at: number; file: string; top: string; landed?: boolean };
18type Claim = { deny?: string; key?: string };
19type Holder = { pid?: number; start?: string; idleSince: number | null };
20type Proc = { pid: number; start: string };
21
22const HOLD = 'hold:';
23const HOLDER = 'holder:';
24const REFUSED = 'refused:';
25const IDLE_MS = 30 * 60 * 1000;
26
27const holdKey = (sid: string, path: string) => `${HOLD}${sid}:${path}`;
28const short = (sid: string) => sid.slice(0, 8);
29const dirname = (file: string) => file.slice(0, file.lastIndexOf('/')) || '/';
30
31// a session id carries no ':', so the first one after the prefix ends it
32function parseHoldKey(key: string) {
33    if (!key.startsWith(HOLD)) return null;
34    const cut = key.indexOf(':', HOLD.length);
35    return cut < 0
36        ? null
37        : { path: key.slice(cut + 1), sid: key.slice(HOLD.length, cut) };
38}
39
40// the key is lowercased on every platform: APFS is case-insensitive and the mod cannot ask which os it runs on
41async function realPath($: EngineInterface, file: string) {
42    const resolve = (p: string) =>
43        $.fs.stat(p, { resolve: true }).then(
44            (s) => s.realPath,
45            () => undefined,
46        );
47    const direct = await resolve(file);
48    if (direct) return { file: direct, key: direct.toLowerCase() };
49    // a new file has no real path yet; its nearest existing folder does
50    let dir = dirname(file);
51    let real = await resolve(dir);
52    while (!real && dir !== '/') {
53        dir = dirname(dir);
54        real = await resolve(dir);
55    }
56    const full = `${real ?? dir}${file.slice(dir.length)}`;
57    return { file: full, key: full.toLowerCase() };
58}
59
60async function procOf($: EngineInterface): Promise<Proc | undefined> {
61    const r = await $.process.run([
62        'sh',
63        '-c',
64        'echo $PPID; ps -o lstart= -p $PPID',
65    ]);
66    const [pid, start] = r.stdout.split('\n').map((s) => s.trim());
67    return r.exitCode === 0 && pid && start
68        ? { pid: Number(pid), start }
69        : undefined;
70}
71
72// the working tree a path sits in, lowercased; '' outside git
73async function topOf($: EngineInterface, cwd: string) {
74    const r = await $.process
75        .run(['git', 'rev-parse', '--show-toplevel'], { cwd })
76        .catch(() => null);
77    return r?.exitCode === 0 ? r.stdout.trim().toLowerCase() : '';
78}
79
80async function isClean($: EngineInterface, file: string) {
81    const r = await $.process
82        .run(['git', 'status', '--porcelain', '--', file], {
83            cwd: dirname(file),
84        })
85        .catch(() => null);
86    // the folder is gone, and the file with it
87    if (!r) return true;
88    if (r.exitCode === 0) return r.stdout.trim() === '';
89    // outside a repo nothing can be committed, so nothing is held
90    if (!/not a git repository/i.test(r.stderr))
91        $.ui.log(`x-mod-holds: git status failed on ${file}, hold released`);
92    return true;
93}
94
95// a holder that ended, went idle or died holds nothing
96async function isGone(
97    $: EngineInterface,
98    holder: Holder | undefined,
99    now: number,
100) {
101    if (!holder) return true;
102    if (holder.idleSince !== null && now - holder.idleSince >= IDLE_MS)
103        return true;
104    if (holder.pid && holder.start) {
105        const ps = await $.process.run([
106            'ps',
107            '-o',
108            'lstart=',
109            '-p',
110            String(holder.pid),
111        ]);
112        // a reused pid starts at another time
113        if (ps.stdout.trim() !== holder.start) return true;
114    }
115    return false;
116}
117
118async function isReleased(
119    $: EngineInterface,
120    sid: string,
121    hold: Hold,
122    now: number,
123) {
124    const holder = (await $.store.get(HOLDER + sid)) as Holder | undefined;
125    if (await isGone($, holder, now)) return true;
126    // a hold whose edit has not landed yet (a permission prompt open) is clean and still held
127    return hold.landed === true && isClean($, hold.file);
128}
129
130// a holder that crashed or went idle never settles its own keys
131async function sweep($: EngineInterface, self: string) {
132    const now = await $.clock.now();
133    for (const key of await $.store.keys()) {
134        if (!key.startsWith(HOLDER) || key === HOLDER + self) continue;
135        const holder = (await $.store.get(key)) as Holder | undefined;
136        if (await isGone($, holder, now))
137            await dropAll($, key.slice(HOLDER.length));
138    }
139}
140
141async function claimsOn($: EngineInterface, path: string) {
142    const claims: { sid: string; key: string; hold: Hold }[] = [];
143    for (const key of await $.store.keys()) {
144        const parsed = parseHoldKey(key);
145        if (parsed?.path !== path) continue;
146        const hold = (await $.store.get(key)) as Hold | undefined;
147        if (hold) claims.push({ hold, key, sid: parsed.sid });
148    }
149    return claims;
150}
151
152function refusal(file: string, sid: string, hold: Hold, now: number) {
153    const mins = Math.round((now - hold.at) / 60000);
154    return `x-mod-holds: ${file} is held by session ${short(sid)}, which took it ${mins} min ago. wait, or ask it to commit the file.`;
155}
156
157// the refusal when another live session holds the path; a released hold is cleared on the way
158async function heldBy(
159    $: EngineInterface,
160    sid: string,
161    file: string,
162    path: string,
163    now: number,
164) {
165    for (const c of await claimsOn($, path)) {
166        if (c.sid === sid) continue;
167        if (await isReleased($, c.sid, c.hold, now)) {
168            await $.store.delete(c.key);
169            continue;
170        }
171        await $.store.set(REFUSED + c.sid, { at: now, by: sid, path });
172        return refusal(file, c.sid, c.hold, now);
173    }
174    return undefined;
175}
176
177// a deny when another session holds the file; the key when this call took a new hold
178async function claim(
179    $: EngineInterface,
180    file: string,
181    proc: Proc | undefined,
182): Promise<Claim> {
183    const sid = await $.session.id();
184    const { key: path, file: real } = await realPath($, file);
185    const now = await $.clock.now();
186    const deny = await heldBy($, sid, file, path, now);
187    if (deny) return { deny };
188    const mine = holdKey(sid, path);
189    const held = (await $.store.get(mine)) as Hold | undefined;
190    // a first edit that failed left the hold unlanded; the next one that goes through lands it
191    if (held) return held.landed ? {} : { key: mine };
192    // the holder first: a rival reading the hold without it would take it for released
193    if (!(await $.store.get(HOLDER + sid)))
194        await $.store.set(HOLDER + sid, { ...proc, idleSince: null });
195    await $.store.set(mine, {
196        at: now,
197        file: real,
198        top: await topOf($, dirname(real)),
199    });
200    // no compare-and-set: a rival that wrote meanwhile refuses this claim; a true tie refuses both, the next try settles it
201    const rival = (await claimsOn($, path)).find((c) => c.sid !== sid);
202    if (!rival) return { key: mine };
203    await $.store.delete(mine);
204    return { deny: refusal(file, rival.sid, rival.hold, now) };
205}
206
207async function land($: EngineInterface, key: string) {
208    const hold = (await $.store.get(key)) as Hold | undefined;
209    if (hold) await $.store.set(key, { ...hold, landed: true });
210}
211
212async function markBusy($: EngineInterface) {
213    const sid = await $.session.id();
214    const holder = (await $.store.get(HOLDER + sid)) as Holder | undefined;
215    if (holder?.idleSince != null)
216        await $.store.set(HOLDER + sid, { ...holder, idleSince: null });
217}
218
219// drop this session's holds whose files are clean again; answers how many are left
220async function releaseClean($: EngineInterface, sid: string) {
221    let left = 0;
222    for (const key of await $.store.keys()) {
223        if (parseHoldKey(key)?.sid !== sid) continue;
224        const hold = (await $.store.get(key)) as Hold | undefined;
225        if (!hold || (await isClean($, hold.file))) await $.store.delete(key);
226        else left++;
227    }
228    return left;
229}
230
231// a commit run through the shell, `git commit` or `x lane commit`
232const COMMIT = /\b(git|x\s+lane)\s+(-C\s+\S+\s+)?commit\b/;
233
234// a turn ended: start the idle clock and drop the holds whose files are clean again
235async function settle($: EngineInterface) {
236    const sid = await $.session.id();
237    await sweep($, sid);
238    const holder = (await $.store.get(HOLDER + sid)) as Holder | undefined;
239    if (!holder) return;
240    if (await releaseClean($, sid)) {
241        await $.store.set(HOLDER + sid, {
242            ...holder,
243            idleSince: await $.clock.now(),
244        });
245        return;
246    }
247    await $.store.delete(HOLDER + sid);
248    await $.store.delete(REFUSED + sid);
249}
250
251async function dropAll($: EngineInterface, sid: string) {
252    for (const key of await $.store.keys())
253        if (parseHoldKey(key)?.sid === sid) await $.store.delete(key);
254    await $.store.delete(HOLDER + sid);
255    await $.store.delete(REFUSED + sid);
256}
257
258// fail-open: a store or git error lets the edit through, with a line in the transcript
259// the tool input is the model's: a path that is not a string is not guarded
260async function guard($: EngineInterface, file: unknown): Promise<Claim> {
261    if (typeof file !== 'string') return {};
262    try {
263        return await claim($, file, proc);
264    } catch (err) {
265        $.ui.log(
266            `x-mod-holds: ${errorText(err)}; the edit went through unguarded`,
267        );
268        return {};
269    }
270}
271
272export const register: Register = (on) => {
273    on('session.start', async ($, e, next) => {
274        proc = await procOf($).catch(() => {
275            $.ui.log(
276                'x-mod-holds: no pid for this session, a dead holder releases only by idle',
277            );
278            return undefined;
279        });
280        return next(e);
281    });
282
283    // a new turn: this session is no longer idle, so its holds keep
284    on('prompt.submit', async ($, e, next) => {
285        await markBusy($).catch(() => undefined);
286        return next(e);
287    });
288
289    on('turn.complete', async ($, e, next) => {
290        const r = await next(e);
291        // a subagent's turn ending is not the session going idle
292        if (e.agentId) return r;
293        await settle($).catch(() =>
294            $.ui.log("x-mod-holds: could not settle this turn's holds"),
295        );
296        return r;
297    });
298
299    on('session.end', async ($, e, next) => {
300        await dropAll($, e.sessionId).catch(() => undefined);
301        return next(e);
302    });
303
304    // `/clear` and `/resume` leave the conversation: its holds go now
305    on('command.run', async ($, e, next) => {
306        if (e.command !== 'clear' && e.command !== 'resume') return next(e);
307        const left = await $.session.id().catch(() => undefined);
308        const r = await next(e);
309        if (left) await dropAll($, left).catch(() => undefined);
310        return r;
311    });
312
313    on(
314        'tool.call',
315        { tool: /^(Edit|Write|NotebookEdit)$/ },
316        async ($, e, next) => {
317            const g = await guard(
318                $,
319                'notebook_path' in e
320                    ? e.notebook_path
321                    : 'file_path' in e
322                      ? e.file_path
323                      : undefined,
324            );
325            if (g.deny) return { deny: g.deny };
326            const r = await next(e);
327            if (g.key && r.deny === undefined && !r.isError)
328                await land($, g.key).catch(() => undefined);
329            return r;
330        },
331    );
332
333    // a commit made through Bash releases its clean files now, not at the turn's end
334    on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
335        const command = 'command' in e ? e.command : undefined;
336        const r = await next(e);
337        if (typeof command === 'string' && COMMIT.test(command))
338            await releaseClean($, await $.session.id()).catch(() =>
339                $.ui.log('x-mod-holds: could not release after a commit'),
340            );
341        return r;
342    });
343};
344