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…

🎞️ 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">
home/ is ~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 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">
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">
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
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.
hooks/register.ts 344 lines1import 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