SLOPSHOPPER

leaf

Generative UI: the agent builds the page your task needs, writes new widgets when the project calls for them, and answers your comments and edits over a…

newprocess
★ 5v?MITupdated 2026-10-09max-sixty/leaf
A shopper browsing a rack in a slop shop
README

leaf

maintained with tend

Experimental software; not ready for general use.

Leaf is generative UI for Claude Code and Codex. Review a plan, make a decision, or follow live work in a page you can comment on and change. The agent responds by revising the page.

<img alt="A Leaf page with an anchored comment, the agent's reply, and a revised plan" src="https://raw.githubusercontent.com/max-sixty/leaf-assets/445c6883aa8b8011849afad2340151e5fe0f9d49/demo/session-light.png">

A Leaf page receiving a comment, revising the work, and preserving a moved card

Try Leaf on the home page, where a small demo agent responds in your private copy, or explore the examples.

Install

You need uv, jq 1.6 or newer on PATH, and a browser that can reach the machine the agent runs on. No Leaf account or configuration is required. Leaf ships a prepared browser runtime. Users and package authors never need to build Leaf or run npm; custom widgets are ordinary browser JavaScript.

Claude Code:

/plugin marketplace add max-sixty/leaf#prepared
/plugin install leaf@leaf

Codex:

codex plugin marketplace add max-sixty/leaf --ref prepared
codex plugin add leaf@leaf

