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

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.

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.
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.
Named stages, one sentence each:
config.review.max_rounds (default 2) capping the rounds.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.
macOS (Apple Silicon or Intel), Linux (x86_64 / arm64), or Windows via WSL2. Python 3.11+ or uv, jq, and gh (authenticated).
Apache-2.0, Jason Noah Choi
hooks/register.mjs 6 lines1import { registerMailPane } from './mail-pane.mjs'
2
3export function register(on) {
4 registerMailPane(on)
5}
6hooks/mail-pane.mjs 594 lines1const 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