SLOPSHOPPER

Footnote

Set a target and walk away. Say f[no] to mostly done.

newpanerowsprocesstimer
★ 2v0.4.1Apache-2.0updated 2026-10-09bllshttng/footnote
A shopper browsing a rack in a slop shop
README

footnote - f[no]

Set a target and walk away. Say f[no] to mostly done.

footnote is an orchestration loop that ships software. It plans, builds, reviews, and opens a green PR, and it does not stop until external truth says so. It runs as a plugin on any harness that accepts plugins and hooks (Claude Code, Codex, OpenCode, and agy are wired today), with a standalone CLI underneath.

The fno mux with four agent panes working fno tasks, a sideline roster, and a live status row

  • Point it at a feature description or a backlog node. It plans, builds with TDD, reviews its own diff, and ships the PR.
  • Completion is decided by the world, not by a model's mood. The PR exists, CI is green, review has had its rounds.
  • A dependency-graph backlog keeps the next piece of work ready, so an unattended loop never stalls on "what now?"

Install

Start with the CLI:

curl -fsSL fno.sh | sh

When uv is missing the script installs it first. The install lands the fno CLI with its bundled binaries and adds the tool bin to your PATH. It then wires the plugin into every agent CLI it detects: claude, codex, opencode, pi, agy, gemini. Each harness prints one summary line. Set FNO_NO_WIRE=1 to skip this step. fno config setup wizard wires a harness by hand. The per-harness commands below do it manually. The plugin-only install routes pick up their CLI on the next session.

Claude Code:

/plugin marketplace add bllshttng/footnote
/plugin install fno@footnote

Codex CLI:

codex plugin marketplace add bllshttng/footnote
codex plugin add fno@footnote

The plugin-only route gets its CLI on the next session: the first session you start after installing runs the installer. Until then fno is not on PATH and the /fno: skills cannot run.

CLI only, for scripting, CI, or driving footnote yourself:

uv tool install fno
cargo install fno

From a clone, bash scripts/setup.sh installs the CLI and scaffolds the project. One skill runs without the CLI at all:

npx skills add bllshttng/footnote --skill tdd

Other harnesses (opencode, agy, gemini, pi): see docs/HARNESSES.md. Then run /fno:setup (or fno config setup wizard), and point /fno:target at a feature.

Uninstall

fno uninstall --dry-run
fno uninstall

The first command lists what it found and changes nothing. The second removes the plugins, the hooks fno added to harness config, the launchd agents and the binaries. It also stops the daemon and the mux. It keeps ~/.fno. Add --purge to delete that too, after you type the confirmation word. Run it from a plain terminal, not from inside an fno mux pane.

How it works

Named stages, one sentence each:

  • think explores the design space and writes cited findings before anyone commits to a plan.
  • blueprint turns the approved direction into an executable plan: waves, tasks, acceptance criteria.
  • target is the walk-away loop. It executes the plan and will not stop until the PR is up, CI is green, and review is done.
  • review reads the diff before it ships. An inline lane emits a head-pinned attestation, with config.review.max_rounds (default 2) capping the rounds.
  • pr drives the lifecycle: create, check for external review, and the post-merge ritual.

What it enforces

The finish line is a PR with CI green and review done under your configured policy. The review round cap releases still-open findings into the PR conversation rather than blocking forever. The merge itself is yours until you opt in to auto-merge. See what can run without you, gate by gate: fno agents autonomy status. Not a sandbox: it runs your plans with your credentials on your machine, and docs/security-posture.md draws the trust boundary.

Docs

Requirements

macOS (Apple Silicon or Intel), Linux (x86_64 / arm64), or Windows via WSL2. Python 3.11+ or uv, jq, and gh (authenticated).

License

Apache-2.0, Jason Noah Choi