Pi (a highly experimental trial, which the rest of these docs don't cover yet):

pi install git:github.com/max-sixty/leaf@prepared

Then ask: “Use Leaf to write up the options for this change.” The explicit skill is /leaf [topic] in Claude Code, $leaf [topic] in Codex, and /skill:leaf [topic] in Pi; without a topic, it presents the work already under discussion. The result opens in a browser page; its comments return to the same agent task.

Known functional limitations and accepted visual differences in current browsers are recorded in Browser support gaps.

The first run syncs the plugin's uv environment through your configured package index. Render checks use the executable named by LEAF_BROWSER_EXECUTABLE, CHROME_PATH, or CHROME_BIN, then installed Google Chrome, then the first google-chrome, google-chrome-stable, chrome, chromium, or chromium-browser on PATH.

In Claude Code, a page messages its session when input arrives while nothing watches it, as after a turn you interrupted. A session that bypasses permissions holds that message for your approval unless Claude Code's crossSessionInbound setting is "accept".

Leaf's "Watch pages with a hooks module" option, in /config, moves that watch into a Claude Code hooks module, an early-access Claude Code feature, which goes on watching after a turn you interrupt. A Claude Code that doesn't load hooks modules keeps the default watch.

Explore and extend

  • How it works: comments, decisions, live revisions, and the widgets a page can use.
  • Examples: proposals, editable drafts, release workspaces, and code reviews.
  • Extending: choose between page-local changes, reusable packages, and Leaf's shared kernel. Your agent can build a widget a task needs and use it in later pages.
  • Public contracts: authoring, serving, and continuing a Leaf page.
Source 2 files
hooks/claude-code.ts 238 lines
1/**
2 * Leaf's Claude Code hooks module: the session's watch between turns, kept by
3 * the module rather than by the background Stop registration in `hooks.json`.
4 * It runs only while the plugin's `hooks_module` option is on, and only in a
5 * Claude Code that loads hooks modules, an early-access API.
6 *
7 * It does for the watch what `hooks/pi.ts` does for a Pi session
8 * (`ClaudeCodeHarness` in `skills/leaf/scripts/leaf/harness.py`):
9 *
10 * - the session's start and each main-loop turn's end start the watch (`bin/leaf
11 *   hook --harness claude-code --watch`), which prints a line and exits once one
12 *   of the session's pages has input;
13 * - a turn the user interrupts ends with no Stop hook, so the module starts the
14 *   watch with the Interrupt payload, which closes the turn. Receipt distinguishes
15 *   input that entered that turn from input held outside its context;
16 * - a watch that wakes an idle session submits its line as a prompt, whose
17 *   prompt hook hands the input over; one that wakes during a turn calls the
18 *   prompt hook itself and appends what it returns to that turn, which reads it
19 *   at its next step. The appended row joins the session's transcript at once,
20 *   and stays there through an Escape (measured at 2.1.291), so the hook's
21 *   confirmation holds: the input is in the session's context. Claude Code
22 *   shows neither that row nor the prompt hook's context in the terminal. A
23 *   wake while a turn's Stop hooks run waits for them, since Leaf's hands over
24 *   what is pending and decides whether the turn goes on;
25 * - a delivery Leaf's Stop hook hands over as a turn ends, which Claude Code
26 *   would print in full, goes into the session the same way, and the turn goes
27 *   on with one line in its place.
28 *
29 * Once it can start the watch, each Stop payload it passes on carries `leaf_watch:
30 * "module"`, which stands the background registration beneath it down
31 * (`hooks/scripts/loop-guard.py`). A Claude Code that does not load the module, or
32 * a module that cannot start the watch, passes the payload unmarked, so that
33 * registration goes on watching. The prompt, Stop, SessionStart and
34 * SessionEnd registrations do the same work under either watch and stay in
35 * `hooks.json`.
36 *
37 * TODO: once Claude Code loads modules without an opt-in, make this module the
38 * only watcher and transport and drop the background Stop registration, `loop-guard.py`, and
39 * the interrupt half of the nudge.
40 *
41 * A module's processes inherit Claude Code's own environment, which names no
42 * session of its own, so every call states the session and Claude Code's process
43 * as a hook's environment does. That process is the parent of every process the
44 * module starts (measured at Claude Code 2.1.291). Failures are silent, as
45 * `hooks.json` makes them, but for a wake Claude Code refuses, which goes to its
46 * debug log.
47 */
48
49import type { EngineInterface, Register } from 'claude-code'
50import { WatchOwner } from './watch.ts'
51
52// `hooks.json`'s timeout for the prompt hook, inside which it confirms what it
53// hands over (`CONFIRM_WITHIN` in `hook_transport.py`).
54const HOOK_TIMEOUT_MS = 20_000
55// The line an inline delivery opens with (`INLINE_DELIVERY` in `hook_transport.py`),
56// and the line the turn goes on with once the module has appended one.
57const INLINE_DELIVERY = 'Leaf has new input for your turn.'
58const DELIVERED = "Leaf added the page's new input to your context above; answer it before you end the turn."
59
60type Payload = { hook_event_name: string; session_id: string; ended_at?: number }
61
62// Claude Code's process, read as the session starts.
63let claudePid = ''
64let watch = new WatchOwner()
65// Whether a main-loop turn is going, from its start until its Stop hooks let it end.
66let running = false
67// The Stop hooks of a turn now ending, while they run.
68let stopping: Promise<unknown> | undefined
69
70function environment(session: string) {
71  return { CLAUDE_CODE_SESSION_ID: session, CLAUDE_PID: claudePid }
72}
73
74function launcher($: EngineInterface) {
75  return `${$.plugin.root}/bin/leaf`
76}
77
78/** The context a hook's output puts in the turn (`Harness.hook_context`). */
79async function hook($: EngineInterface, payload: Payload): Promise<string | undefined> {
80  try {
81    const ran = await $.process.run([launcher($), 'hook', '--harness', 'claude-code'], {
82      env: environment(payload.session_id),
83      stdin: JSON.stringify(payload),
84      timeoutMs: HOOK_TIMEOUT_MS,
85    })
86    if (ran.exitCode !== 0 || !ran.stdout.trim()) return undefined
87    return JSON.parse(ran.stdout).hookSpecificOutput?.additionalContext || undefined
88  } catch {
89    return undefined
90  }
91}
92
93/** The watch owner serializes endings; the adapter supplies Claude Code's process
94 * and wake operations. Without its parent process, the registration keeps watch. */
95async function ensureWatch($: EngineInterface, session: string, interrupted: boolean) {
96  const ended = Date.now() / 1000
97  if (!claudePid) return
98  const owner = watch
99  await owner.ensure(interrupted, () => {
100    const payload: Payload = {
101      hook_event_name: interrupted ? 'Interrupt' : 'Stop',
102      session_id: session,
103      ended_at: ended,
104    }
105    const stream = $.process.spawn({
106      argv: [launcher($), 'hook', '--harness', 'claude-code', '--watch'],
107      env: environment(session),
108      input: JSON.stringify(payload),
109    })
110    const done = (async () => {
111      let out = ''
112      try {
113        for await (const chunk of stream) if (chunk.stream === 'stdout') out += chunk.text
114      } catch {
115        // Cancellation can abort output before the process releases its lease.
116      }
117      try {
118        return (await stream.result).code === 0 ? out.trim() : ''
119      } catch {
120        return ''
121      }
122    })()
123    return { stop: () => stream.return(undefined as never), done }
124  }, (output, live) => wake($, session, owner, output, live))
125}
126
127/** Whether the turn goes on, once any Stop hooks now running have returned. They
128 * hand over the input pending as they run, and decide whether the turn goes on,
129 * so a wake waits for them rather than carrying that input beside them. */
130async function turnGoesOn() {
131  while (stopping) await stopping.catch(() => undefined)
132  return running
133}
134
135function append($: EngineInterface, text: string) {
136  return $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
137}
138
139async function wake($: EngineInterface, session: string, owner: WatchOwner, woke: string, live: () => boolean) {
140  if (!woke) return
141  try {
142    // Claude Code refuses a plugin's prompt that starts with `/`, as the watch's
143    // line, a page's path, does.
144    const prompt = `Leaf: ${woke}`
145    const ongoing = await turnGoesOn()
146    if (!live()) return
147    if (ongoing) {
148      const context = await hook($, { hook_event_name: 'UserPromptSubmit', session_id: session })
149      if (!owner.open) return
150      // The hook has confirmed what it hands over, so that goes into the session
151      // even where the turn ended while the hook ran: an idle session reads an
152      // appended row at its next turn, which the prompt below starts.
153      if (context) await append($, context)
154      const ongoing = await turnGoesOn()
155      // Confirmed input still needs an idle turn even if another watch started;
156      // a session that ended meanwhile owns neither an append nor a new prompt.
157      if (!owner.open || (!context && !live())) return
158      if (ongoing) {
159        if (!context) await append($, prompt)
160        return
161      }
162    }
163    await $.prompt.submit({ text: prompt })
164  } catch (error) {
165    // A wake Claude Code refuses leaves the input to the next turn's prompt hook.
166    $.ui.log(`Leaf: the watch's wake was refused: ${error}`, { to: 'debug' })
167  }
168}
169
170export const register: Register = (on, options) => {
171  if (options.hooks_module !== true) return
172
173  on('session.start', async ($, e, next) => {
174    const started = await next(e)
175    const parent = await $.process.run(['/bin/sh', '-c', 'echo $PPID'])
176    claudePid = parent.stdout.trim()
177    void ensureWatch($, await $.session.id(), false)
178    return started
179  })
180
181  on('turn.start', ($, e, next) => {
182    running = true
183    return next(e)
184  })
185
186  on('classic.Stop', ($, e, next) => {
187    const ending = (async () => {
188      // Without Claude Code's process the watch cannot run, so the registration
189      // beneath keeps it.
190      const result = await next(claudePid ? Object.assign({}, e, { leaf_watch: 'module' }) : e)
191      const contexts = result.additionalContext ?? []
192      // Claude Code prints a Stop hook's context in the terminal, so a delivery
193      // Leaf's Stop hook hands over goes into the session as an appended row,
194      // which it does not show, and the turn goes on with one line.
195      const shown: string[] = []
196      for (const context of contexts) {
197        if (!context.startsWith(INLINE_DELIVERY)) {
198          shown.push(context)
199          continue
200        }
201        try {
202          await append($, context)
203          shown.push(DELIVERED)
204        } catch (error) {
205          $.ui.log(`Leaf: the Stop hook's delivery was not appended: ${error}`, { to: 'debug' })
206          shown.push(context)
207        }
208      }
209      // A Stop hook's context keeps the turn going.
210      running = shown.length > 0 || result.block !== undefined
211      return contexts.length ? Object.assign({}, result, { additionalContext: shown }) : result
212    })()
213    stopping = ending
214    const settled = () => {
215      if (stopping === ending) stopping = undefined
216    }
217    ending.then(settled, settled)
218    return ending
219  })
220
221  on('turn.complete', async ($, e, next) => {
222    const completed = await next(e)
223    if (e.agentId !== undefined) return completed
224    running = false
225    void ensureWatch($, await $.session.id(), e.isAborted)
226    return completed
227  })
228
229  on('session.end', async ($, e, next) => {
230    const owner = watch
231    await owner.close()
232    const ended = await next(e)
233    // /clear and resume keep the module loaded without another session.start.
234    if (watch === owner) watch = new WatchOwner()
235    return ended
236  })
237}
238
hooks/watch.ts 83 lines
1/**
2 * One session's between-turn watch. Host adapters own process creation and wakes;
3 * this owner keeps termination, replacement and completion in the same order.
4 *
5 * A Stop watch survives another Stop. Every Interrupt replaces the old watch,
6 * including an Interrupt watch, so the new ending reads fresh receipt evidence.
7 * Replacement invalidates completion immediately, but retains the old process
8 * until `done` proves it has exited and released its lease. Concurrent endings
9 * collapse to the latest request. Shutdown invalidates queued starts and wakes.
10 * `live` checks watch ownership after yielding; `open` tracks the session lifetime
11 * for a host that has already confirmed input and still owes its delivery.
12 */
13
14type Watch = { stop: () => unknown; done: Promise<string> }
15type Ending = { interrupted: boolean }
16
17export class WatchOwner {
18  private current: Watch | undefined
19  private desired: Ending | undefined
20  private pending: Promise<void> = Promise.resolve()
21  private closed = false
22  private generation = 0
23
24  get open(): boolean {
25    return !this.closed
26  }
27
28  ensure(
29    interrupted: boolean,
30    start: () => Watch,
31    wake: (output: string, live: () => boolean) => unknown,
32  ): Promise<void> {
33    if (this.closed || (!interrupted && this.desired?.interrupted === false)) return this.pending
34    const ending = { interrupted }
35    this.desired = ending
36    const generation = ++this.generation
37    return this.enqueue(async () => {
38      if (this.desired !== ending) return
39      await this.stopCurrent()
40      if (this.closed || this.desired !== ending) return
41      const current = start()
42      this.current = current
43      const live = () => !this.closed && this.generation === generation
44      void current.done.then(output => {
45        if (!live()) return
46        this.current = undefined
47        this.desired = undefined
48        return wake(output, live)
49      })
50    }).catch(error => {
51      if (this.desired === ending) this.desired = undefined
52      throw error
53    })
54  }
55
56  close(): Promise<void> {
57    this.closed = true
58    this.desired = undefined
59    ++this.generation
60    return this.enqueue(() => this.stopCurrent())
61  }
62
63  private enqueue(operation: () => Promise<void>): Promise<void> {
64    const next = this.pending.then(operation)
65    // A failed host operation is reported to its caller, without poisoning later
66    // shutdown or replacement requests.
67    this.pending = next.catch(() => undefined)
68    return next
69  }
70
71  private async stopCurrent(): Promise<void> {
72    const current = this.current
73    if (!current) return
74    try {
75      await current.stop()
76    } finally {
77      // Cancelling a host's output iterator can finish before its child exits.
78      await current.done
79      this.current = undefined
80    }
81  }
82}
83