SLOPSHOPPER

Footnote

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

newpanerowsprocesstimer
★ 2v0.4.3Apache-2.0updated 2026-10-26bllshttng/fno
A shopper browsing a rack in a slop shop
README

fno

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

fno 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. Then it runs the one-time setup with its defaults. Set FNO_NO_WIRE=1 to skip both steps. The plugin install routes do the same work. fno config setup run --check names anything the machine still lacks, each with its fix. The per-harness commands below wire a harness by hand.

Claude Code:

/plugin marketplace add bllshttng/fno#stable
/plugin install fno@fno

#stable pins the add to the stable branch (781 files). Without it, the add clones main (7,949 files, 80s+), which can time out: Claude Desktop's "Add marketplace" dialog gives up after 120s and reports "Failed to add marketplace."

Codex CLI:

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

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 fno 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/fno --skill tdd

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