Auto-submits /coordinator-handoff when a dotfiles coordinator session's context reaches the configured limit, with a visible status-line heartbeat

A highly resilient, declarative dotfiles setup using Chezmoi, Mise, and Python, optimized for Linux-based devcontainers.
mise install # Install all tools
mise run lint # Run lint checks (read-only ≡ CI; `mise run fmt` auto-fixes)
uv run --project python pytest tests/ -x -q # Run all 190 tests
docker buildx bake dev-load # Build devcontainer locally
curl -fsSL https://raw.githubusercontent.com/sortakool/dotfiles/main/install.sh | bash
install.sh bootstraps mise.mise installs git, chezmoi, and uv.chezmoi init clones the repo and applies templated configs.uv run) handle complex orchestration and tool installations.DotfilesConfig centralizes 16 env vars; hk.pkl for git hooks with shared hk-common.pkl checks.dotfiles_setup).noqa, type: ignore, or pylint: disable — enforced by no_lint_skip hk step.changes path-gate → base-prep → p2996-prep → build → smoke-test (smoke + Dive); promote retags on main; benchmark + Trivy run async in image-analysis.yml. Docs-only changes skip the build chain.All tools are declared in mise.toml and installed via mise install. Python dependencies are managed via uv with python/pyproject.toml.
uv run --project python pytest tests/ -x -q # All tests
uv run --project python dotfiles-setup verify run # Contract verification
mise run pin-actions # Verify GHA SHA-pinning
mise run lint-docs # Validate agent documentationhooks/register.ts 318 lines1import type { EngineInterface, Register } from "claude-code";
2
3/**
4 * Auto-submits `/coordinator-handoff` when a dotfiles coordinator's context
5 * reaches the configured limit (spec:
6 * `docs/specs/coordinator-auto-handoff-2026-10-02.md` §3d).
7 *
8 * The module owns only a cheap pre-filter and the visible heartbeat; python
9 * (`dotfiles-setup coordinator-handoff decide`) owns every judgement: the role
10 * check against the bg job record, the fired-level state, the re-fire step.
11 * Helpers are deliberately local: each skills-dir plugin loads independently.
12 *
13 * Function-hook failures are skipped SILENTLY by the engine, so a broken
14 * trigger would look exactly like "below the limit" until "Prompt is too
15 * long". Every measurement therefore overwrites this plugin's status line —
16 * `handoff 23%/30%`, `handoff fired @30%`, `handoff next @35%`,
17 * `handoff n/a (not coordinator)`, `handoff DRY-RUN @30%`, or
18 * `handoff ERROR: <reason>` — and an absent or stale entry itself means the
19 * hook did not run. Every failure is a value, never a throw.
20 */
21
22const SKILL = "coordinator-handoff";
23const DEFAULT_LIMIT_PCT = 30;
24const MAX_PCT = 100;
25/** `uv run` can be cold; the engine's own default (30 s) is too tight. */
26const DECIDE_TIMEOUT_MS = 60_000;
27const REASON_MAX_CHARS = 80;
28const NEGATIVE_ROLE_TTL_MS = 10 * 60_000;
29type Mode = "real" | "probe" | "dry-run";
30
31/** Mirrors `Decision.to_json()` in `python/src/dotfiles_setup/coordinator_handoff.py`. */
32const REASONS = [
33 "fire", "not-coordinator", "below-limit", "below-next-step", "already-launched",
34 "launch-in-progress", "probe-done", "probe-in-progress",
35 "invalid-session-id", "invalid-percent", "state-write-failed", "state-locked", "state-unreadable",
36] as const; // Mirrors Python DecisionReason (Literal).
37type DecisionReason = (typeof REASONS)[number];
38type Decision = {
39 fire: boolean;
40 reason: DecisionReason;
41 level: number | null;
42 percent: number;
43 warnings: string[];
44};
45
46/** One toast per distinct reason: a repeating failure must not flood the bar. */
47const toasted = new Set<string>();
48const roles = new Map<string, "coordinator" | "not-below-limit" | "not">();
49const negativeExpires = new Map<string, number>();
50const probeErrors = new Map<string, string>();
51const measuring = new Map<string, Promise<void>>();
52
53function toastOnce($: EngineInterface, text: string): void {
54 if (toasted.has(text)) return;
55 toasted.add(text);
56 $.ui.toast(text);
57}
58
59function fail($: EngineInterface, reason: string): void {
60 const short = reason.length > REASON_MAX_CHARS ? `${reason.slice(0, REASON_MAX_CHARS)}…` : reason;
61 try {
62 $.ui.status(`handoff ERROR: ${short}`);
63 toastOnce($, `coordinator-handoff: ${short}`);
64 } catch {
65 // Nothing left to report through; the engine skips a throw silently anyway.
66 }
67}
68
69/** Same acceptance as python's `_parse_pct`: a number in (0, 100], else 30. */
70function parseLimit(raw: string | undefined): number {
71 if (raw === undefined || raw.trim() === "") return DEFAULT_LIMIT_PCT;
72 const value = Number(raw);
73 return Number.isFinite(value) && value > 0 && value <= MAX_PCT ? value : DEFAULT_LIMIT_PCT;
74}
75
76/** Validate untrusted subprocess JSON before anything acts on it. */
77function parseDecision(stdout: string): Decision | null {
78 let value: unknown;
79 try {
80 value = JSON.parse(stdout);
81 } catch {
82 return null;
83 }
84 if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
85 const record = value as Record<string, unknown>;
86 const { fire, reason, level, percent, warnings } = record;
87 if (typeof fire !== "boolean" || typeof reason !== "string" || typeof percent !== "number") {
88 return null;
89 }
90 if (!REASONS.some((candidate) => candidate === reason)) return null;
91 if (level !== null && typeof level !== "number") return null;
92 if (!Array.isArray(warnings) || !warnings.every((w) => typeof w === "string")) return null;
93 return { fire, reason: reason as DecisionReason, level, percent, warnings: warnings as string[] };
94}
95
96/** The env the decision depends on, handed down explicitly (literal names only). */
97async function decisionEnv($: EngineInterface): Promise<Record<string, string>> {
98 const env: Record<string, string> = {};
99 const limit = await $.env.get("DOTFILES_COORDINATOR_HANDOFF_PCT");
100 const step = await $.env.get("DOTFILES_COORDINATOR_HANDOFF_STEP_PCT");
101 if (limit !== undefined) env.DOTFILES_COORDINATOR_HANDOFF_PCT = limit;
102 if (step !== undefined) env.DOTFILES_COORDINATOR_HANDOFF_STEP_PCT = step;
103 return env;
104}
105
106/** Ask python; a string answer is the reason it could not be asked. */
107async function decide(
108 $: EngineInterface,
109 sessionId: string,
110 percent: number,
111 mode: Mode,
112): Promise<Decision | string> {
113 let run: { exitCode: number; stdout: string; stderr: string };
114 try {
115 const projectDir = (await $.env.get("CLAUDE_PROJECT_DIR")) ?? (await $.session.root());
116 run = await $.process.run(
117 [
118 "uv",
119 "run",
120 "--project",
121 "python",
122 "dotfiles-setup",
123 "coordinator-handoff",
124 "decide",
125 "--session-id",
126 sessionId,
127 "--percent",
128 String(percent),
129 ...(mode === "real" ? [] : [`--${mode}`]),
130 ],
131 { cwd: projectDir, env: await decisionEnv($), timeoutMs: DECIDE_TIMEOUT_MS },
132 );
133 } catch {
134 return "decide failed to run";
135 }
136 if (run.exitCode !== 0) return `decide rc ${run.exitCode}`;
137 return parseDecision(run.stdout) ?? "decide output not JSON";
138}
139
140/**
141 * The skill's command name as the engine lists it — resolved, never assumed
142 * (spec P2): a skills-dir plugin may list it bare or plugin-qualified.
143 */
144async function resolveCommand($: EngineInterface): Promise<string | undefined> {
145 const commands = await $.command.list();
146 return (
147 commands.find((c) => c.name === SKILL)?.name ??
148 commands.find((c) => c.name.endsWith(`:${SKILL}`))?.name
149 );
150}
151
152function belowStatus($: EngineInterface, decision: Decision, percent: number): void {
153 if (decision.reason === "not-coordinator") {
154 $.ui.status("handoff n/a (not coordinator)");
155 } else if (decision.reason === "below-next-step") {
156 $.ui.status(`handoff next @${decision.level ?? "?"}%`);
157 } else if (decision.reason === "below-limit") {
158 $.ui.status(`handoff ${percent}%/${decision.level ?? "?"}%`);
159 } else if (decision.reason === "already-launched") {
160 $.ui.status("handoff already launched");
161 } else if (decision.reason === "launch-in-progress") {
162 $.ui.status("handoff launch in progress");
163 } else if (decision.reason === "probe-done") {
164 $.ui.status("handoff probe done");
165 } else if (decision.reason === "probe-in-progress") {
166 $.ui.status("handoff probe pending");
167 } else {
168 fail($, decision.reason);
169 }
170}
171
172async function releaseFailure(
173 $: EngineInterface, sessionId: string, level: number, mode: Mode | "none", reason: string,
174): Promise<void> {
175 if (mode === "probe") {
176 reason = "probe delivery failed";
177 probeErrors.set(sessionId, reason);
178 }
179 fail($, reason);
180 if (mode === "dry-run" || mode === "none") return;
181 try {
182 const projectDir = (await $.env.get("CLAUDE_PROJECT_DIR")) ?? (await $.session.root());
183 const run = await $.process.run(
184 ["uv", "run", "--project", "python", "dotfiles-setup", "coordinator-handoff",
185 "release", "--session-id", sessionId,
186 ...(mode === "probe" ? ["--probe"] : ["--level", String(level)])],
187 { cwd: projectDir, timeoutMs: DECIDE_TIMEOUT_MS },
188 );
189 if (run.exitCode !== 0) {
190 fail($, `${reason}; release rc ${run.exitCode}`);
191 return;
192 }
193 } catch {
194 fail($, `${reason}; release failed to run`);
195 return;
196 }
197 // Releasing never overwrites delivery failure with a successful heartbeat.
198 fail($, reason);
199}
200
201async function confirmProbe($: EngineInterface, sessionId: string): Promise<void> {
202 try {
203 const projectDir = (await $.env.get("CLAUDE_PROJECT_DIR")) ?? (await $.session.root());
204 const run = await $.process.run(
205 ["uv", "run", "--project", "python", "dotfiles-setup", "coordinator-handoff",
206 "release", "--session-id", sessionId, "--probe", "--delivered"],
207 { cwd: projectDir, timeoutMs: DECIDE_TIMEOUT_MS },
208 );
209 if (run.exitCode !== 0) throw new Error(`confirmation rc ${run.exitCode}`);
210 probeErrors.delete(sessionId);
211 $.ui.status("handoff probe done");
212 } catch {
213 probeErrors.set(sessionId, "probe confirmation failed");
214 fail($, "probe confirmation failed");
215 }
216}
217
218async function measure($: EngineInterface, sessionId: string, percent: number): Promise<void> {
219 if (roles.get(sessionId) === "not") {
220 if (await $.clock.now() < (negativeExpires.get(sessionId) ?? 0)) {
221 $.ui.status("handoff n/a (not coordinator)");
222 return;
223 }
224 roles.delete(sessionId);
225 negativeExpires.delete(sessionId);
226 }
227 const limit = parseLimit(await $.env.get("DOTFILES_COORDINATOR_HANDOFF_PCT"));
228 if (roles.get(sessionId) === "not-below-limit" && percent < limit) {
229 $.ui.status("handoff n/a (not coordinator)");
230 return;
231 }
232 if (roles.get(sessionId) === "coordinator" && percent < limit) {
233 // Once the role is known, below the limit needs no process.
234 $.ui.status(`handoff ${percent}%/${limit}%`);
235 return;
236 }
237 const probe = (await $.env.get("DOTFILES_COORDINATOR_HANDOFF_PROBE")) === "1";
238 const dryRun = (await $.env.get("DOTFILES_COORDINATOR_HANDOFF_DRY_RUN")) === "1";
239 // DRY_RUN wins when both are set: it never submits a command.
240 const mode = dryRun ? "dry-run" : probe ? "probe" : "real";
241 const decision = await decide($, sessionId, percent, mode);
242 if (typeof decision === "string") {
243 fail($, decision);
244 return;
245 }
246 if (decision.reason === "not-coordinator") {
247 // A transient first miss below the limit gets exactly one re-check at it.
248 roles.set(sessionId, percent < limit ? "not-below-limit" : "not");
249 if (percent >= limit) negativeExpires.set(sessionId, await $.clock.now() + NEGATIVE_ROLE_TTL_MS);
250 } else if (["fire", "below-limit", "below-next-step", "already-launched", "launch-in-progress", "probe-done", "probe-in-progress"].includes(decision.reason)) {
251 roles.set(sessionId, "coordinator");
252 }
253 const level = decision.level ?? percent;
254 const args = probe ? "--probe" : `${sessionId} ${percent}`;
255 try {
256 for (const warning of decision.warnings) toastOnce($, `coordinator-handoff: ${warning}`);
257 if (!decision.fire) {
258 if (mode === "probe" && probeErrors.has(sessionId)
259 && ["probe-done", "probe-in-progress"].includes(decision.reason)) {
260 fail($, probeErrors.get(sessionId) ?? "probe delivery failed");
261 return;
262 }
263 belowStatus($, decision, percent);
264 return;
265 }
266 if (dryRun) {
267 const line = `coordinator-handoff DRY-RUN: would run /${SKILL} ${args} (context ${percent}%, level ${level}%)`;
268 $.ui.status(`handoff DRY-RUN @${level}%`);
269 toastOnce($, line);
270 $.ui.log(line);
271 return;
272 }
273 const command = await resolveCommand($);
274 if (command === undefined) {
275 await releaseFailure($, sessionId, level, mode, "skill not listed");
276 return;
277 }
278 const line = `coordinator-handoff: context ${percent}% reached ${level}% — running /${command} ${args}`;
279 $.ui.status(mode === "probe" ? "handoff probe pending" : `handoff fired @${level}%`);
280 $.ui.toast(line);
281 $.ui.log(line);
282 // Completion is queued until idle: never await command.run inside the hook.
283 void $.command.run({ command, args }).then(async () => {
284 if (mode === "probe") await confirmProbe($, sessionId);
285 }).catch((error: unknown) =>
286 releaseFailure($, sessionId, level, mode,
287 `command.run rejected: ${error instanceof Error ? error.message : String(error)}`),
288 );
289 } catch (error: unknown) {
290 await releaseFailure($, sessionId, level, decision.fire ? mode : "none",
291 `delivery failed: ${error instanceof Error ? error.message : String(error)}`);
292 }
293}
294
295export const register: Register = (on) => {
296 on("session.measure", async ($, e, next) => {
297 const result = await next(e);
298 try {
299 const percent = e.context.percent;
300 if (!e.changed.includes("context") || typeof percent !== "number" || !Number.isFinite(percent)) {
301 return result;
302 }
303 const sessionId = await $.session.id();
304 // Serialise measurements for one session so the first role query is unique.
305 const work = (measuring.get(sessionId) ?? Promise.resolve()).then(() => measure($, sessionId, percent));
306 measuring.set(sessionId, work);
307 try {
308 await work;
309 } finally {
310 if (measuring.get(sessionId) === work) measuring.delete(sessionId);
311 }
312 } catch (error: unknown) {
313 fail($, `hook failed: ${error instanceof Error ? error.message : String(error)}`);
314 }
315 return result;
316 });
317};
318