SLOPSHOPPER

tiller

tiller node mod: inbox delivery, band, toasts and commands

newbandcommandtoastnetworktimer
★ 1v0.1.0no licenseupdated 2026-10-15swicken/tiller/mod
A shopper browsing a rack in a slop shop
README

tiller mod

Per-node Claude Code mod. It does nothing unless TL_NODE_ID is set (with TL_ENDPOINT, TL_TOKEN_FILE, optional TL_PARENT_ID). Every second it polls the daemon: /tree (the caller's whole tree, root first) feeds the band and toasts, and /inbox messages are delivered. While a turn runs (from turn.start to its turn.complete) a message stays held in the daemon so its sender can still withdraw it; once the turn ends the mod claims it with POST /claim (a withdrawn or expired message fails the claim and is skipped), submits it with $.prompt.submit and acks it with POST /ack once the prompt is accepted. A steer (send with mode "now") is not held: the mod claims it, aborts the running turn with $.turn.abort and submits it at once. Should a turn.complete never arrive, the hold is released once /tree has shown this node done on two polls in a row. It forwards turn.start, turn.complete and session.measure to /mod-event. It draws a one-line band above the prompt (in a child, ↑ parent and ⌂ root buttons; the needs-you child), and in-session toasts for children that need you, finish or error. The tree and node controls live in the app, not in Claude. The mod is silent while a tiller client is attached. Every /tree and /inbox response carries X-TL-Clients: app,browser (the kinds of authenticated ws clients now connected, empty when none); when it is non-empty the mod keeps a quiet state atom true, the AbovePrompt hook passes through, and it makes no toasts and no $.ui.log lines, because the client's sidebar already shows all of it. Inbox delivery, acks, event forwarding and slash commands carry on. When the last client disconnects the band and toasts return, and the first diff after that is a baseline so changes made while quiet are not replayed. The daemon loads it by launching claude --plugin-dir <repo>/mod ... with the TL_* variables in the environment. Commands: /tl-list, /tl-spawn <title> :: <prompt>, /tl-send <nodeId> <text>, /tl-read <nodeId>. The four call the daemon's POST /api/<tool> with the node's token and print the text result. The band's ↑ parent and ⌂ root buttons POST /focus {id} (any node in the same tree); the daemon broadcasts it and a tl client showing that node switches to the target. Source is TypeScript under hooks/: register.tsx holds everything that uses $; api.ts, notify.ts, commands.ts and band.tsx are pure helpers (the validator forbids passing $ to imported functions). Check with claude plugin validate mod/ and claude plugin test mod/; the quiet behaviour and the hold during a turn are tested against a stub daemon in mod/tests/quiet.test.tsx and mod/tests/hold.test.tsx. The mod sends no OS notifications: the daemon is the single sender (see docs/frontends.md), so the app and the terminal never both notify.

Source 6 files
hooks/register.tsx 124 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3import type { Child } from '../types'
4import { parse, request, type Ctx } from './api'
5import { Band } from './band'
6import { COMMANDS, parseSend, parseSpawn } from './commands'
7import { changes } from './notify'
8
9// tiller per-node mod. Inert unless TL_NODE_ID is set. Contract: src/shared/protocol.ts "Mod-facing HTTP API".
10const kids = atom({ plugin: 'tiller', key: 'children' } as const, [] as Child[])
11const pend = atom({ plugin: 'tiller', key: 'pending' } as const, 0)
12// True while any tiller client (app or browser) is attached: its sidebar makes everything the mod draws redundant.
13const quietAtom = atom({ plugin: 'tiller', key: 'quiet' } as const, false)
14let quiet = false
15let ctx: Ctx | undefined
16let parent: string | undefined, busy = false
17// The running turn's id, from turn.start; unknown after a hot reload mid-turn, when messages are submitted as before.
18let turn: string | undefined, endedTurn: string | undefined
19let prev: Child[] | null = null
20const inflight = new Set<string>()
21const lastToast = new Map<string, number>()
22
23async function call($: any, method: string, path: string, body?: unknown) {
24  const [url, init] = request(ctx!, method, path, body)
25  return parse(path, await $.http.fetch(url, init))
26}
27
28async function tool($: any, name: string, args: unknown): Promise<string> {
29  try { const r = await call($, 'POST', `/api/${name}`, args); return r.text ?? (r.data === undefined ? '(no output)' : JSON.stringify(r.data)) } catch (err) { return `error: ${err}` }
30}
31
32async function focus($: any, id: string, label: string) {
33  try { await call($, 'POST', '/focus', { id }); $.ui.toast(`→ ${label}`) } catch (err) { $.ui.toast(`tiller: focus failed: ${err}`) }
34}
35
36function forward($: any, event: string, payload: unknown) {
37  if (ctx) call($, 'POST', '/mod-event', { event, payload }).catch(() => {})
38}
39
40// At most one toast per key per 5 s. OS notifications are the daemon's job, not the mod's.
41async function notify($: any, key: string, text: string) {
42  const now = await $.clock.now()
43  if (quiet || now - (lastToast.get(key) ?? 0) < 5000) return
44  lastToast.set(key, now)
45  $.ui.toast(text)
46}
47
48async function poll($: any) {
49  if (busy) return
50  busy = true
51  try {
52    const [url, init] = request(ctx!, 'GET', '/tree')
53    const res = await $.http.fetch(url, init)
54    const list: Child[] = parse('/tree', res)
55    const q = !!res.headers['x-tl-clients']
56    if (q !== quiet) {
57      quiet = q
58      if (!q) prev = null // what changed while quiet is not news: the next diff is a baseline
59      await update($, quietAtom, () => q)
60    }
61    await update($, kids, () => list)
62    // Should a turn.complete never reach this hook (another plugin's hook answering without next), messages would be held
63    // until they expire; so release the hold once the daemon has seen this turn end on two polls in a row.
64    const ended = turn !== undefined && list.find(c => c.self)?.state === 'done'
65    if (ended && endedTurn === turn) turn = undefined
66    endedTurn = ended ? turn : undefined
67    if (prev) for (const c of changes(prev, list)) await notify($, c.key, c.text)
68    prev = list
69    const msgs = (await call($, 'GET', '/inbox')).filter((m: any) => !inflight.has(m.id))
70    await update($, pend, () => inflight.size + msgs.length)
71    for (const m of msgs) {
72      // While a turn runs, hold a message in the daemon so its sender can still withdraw it; a steer goes now.
73      if (turn && !m.steer) continue
74      inflight.add(m.id)
75      try { await call($, 'POST', '/claim', { id: m.id }) } catch { inflight.delete(m.id); continue } // withdrawn or expired
76      if (m.steer && turn) await $.turn.abort({ turnId: turn }).catch(() => {}) // the turn may have just ended
77      await notify($, `msg:${m.from}`, `message from ${m.from}`)
78      // submit resolves once the prompt is accepted (session idle); ack after that.
79      $.prompt.submit({ text: m.text, asUser: true }).then(async () => {
80        await call($, 'POST', '/ack', { id: m.id })
81        inflight.delete(m.id)
82      }).catch((err: unknown) => { inflight.delete(m.id); if (!quiet) $.ui.log(`tiller: deliver ${m.id} failed: ${err}`) })
83    }
84  } catch (err) { if (!quiet) $.ui.log(`tiller: poll error ${err}`) } finally { busy = false }
85}
86
87export const register: Register = on => {
88  on('session.start', async ($, e, next) => {
89    const id = await $.env.get('TL_NODE_ID')
90    if (!id) return next(e)
91    const token = (await $.fs.read((await $.env.get('TL_TOKEN_FILE'))!)).trim()
92    ctx = { id, endpoint: (await $.env.get('TL_ENDPOINT'))!, token }
93    parent = (await $.env.get('TL_PARENT_ID')) || undefined
94    for (const [name, description] of COMMANDS) await $.command.register({ name, description })
95    $.clock.every(1000, () => poll($))
96    return next(e)
97  })
98
99  on('turn.start', ($, e, next) => { turn = e.turnId; forward($, 'turn.start', e); return next(e) })
100  on('turn.complete', ($, e, next) => { if (turn === e.turnId) turn = undefined; forward($, 'turn.complete', e); return next(e) })
101  on('session.measure', ($, e, next) => { forward($, 'session.measure', e); return next(e) })
102
103  on('command.run', { command: 'tl-list' }, async $ => ({ text: await tool($, 'list_nodes', {}) }))
104  on('command.run', { command: 'tl-spawn' }, async ($, e) => {
105    const a = parseSpawn(e.args)
106    if (!a) return { text: 'usage: /tl-spawn <title> :: <prompt>' }
107    return { text: await tool($, 'spawn', { kind: 'claude', cwd: (await $.env.get('PWD')) || '.', ...a }) }
108  })
109  on('command.run', { command: 'tl-send' }, async ($, e) => {
110    const a = parseSend(e.args)
111    return { text: a ? await tool($, 'send', a) : 'usage: /tl-send <nodeId> <text>' }
112  })
113  on('command.run', { command: 'tl-read' }, async ($, e) => {
114    const nodeId = e.args.trim()
115    return { text: nodeId ? await tool($, 'read', { nodeId, what: 'summary' }) : 'usage: /tl-read <nodeId>' }
116  })
117
118  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
119    if (!ctx || (await read($, quietAtom))) return next(e)
120    const id = ctx.id
121    return Band($.ui.resolve(e), id, await read($, kids), await read($, pend), parent, (to, label) => focus($, to, label))
122  })
123}
124
hooks/api.ts 17 lines
1// Pure helpers for talking to the daemon; register.tsx does the actual $.http.fetch.
2export type Ctx = { id: string; endpoint: string; token: string }
3
4export const request = (c: Ctx, method: string, path: string, body?: unknown): [string, { method: string; headers: Record<string, string>; body?: string }] => [
5  `${c.endpoint}${path}`,
6  {
7    method,
8    headers: { Authorization: `Bearer ${c.token}`, 'X-TL-Node': c.id, 'Content-Type': 'application/json' },
9    body: body === undefined ? undefined : JSON.stringify(body),
10  },
11]
12
13export const parse = (path: string, r: { ok: boolean; status: number; text: string }) => {
14  if (!r.ok) throw new Error(`${path}: ${r.status} ${r.text}`)
15  return r.text ? JSON.parse(r.text) : undefined
16}
17
hooks/band.tsx 21 lines
1import type { Child } from '../types'
2import { needsYou } from './notify'
3export type Els = { Box: any; Text: any; Button: any }
4
5/** One line above the prompt. In a child the parent (and, when different, the root) is a button that focuses it. */
6export function Band({ Box, Text, Button }: Els, id: string, list: Child[], pending: number, parentId: string | undefined, focus: (id: string, label: string) => void) {
7  const rootId = list[0] && !list[0].self ? list[0].id : undefined
8  const me = list.find(c => c.self)
9  const needy = list.filter(c => !c.self && needsYou(c))
10  const first = needy[0], k = needy.length
11  return (
12    <Box columnGap={1}>
13      <Text dimColor wrap="truncate-end">tiller · {me?.title ?? id} ·{parentId ? '' : ' parent none'}</Text>
14      {parentId && <Button plain key="parent" onPress={() => focus(parentId, 'parent')}>↑ parent</Button>}
15      {rootId && rootId !== parentId && <Button plain key="root" onPress={() => focus(rootId, 'root')}>⌂ root</Button>}
16      {first && <Text color="yellow" wrap="truncate-end">· ! {first.title} [{first.id.slice(-4)}]{k > 1 ? ` +${k - 1}` : ''}</Text>}
17      {pending > 0 && <Text dimColor>· {pending} pending</Text>}
18    </Box>
19  )
20}
21
hooks/commands.ts 16 lines
1export const COMMANDS: [string, string][] = [
2  ['tl-list', 'List tiller nodes'],
3  ['tl-spawn', 'Spawn a child: <title> :: <prompt>'], ['tl-send', 'Send a message: <nodeId> <text>'],
4  ['tl-read', 'Read a node summary: <nodeId>'],
5]
6
7export const parseSpawn = (args: string) => {
8  const [title, prompt] = args.split('::').map(s => s.trim())
9  return title ? { title, prompt: prompt || undefined } : undefined
10}
11
12export const parseSend = (args: string) => {
13  const [nodeId, ...text] = args.trim().split(/\s+/)
14  return nodeId && text.length ? { nodeId, text: text.join(' ') } : undefined
15}
16
hooks/notify.ts 19 lines
1import type { Child } from '../types'
2
3// Same rule as needsYou in src/shared/protocol.ts, which the mod cannot import.
4export const needsYou = (c: Child) => !c.exited && (c.state === 'blocked' || c.state === 'waiting')
5export const kind = (c: Child) => (c.errored ? 'error' : needsYou(c) ? 'needs' : c.state === 'done' ? 'done' : '')
6
7/** Toasts for descendants whose kind changed since `prev`. */
8export function changes(prev: Child[], list: Child[]) {
9  const out: { key: string; text: string }[] = []
10  for (const c of list) {
11    if (c.self) continue
12    const was = prev.find(p => p.id === c.id)
13    const k = kind(c)
14    if (!was || !k || kind(was) === k) continue
15    out.push({ key: c.id, text: k === 'done' ? `${c.title} finished` : k === 'error' ? `${c.title} errored` : `${c.title} needs you` })
16  }
17  return out
18}
19
types/index.d.ts 12 lines
1export type Child = {
2  id: string; title: string; depth: number; state: string; activity?: string
3  ageSec: number; lastMessage?: string; unread: boolean; errored?: boolean; exited?: boolean
4  self?: boolean; parentId?: string | null
5}
6
7declare module 'claude-code' {
8  interface PluginState {
9    tiller: { children: Child[]; pending: number; quiet: boolean }
10  }
11}
12