Watches moto's workers from inside the driver session and prompts it when one needs it.

My dotfiles and system configuration, declaratively managed with Nix.
| Layer | Tool |
|---|---|
| Config | Nix Flakes + Home Manager |
| WM | Niri |
| Terminal | Ghostty |
| Editor | Neovim (LazyVim) |
| Shell | Zsh + Starship |
nix/ # NixOS & Home Manager modules
config/ # Tool configs linked or seeded by Nix
bin/ # Scripts and utilities added to PATH
skills/ # Local agent skills linked into Claude, Pi, and Codex
Setting up a fresh Mac? Run bin/bootstrap-mac from the checkout for an interactive walkthrough, including adding a missing target. See the new Mac bootstrap guide for the initial clone and manual steps.
For this M1 Air's nix-darwin migration, Tailscale SSH, and closed-lid use, follow Mac remote access.
On the desktop, Z13, and M1, remote-mode on keeps the machine awake for remote access across logouts and reboots. Use remote-mode off to restore normal sleep behavior, or remote-mode status to check it. See remote mode for laptop precautions.
Use t3ctl to control T3 Code on obelisk from agents or scripts.
Private agent skills sync from Forgejo on opted-in personal hosts, outside this public checkout and the Nix store.
Use !fork-window in Claude or Codex, or /fork-window in Pi, to fork the current conversation into a Herdr tab or a new Ghostty window on Hyprland without a model turn in the original session. The fork shares the checkout, acknowledges a fork notice, and waits for instructions. From a terminal, use fork-window pi /path/to/session.jsonl, fork-window claude <session-uuid>, or fork-window codex <session-uuid>. Container sandboxes are not supported.
Use /spawn <task> in Claude to start a new session in a Herdr tab that begins work on a task from the current conversation. A fork of the conversation writes the task brief and picks the repo and worktree, then runs spawn-session, which opens the tab and starts the agent.
just switch # Apply config (NixOS, nix-darwin, or Home Manager)
just check # Format, lint, evaluatehooks/register.ts 97 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Pass } from '../types'
5
6// Each pass is one short `moto watch`, which hands over what changed since the last.
7const POLL_MS = 3000
8const TAG = '[moto watch]'
9const SECTION = {
10 id: 'moto-watch:watching',
11 scope: 'session',
12 text: [
13 'The moto-watch mod runs `moto watch` every 3 seconds for this session, so never run it yourself:',
14 'a pass hands its news over once. When a worker needs you, a prompt starting with',
15 `${TAG} arrives after your turn, with one line per worker. Notes from scheduled jobs`,
16 'arrive in the same prompt, each starting with [job].',
17 ].join(' '),
18} as const
19
20const lines = atom({ plugin: 'moto-watch', key: 'lines' } as const, [] as readonly string[])
21const notes = atom({ plugin: 'moto-watch', key: 'notes' } as const, [] as readonly string[])
22
23// Turns under way. News waits for the last to end, then arrives as one prompt.
24const turns = new Set<string>()
25let isPolling = false
26
27async function deliver($: EngineInterface) {
28 if (turns.size > 0) {
29 return
30 }
31 const held = await read($, lines)
32 const left = await read($, notes)
33 if (held.length === 0 && left.length === 0) {
34 return
35 }
36 await update($, lines, () => [])
37 await update($, notes, () => [])
38 const parts = held.length > 0 ? [[TAG, ...held].join('\n'), ...left] : left
39 void $.prompt.submit({ text: parts.join('\n\n') })
40}
41
42async function poll($: EngineInterface) {
43 if (isPolling) {
44 return
45 }
46 isPolling = true
47 try {
48 const { exitCode, stdout, stderr } = await $.process.run(['moto', 'watch'])
49 if (exitCode !== 0) {
50 $.ui.status(stderr.trim().split('\n')[0] || `moto watch exited ${exitCode}`)
51 return
52 }
53 const pass = JSON.parse(stdout) as Pass
54 $.ui.status(`watching ${pass.watching}`)
55 if (pass.lines.length > 0) {
56 await update($, lines, before => [...before, ...pass.lines])
57 }
58 const note = pass.notes
59 if (note !== null) {
60 await update($, notes, before => [...before, note])
61 }
62 await deliver($)
63 } catch (error) {
64 $.ui.status(error instanceof Error ? error.message : String(error))
65 } finally {
66 isPolling = false
67 }
68}
69
70export const register: Register = on => {
71 on('session.start', async ($, e, next) => {
72 const result = await next(e)
73 // A fork of the driver owns no task; moto refuses to watch from one.
74 if ((await $.env.get('MOTO_FORK')) === undefined) {
75 $.clock.every(POLL_MS, () => void poll($))
76 }
77 return result
78 })
79
80 on('prompt.compose', async ($, e, next) => {
81 const { sections } = await next(e)
82 return { sections: [...sections, SECTION] }
83 })
84
85 on('turn.start', ($, e, next) => {
86 turns.add(e.turnId)
87 return next(e)
88 })
89
90 on('turn.complete', async ($, e, next) => {
91 const result = await next(e)
92 turns.delete(e.turnId)
93 await deliver($)
94 return result
95 })
96}
97types/index.d.ts 14 lines1// What one `moto watch` prints.
2export type Pass = {
3 watching: number
4 lines: string[]
5 notes: string | null
6}
7
8declare module 'claude-code' {
9 interface PluginState {
10 // What passes found while a turn ran, held for the turn's end: worker lines and job notes.
11 'moto-watch': { lines: readonly string[]; notes: readonly string[] }
12 }
13}
14