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

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.

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.
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 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 588 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 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