Source 2 files
hooks/register.mjs 6 lines
1import { registerMailPane } from './mail-pane.mjs'
2
3export function register(on) {
4  registerMailPane(on)
5}
6
hooks/mail-pane.mjs 588 lines
1const PANE_ID = 'fno-mail-inspector'
2const MIN_VERSION = [2, 1, 287]
3const POLL_MS = 2_000
4const COMMAND_TIMEOUT_MS = 5_000
5const CLASSIFICATION_CACHE_LIMIT = 128
6const CHAT_WINDOW = 100
7
8let enabled = false
9let muxPane = false
10let pollInstalled = false
11let generation = 0
12let selection = null
13let view = null
14let viewError = ''
15let loading = false
16let reading = false
17let projectionCache = null
18let projectionCacheAt = -Infinity
19const classificationCache = new Map()
20
21function versionParts(value) {
22  const match = /^(\d+)\.(\d+)\.(\d+)/.exec(String(value ?? '').trim())
23  return match ? match.slice(1).map(Number) : null
24}
25
26function versionSupported(value) {
27  const parts = versionParts(value)
28  if (!parts) return false
29  for (let index = 0; index < MIN_VERSION.length; index += 1) {
30    if (parts[index] > MIN_VERSION[index]) return true
31    if (parts[index] < MIN_VERSION[index]) return false
32  }
33  return true
34}
35
36function paneNumber(value) {
37  let digits = String(value ?? '').trim()
38  while (digits.startsWith('%')) digits = digits.slice(1)
39  if (!digits) return null
40  for (const digit of digits) {
41    if (digit < '0' || digit > '9') return null
42  }
43  const parsed = Number(digits)
44  return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null
45}
46
47function errorText(error) {
48  return error instanceof Error ? error.message : String(error)
49}
50
51function invalidate($) {
52  try {
53    $.ui.invalidate('ui.render')
54  } catch {
55    // A redraw can be refused while a session is unloading.
56  }
57}
58
59function cacheClassification(key, value) {
60  classificationCache.delete(key)
61  classificationCache.set(key, value)
62  while (classificationCache.size > CLASSIFICATION_CACHE_LIMIT) {
63    const oldest = classificationCache.keys().next().value
64    classificationCache.delete(oldest)
65  }
66}
67
68async function classify($, requestId, text) {
69  const key = `${requestId}\u0000${text}`
70  if (classificationCache.has(key)) return classificationCache.get(key)
71
72  const result = await $.process.run(
73    ['fno-agents', 'mail-envelope', '--classify'],
74    { stdin: JSON.stringify([text]), timeoutMs: COMMAND_TIMEOUT_MS },
75  )
76  if (result.exitCode !== 0) {
77    throw new Error(result.stderr.trim() || `mail classifier exited ${result.exitCode}`)
78  }
79
80  const decoded = JSON.parse(result.stdout)
81  const item = Array.isArray(decoded) ? decoded[0] : decoded
82  const turns = item?.framing === 'header' && Array.isArray(item.header_turns)
83    ? item.header_turns.filter(turn =>
84      typeof turn?.id === 'string' &&
85      turn.id.startsWith('fmail-') &&
86      typeof turn?.sender === 'string' &&
87      turn.sender.length > 0)
88    : []
89  cacheClassification(key, turns)
90  return turns
91}
92
93function keyPart(value) {
94  return String(value).replace(/[^A-Za-z0-9_-]/g, '_')
95}
96
97function headerControls($, event, line, turn, requestId) {
98  const idAt = line.indexOf(turn.id)
99  if (idAt < 0) return null
100  const senderLabel = `@${turn.sender}`
101  const senderAt = line.lastIndexOf(senderLabel, idAt)
102  if (senderAt < 0) return null
103
104  const afterId = line.slice(idAt + turn.id.length)
105  if (!afterId.startsWith(' · ')) return null
106
107  let prefix = line.slice(0, senderAt)
108  if (prefix.endsWith('`')) prefix = prefix.slice(0, -1)
109  const between = line.slice(senderAt + senderLabel.length, idAt)
110  // The subject stays on the header row; the body after the closing fence
111  // is not shown inline - the id button opens it. The closer is the first
112  // backtick whose remainder is the line end or the " ⏎ " body separator,
113  // matching the producer's split_header_span; a backtick inside the
114  // subject is not a fence.
115  let subject = afterId.slice(3)
116  for (let at = subject.indexOf('`'); at >= 0; at = subject.indexOf('`', at + 1)) {
117    const tail = subject.slice(at + 1)
118    if (!tail || tail.startsWith(' ⏎ ')) {
119      subject = subject.slice(0, at)
120      break
121    }
122  }
123
124  const { Box, Button, Text } = $.ui.resolve(event)
125  const controls = []
126  if (prefix) controls.push(Text({ children: [prefix] }))
127  controls.push(Button({
128    key: `mail-sender-${keyPart(requestId)}-${keyPart(turn.id)}`,
129    label: senderLabel,
130    plain: true,
131    onPress: () => openFromMessage($, turn.id),
132  }))
133  if (between) controls.push(Text({ children: [between] }))
134  controls.push(Button({
135    key: `mail-message-${keyPart(requestId)}-${keyPart(turn.id)}`,
136    label: turn.id,
137    plain: true,
138    onPress: () => openMessage($, turn.id),
139  }))
140
141  if (subject) controls.push(Text({ children: [` · ${subject}`] }))
142
143  return {
144    buttons: Box({ flexDirection: 'row', flexWrap: 'wrap', children: controls }),
145  }
146}
147
148async function runJson($, argv, timeoutMs = COMMAND_TIMEOUT_MS) {
149  const result = await $.process.run(argv, { timeoutMs })
150  if (result.exitCode !== 0) {
151    throw new Error(result.stderr.trim() || `${argv[0]} exited ${result.exitCode}`)
152  }
153  return JSON.parse(result.stdout)
154}
155
156async function readThreads($, force = false) {
157  const now = await $.clock.now()
158  if (!force && projectionCache && now - projectionCacheAt < 1_000) {
159    return projectionCache
160  }
161  const projection = await runJson($, ['fno-agents', 'mail-threads', '--format', 'json'])
162  if (!Array.isArray(projection?.threads) || !Array.isArray(projection?.participants)) {
163    throw new Error('mail thread projection has an invalid shape')
164  }
165  projectionCache = projection
166  projectionCacheAt = now
167  return projection
168}
169
170function messageMatches(projection, messageId) {
171  const matches = []
172  for (const thread of projection.threads ?? []) {
173    for (const row of thread.rows ?? []) {
174      if (row.id === messageId) {
175        matches.push({
176          kind: 'chat',
177          chatId: thread.chat_id,
178          rows: thread.rows,
179          row,
180        })
181      }
182    }
183  }
184  for (const channel of projection.channels ?? []) {
185    for (const row of channel.rows ?? []) {
186      if (row.id === messageId) {
187        matches.push({
188          kind: 'channel',
189          scope: channel.scope,
190          rows: channel.rows,
191          row,
192        })
193      }
194    }
195  }
196  const announcements = projection.announcements ?? []
197  for (const row of announcements) {
198    if (row.id === messageId) {
199      matches.push({
200        kind: 'channel',
201        scope: 'announcements',
202        rows: announcements,
203        row,
204      })
205    }
206  }
207  return matches
208}
209
210function rowForMessage(projection, messageId) {
211  const matches = messageMatches(projection, messageId)
212  if (matches.length === 0) throw new Error('Message is no longer available')
213  if (matches.length > 1) throw new Error('Message id resolves to more than one thread')
214  return matches[0]
215}
216
217function sessionForRow(projection, row) {
218  if (row.system === true) throw new Error('System messages do not have a peer session')
219  const fromKey = typeof row.from_key === 'string' ? row.from_key : ''
220  if (!fromKey) throw new Error('Sender identity is unavailable')
221  const participant = (projection.participants ?? []).find(item => item.key === fromKey)
222  if (!participant || participant.system === true) {
223    throw new Error('Sender identity is unavailable')
224  }
225  const sessionId = typeof participant.session_id === 'string'
226    ? participant.session_id.trim()
227    : ''
228  if (!sessionId) throw new Error('Sender session id is unavailable')
229  return sessionId
230}
231
232async function readPeek($, sessionId) {
233  const result = await $.process.run(
234    ['fno', 'agents', 'peek', sessionId, '--lines', '30', '--json'],
235    { timeoutMs: COMMAND_TIMEOUT_MS },
236  )
237  if (result.exitCode === 13) throw new Error('Session is not in the local registry')
238  if (result.exitCode === 1) {
239    throw new Error(result.stderr.trim() || 'Session transcript is unreadable')
240  }
241  if (result.exitCode !== 0) {
242    throw new Error(result.stderr.trim() || `Peer read exited ${result.exitCode}`)
243  }
244
245  const rows = []
246  for (const line of result.stdout.split(/\r?\n/)) {
247    if (!line.trim()) continue
248    let row
249    try {
250      row = JSON.parse(line)
251    } catch {
252      throw new Error('Peer read returned invalid JSONL')
253    }
254    if (typeof row.status === 'string') {
255      rows.push({ status: row.status })
256    } else if (typeof row.text === 'string') {
257      rows.push({
258        role: typeof row.role === 'string' ? row.role : 'message',
259        text: row.text,
260      })
261    } else {
262      throw new Error('Peer read returned an unknown record')
263    }
264  }
265  return rows.length ? rows : [{ status: 'no activity yet' }]
266}
267
268function chatWindow(rows, selectedId) {
269  const selectedIndex = rows.findIndex(row => row.id === selectedId)
270  if (rows.length <= CHAT_WINDOW) return { rows, start: 0 }
271  const desired = selectedIndex < 0 ? rows.length - CHAT_WINDOW : selectedIndex - 49
272  const start = Math.max(0, Math.min(desired, rows.length - CHAT_WINDOW))
273  return { rows: rows.slice(start, start + CHAT_WINDOW), start }
274}
275
276async function refreshSelection($, expectedGeneration = generation) {
277  if (!selection || reading || expectedGeneration !== generation) return
278  reading = true
279  loading = view === null
280  try {
281    let nextView
282    if (selection.kind === 'peer') {
283      nextView = {
284        kind: 'peer',
285        sessionId: selection.sessionId,
286        rows: await readPeek($, selection.sessionId),
287      }
288    } else if (selection.kind === 'chat' || selection.kind === 'channel') {
289      const projection = await readThreads($, true)
290      const match = rowForMessage(projection, selection.messageId)
291      if (match.kind !== selection.kind) throw new Error('Message changed thread type')
292      if (selection.kind === 'chat' && match.chatId !== selection.chatId) {
293        throw new Error('Message changed chat')
294      }
295      if (selection.kind === 'channel' && match.scope !== selection.scope) {
296        throw new Error('Message changed channel')
297      }
298      const window = chatWindow(match.rows, selection.messageId)
299      nextView = {
300        kind: selection.kind,
301        chatId: match.chatId,
302        scope: match.scope,
303        messageId: selection.messageId,
304        rows: window.rows,
305        start: window.start,
306        total: match.rows.length,
307      }
308    } else {
309      nextView = { kind: 'unavailable', message: selection.message }
310    }
311    if (expectedGeneration === generation) {
312      view = nextView
313      viewError = ''
314    }
315  } catch (error) {
316    if (expectedGeneration === generation) viewError = errorText(error)
317  } finally {
318    reading = false
319    if (expectedGeneration === generation) {
320      loading = false
321      invalidate($)
322    }
323  }
324}
325
326async function refreshIfVisible($) {
327  if (!enabled || muxPane || !selection || reading) return
328  try {
329    const panes = await $.ui.panes()
330    if (!panes.some(pane => pane.id === PANE_ID && pane.isShown && pane.isPlaced)) return
331  } catch {
332    return
333  }
334  await refreshSelection($, generation)
335}
336
337async function activate($, nextSelection) {
338  generation += 1
339  const currentGeneration = generation
340  selection = nextSelection
341  view = null
342  viewError = ''
343  loading = true
344  projectionCache = null
345  try {
346    await $.ui.open({
347      id: PANE_ID,
348      title: nextSelection.title || 'Mail',
349      focus: true,
350      closeOnEscape: true,
351    })
352  } catch (error) {
353    loading = false
354    viewError = errorText(error)
355    invalidate($)
356    return
357  }
358  await refreshIfVisible($)
359  if (currentGeneration === generation && !view && !viewError) {
360    loading = true
361    invalidate($)
362  }
363}
364
365async function unavailable($, messageId, error) {
366  await activate($, {
367    kind: 'unavailable',
368    messageId,
369    title: 'Mail unavailable',
370    message: errorText(error),
371  })
372  if (selection?.messageId === messageId) {
373    view = { kind: 'unavailable', message: errorText(error) }
374    viewError = ''
375    loading = false
376    invalidate($)
377  }
378}
379
380async function openFromMessage($, messageId) {
381  try {
382    const projection = await readThreads($)
383    const match = rowForMessage(projection, messageId)
384    const sessionId = sessionForRow(projection, match.row)
385    await activate($, {
386      kind: 'peer',
387      messageId,
388      sessionId,
389      title: `Session ${sessionId}`,
390    })
391  } catch (error) {
392    await unavailable($, messageId, error)
393  }
394}
395
396async function openMessage($, messageId) {
397  try {
398    const projection = await readThreads($)
399    const match = rowForMessage(projection, messageId)
400    await activate($, {
401      kind: match.kind,
402      messageId,
403      chatId: match.chatId,
404      scope: match.scope,
405      title: match.kind === 'chat' ? `Chat ${match.chatId}` : `Channel ${match.scope}`,
406    })
407  } catch (error) {
408    await unavailable($, messageId, error)
409  }
410}
411
412function peerElements(Text, target, currentView, currentError, isLoading) {
413  const children = [
414    Text({ bold: true, children: [`Session ${target.sessionId}`] }),
415    Text({ dimColor: true, wrap: 'wrap', children: [`fno agents peek ${target.sessionId}`] }),
416  ]
417  if (currentError) {
418    children.push(Text({ color: 'yellow', wrap: 'wrap', children: [currentView ? `stale: ${currentError}` : `unavailable: ${currentError}`] }))
419  }
420  if (isLoading && !currentView) children.push(Text({ dimColor: true, children: ['Loading recent activity…'] }))
421  if (currentView?.kind === 'peer') {
422    for (const row of currentView.rows) {
423      if (row.status === 'no activity yet') {
424        children.push(Text({ dimColor: true, children: ['No activity yet'] }))
425      } else if (row.status) {
426        children.push(Text({ dimColor: true, wrap: 'wrap', children: [row.status] }))
427      } else {
428        const label = `${row.role}: ${row.text}`
429        const clipped = label.length > 8_000 ? `${label.slice(0, 8_000)}…` : label
430        children.push(Text({ wrap: 'wrap', children: [clipped] }))
431      }
432    }
433  }
434  return children
435}
436
437function threadElements(Text, target, currentView, currentError, isLoading) {
438  const chat = target.kind === 'chat'
439  const children = [
440    Text({ bold: true, children: [chat ? `Chat ${target.chatId}` : `Channel ${target.scope}`] }),
441  ]
442  if (currentView?.total > currentView.rows.length) {
443    const first = currentView.start + 1
444    const last = currentView.start + currentView.rows.length
445    children.push(Text({ dimColor: true, children: [`Showing ${first}–${last} of ${currentView.total} messages`] }))
446  }
447  if (currentError) {
448    children.push(Text({ color: 'yellow', wrap: 'wrap', children: [currentView ? `stale: ${currentError}` : `unavailable: ${currentError}`] }))
449  }
450  if (isLoading && !currentView) children.push(Text({ dimColor: true, children: ['Loading conversation…'] }))
451  if (currentView && (currentView.kind === 'chat' || currentView.kind === 'channel')) {
452    for (const row of currentView.rows) {
453      const selected = row.id === target.messageId
454      const sender = typeof row.from === 'string' ? row.from : 'Unknown sender'
455      const timestamp = typeof row.ts === 'string' ? row.ts : ''
456      const body = typeof row.body === 'string' ? row.body : ''
457      children.push(Text({ bold: selected, wrap: 'wrap', children: [`${selected ? '› ' : ''}${sender}${timestamp ? ` · ${timestamp}` : ''}`] }))
458      children.push(Text({ wrap: 'wrap', children: [body.length > 8_000 ? `${body.slice(0, 8_000)}…` : body] }))
459    }
460  }
461  return children
462}
463
464function renderPane($, event) {
465  const { Box, Text } = $.ui.resolve(event)
466  if (!selection) {
467    return Box({
468      flexDirection: 'column',
469      children: [Text({ dimColor: true, wrap: 'wrap', children: ['Select a sender or fmail id in a delivered header to open it here.'] })],
470    })
471  }
472  if (selection.kind === 'unavailable') {
473    return Box({
474      flexDirection: 'column',
475      children: [Text({ color: 'yellow', wrap: 'wrap', children: [selection.message] })],
476    })
477  }
478  const children = selection.kind === 'peer'
479    ? peerElements(Text, selection, view, viewError, loading)
480    : threadElements(Text, selection, view, viewError, loading)
481  return Box({ flexDirection: 'column', gap: 1, children })
482}
483
484export function registerMailPane(on) {
485  on('session.start', async ($, event, next) => {
486    try {
487      const version = await $.session.version()
488      const server = await $.env.get('FNO_SERVER')
489      const pane = await $.env.get('FNO_PANE')
490      enabled = versionSupported(version?.version)
491      muxPane = paneNumber(pane) !== null
492      // FNO_SERVER can exist without a pane-bound caller; only FNO_PANE suppresses the fallback.
493      void server
494    } catch {
495      enabled = false
496      muxPane = false
497    }
498    if (!pollInstalled) {
499      pollInstalled = true
500      $.clock.every(POLL_MS, () => refreshIfVisible($))
501    }
502    return next(event)
503  })
504
505  on('session.end', async ($, event, next) => {
506    generation += 1
507    selection = null
508    view = null
509    viewError = ''
510    loading = false
511    projectionCache = null
512    projectionCacheAt = -Infinity
513    classificationCache.clear()
514    return next(event)
515  })
516
517  on('ui.close', async ($, event, next) => {
518    if (event.id === PANE_ID) {
519      generation += 1
520      selection = null
521      view = null
522      viewError = ''
523      loading = false
524      projectionCache = null
525      projectionCacheAt = -Infinity
526    }
527    return next(event)
528  })
529
530  on('ui.render', { component: 'UserMessage' }, async ($, event, next) => {
531    if (!enabled || muxPane) return next(event)
532    const text = typeof event.props?.text === 'string' ? event.props.text : ''
533    // This candidate check avoids a process for ordinary prompts; Rust still decides whether it is mail.
534    if (!text.includes('fmail-')) return next(event)
535
536    let turns
537    try {
538      turns = await classify($, event.requestId, text)
539    } catch {
540      return next(event)
541    }
542    if (!turns.length) return next(event)
543
544    const { Box, Text } = $.ui.resolve(event)
545    const pending = [...turns]
546    const children = []
547    let plainLines = []
548    let hasButtons = false
549    const flushPlain = () => {
550      if (!plainLines.length) return
551      children.push(Text({ wrap: 'wrap', children: [plainLines.join('\n')] }))
552      plainLines = []
553    }
554    for (const line of text.split('\n')) {
555      let index = -1
556      let result = null
557      for (let candidate = 0; candidate < pending.length; candidate += 1) {
558        if (!line.includes(pending[candidate].id)) continue
559        const parsed = headerControls($, event, line, pending[candidate], event.requestId)
560        if (!parsed) continue
561        index = candidate
562        result = parsed
563        break
564      }
565      if (index < 0 || !result) {
566        plainLines.push(line)
567        continue
568      }
569      pending.splice(index, 1)
570      flushPlain()
571      children.push(result.buttons)
572      hasButtons = true
573    }
574    flushPlain()
575    if (!hasButtons) return next(event)
576    return Box({
577      flexDirection: 'column',
578      children,
579    })
580  })
581
582  on('ui.render', { component: 'Pane' }, async ($, event, next) => {
583    if (event.requestId !== PANE_ID || !enabled || muxPane) return next(event)
584    if (selection && !view && !loading) await refreshSelection($, generation)
585    return renderPane($, event)
586  })
587}
588