Enregistre la session Claude Code en asciicast (.cast) dans nexus/.planning/terminal pour le rejeu dans l'UI Factory.

Publish apps. Distribute them peer-to-peer. Verify what can be verified. Keep the limits visible.
SBFB is currently in closed pilot. The core protocol and several developer tools are code-backed, but this is not yet a public production network.
SBFB is a protocol and local runtime for publishing, distributing, and opening web applications over a peer-to-peer network. A publisher can package a web app (plain HTML, React, Pyodide, WASM, JupyterLite, or any build that produces static web files), attach a manifest, sign provenance, and announce the app. Other nodes discover the announcement, fetch the content-addressed archive, and open it inside a browser sandbox served by their own local daemon.
The second axis is shared AI/GPU compute. Apps can request analysis, translation, or generation tasks from volunteer CPU/GPU workers, but only under explicit local consent, per-project resource caps, and signed task/result records. The worker path exists as primitives and pilot tooling; public multi-worker scale and anti-cheat are still validation work.
The design goal is simple: no app store, no cloud account, no central authority deciding what may exist, and no hidden trust claim. Every public claim should map to code, a local proof, or a clearly marked future layer.
| Area | Status | What this means |
|---|---|---|
| Local daemon, shell UI, iframe sandbox, bridge | Code-backed | A node can run a local shell, open sandboxed apps, and broker app requests through postMessage. |
| P2P app distribution | Code-backed / pilot | iroh gossip, blobs, docs, feed sync, and blob cache exist. LAN/WAN pilot validation is narrower than a public production network. |
| Source provenance | Code-backed self-attestation | provenance.json and Ed25519 signatures exist. This is not a third-party audit or independent reproducible build. |
| Factory CLI | Code-backed | create, validate, preview, and publish exist. The automated publish path currently covers a subset of the full FG0-FG10 gate model. |
| Factory Viewer / Operator | Code-backed local tools | Read-only viewer and local privileged operator exist for pilot/dev workflows. |
| Local search and Proof Cards | Code-backed local features | SQLite FTS5 and deterministic Proof Cards exist. Cross-node SearchManifest discovery is future work. |
| Curator primitives | Code-backed primitives | Signed vouch/disendorse/feed operations exist. Full governance timelines, dissent UI, and social process are future work. |
| Worker compute | Code-backed primitives / pilot | Consent, caps, worker signatures, task/result records, Ollama backend, optional llama.cpp, and kudos v1 exist. Public worker quorum and exact model/GPU proof are not complete. |
| Installers | Built and internally validated | Windows NSIS, Linux .deb, and macOS .dmg tooling exists. Closed pilot distribution uses maintainer-provided binaries and hashes until public release assets are finalized. |
| Gate 1 / CHATONS pilot | In progress | The pilot protocol exists for 2-3 testers. 24h stability, public release assets, and external feedback still determine the next gate. |
Not ready today: production CHATONS hosting, sensitive user data, public multi-node worker quorum, independent security audit, third-party reproducible builds, SearchManifest network discovery, and a full governance UI.
+------------------+
| P2P Network |
| iroh gossip |
| iroh-blobs |
| iroh-docs |
+--------+---------+
|
QUIC / Ed25519
|
+--------+---------+
| Shell Daemon |
| Rust binary |
| local HTTP |
| bearer + Host |
| + Origin auth |
+--------+---------+
|
default: 127.0.0.1
|
+-------------------+-------------------+
| |
+----------+----------+ +--------------+---------+
| Shell UI (React) | | Blob-serve route |
| Browse, Curators, | | decompresses app zip |
| Network, Deploy | | LRU cache + CSP |
+----------+----------+ +--------------+---------+
| |
| postMessage bridge |
+---------------+-----------------------+
|
+----------+----------+
| App iframe |
| sandbox="allow- |
| scripts" |
| no allow-same- |
| origin |
| CSP connect-src |
| 'none' |
+---------------------+
Blob-served app content is protected by the browser sandbox and CSP. It is not currently a separate TCP origin; the iframe gets an opaque origin because allow-same-origin is deliberately absent.
provenance.json self-attestation with Ed25519 signatures and source metadata. This proves a signed local publication path, not that an independent third party rebuilt the same archive.sandbox="allow-scripts" and no allow-same-origin; untrusted content receives CSP including connect-src 'none'.sbfb-bridge.js and window.postMessage with UUID correlation. The host validates requests against a global method allowlist and source iframe. Per-app runtime enforcement of declared SBFB.json bridge.methods is still a hardening item.sbfb-ideas, use iroh-docs replication; other app storage may remain local until explicitly wired..deb, and macOS .dmg. Public asset distribution is separate from the source tree.1. Create -- sbfb-factory create scaffolds an app with SBFB.json, index.html, a template lock, and the bridge SDK.
2. Develop -- The app can be plain static HTML or built from any toolchain that outputs browser files. sbfb-factory validate checks the manifest and basic workspace safety.
3. Preview -- sbfb-factory preview zips the local project, sends it to the local daemon preview store, and returns a blob-serve URL with a 30-minute TTL.
4. Publish -- sbfb-factory publish --repo-url ... talks to the local daemon, runs the currently automated publish checks, deploys from the source repository, and verifies the returned provenance. The full FG0-FG10 model is documented as the target process; the code path today automates FG4, FG5, FG6, deploy/publish, and FG8, with separate commands for preview and checks.
5. Distribute -- Other nodes discover the app through feed/gossip material, fetch the archive through iroh-blobs, verify the available hashes/signatures, and cache it locally.
6. Open -- Users open the app in the shell UI. The daemon serves app files through blob-serve into a sandboxed iframe. The app can only reach SBFB capabilities through the bridge methods exposed by the host.
Closed pilot users receive binaries and hashes directly from the maintainer. Public release asset links will be added when the pilot boundary is lifted.
From source:
# Build the launcher and daemon.
cargo build -p nexus-launcher -p nexus-shell-daemon --release
# Run the launcher (on Windows use target/release/nexus-launcher.exe).
./target/release/nexus-launcher
The launcher/daemon create local identity and auth material under the SBFB home directory (~/.sbfb by default, or SBFB_HOME when set). Some launcher and worker state uses platform data directories or .nexus-grid paths.
The daemon binds to loopback by default and authenticated HTTP routes require a 256-bit bearer token plus Host/Origin checks. The config comments require loopback; non-loopback binds should be treated as a security bug, not a supported production mode.
The Factory CLI is part of the source tree. It is not currently one of the binary artifacts attached by the release workflow, so build it from source for pilot work:
cargo build -p sbfb-factory --release
# Or install it into Cargo's bin directory.
cargo install --path crates/sbfb-factory
Example:
# Scaffold a static app.
sbfb-factory create myapp
cd myapp/
# Edit index.html, then validate.
sbfb-factory validate .
# Preview in the daemon sandbox.
sbfb-factory preview .
# Publish from a public source repository.
sbfb-factory publish . --repo-url https://github.com/<you>/<repo>
Factory gate status:
docs/factory/FACTORY_GATES.md.Every app carries a SBFB.json manifest:
{
"schema_version": 2,
"name": "my-app",
"display_name": "My App",
"description": "What this app does",
"category": "tools",
"license": "AGPL-3.0-or-later",
"lang": "en",
"bridge": {
"methods": ["storage_get", "storage_set", "identity_pubkey"]
},
"tech": {
"type": "static-html",
"build_command": null
}
}
The Rust parser is forward-compatible: unknown fields are ignored and older or newer schema versions can be accepted. That is intentional for network evolution, but it also means README examples may include fields that are documented for the protocol before every field is semantically enforced by the current runtime.
| App | Description | Bridge methods used |
|---|---|---|
Protocol Explorer (examples/sbfb-explorer/) | Interactive protocol documentation with live network status and provenance demos. Pure HTML/JS. | node_status, identity_pubkey, browse_list, provenance_verify |
Ideas Hub (examples/sbfb-ideas/) | Decentralized idea board with voting. This is the clearest current example of replicated app storage. Pure HTML/JS. | storage_get, storage_set, storage_list, storage_delete, storage_version (live-sync), identity_pubkey |
Factory Viewer (examples/sbfb-factory-viewer/) | Read-only sandboxed app for browsing network apps with search and Proof Cards. | browse_list, search, proof_card_get, storage_get |
These are demonstration apps. They prove the bridge and sandbox model, but they are not a complete app marketplace.
Apps in iframes communicate with the host through sbfb-bridge.js, a small dependency-free JavaScript library:
<script src="/sbfb-bridge.js"></script>
<script>
const bridge = new SBFBBridge({ timeout: 10000 });
// Storage. Replication depends on the app namespace and daemon wiring.
await bridge.setStorage("my-key", { value: 42 });
const data = await bridge.getStorage("my-key");
const all = await bridge.listStorage("prefix/");
await bridge.deleteStorage("old-key");
// Network introspection.
const status = await bridge.getNodeStatus();
const apps = await bridge.getBrowseList();
// Provenance and evidence views.
const proof = await bridge.verifyRelease(projectId);
const card = await bridge.getProofCard(projectId);
// Local full-text search.
const results = await bridge.search("translation app", { limit: 20 });
// Identity.
const me = await bridge.getIdentityPubkey();
// Compute task submission. Payload shape follows the coordinator task API
// and pilot apps should avoid sending personal data.
const redacted = await bridge.piiRedact(userInput);
await bridge.submitTask({ prompt: redacted.redacted_text });
// Poll-based live storage notifications.
const unsubscribe = bridge.onStorageUpdate("my-app", () => refresh());
</script>
Every request has a UUID correlation ID and timeout. The host validates message shape and iframe source before dispatch. The bridge surface is broader than the current manifest parser in a few places; keep SBFB.json bridge.methods as the declared intent, not as proof that every runtime call is per-app gated today.
A node can contribute GPU or CPU compute through nexus-worker. The current worker stack supports:
llm_llama_cpp feature and a valid GGUF model path.Important limits:
model_digest, logprobs_hash, and output_token_ids are useful signals, not a complete anti-cheat proof. Today, model_digest can be derived from the model name and logprobs_hash can be empty/zero depending on backend.The threat model follows STRIDE + LINDDUN. Current defense layers:
sandbox="allow-scripts" without allow-same-origin; app JavaScript cannot access shell DOM, cookies, or localStorage.connect-src 'none' and sandbox directives.Additional protections include Hashcash anti-spam, GCRA rate limiting, age witness primitives, iframe watchdog pings, and launcher/keystore paths for encrypted key material. Direct daemon paths may still rely on user-only file permissions for some secrets, so key encryption at rest should not be described as universal until every launch path is covered.
Blob-serve is intentionally reachable without the normal bearer header because iframe navigation cannot attach custom headers. The compensating controls are content addressing, blob tickets where available, path traversal rejection, zip validation, iframe sandboxing, and CSP.
Full threat model: docs/security/THREAT_MODEL.md
Six cumulative trust levels describe what a node can currently know about an app. They are evidence labels, not endorsements.
| Level | Label | What it means |
|---|---|---|
| N0 | Upload direct | An archive exists on the network. Origin unknown. |
| N1 | Source readable | A public source repository is declared. |
| N2 | Provenance attested | A local Ed25519 self-attestation links source metadata to an artifact hash. |
| N3 | Signature verified | The local daemon verified the provenance signature and proof-chain fields it has. |
| N4 | Reproducible build | A third party rebuilt from source and got the same hash. Future. |
| N5 | Feed verified | The observed publication history is hash-chain integral. Global completeness still depends on network sync and cross-peer validation. |
Details: docs/trust/TRUST_TAXONOMY.md
The repo now separates protocol primitives from product experiments:
@dev/@web modes are future work.sbfb/
+-- Cargo.toml # Rust workspace
+-- crates/
| +-- nexus-core-rs/ # iroh wrapper, crypto, canonical bytes
| +-- nexus-coordinator-rs/ # DB, dispatcher, validator, kudos, search
| +-- nexus-shell-daemon-core/ # shared daemon auth/config/blob helpers
| +-- nexus-shell-daemon/ # local daemon: HTTP, blob-serve, feed, gossip
| +-- nexus-launcher/ # launcher: spawn daemon, token, browser
| +-- nexus-worker-core/ # worker engine, consent, LLM backends
| +-- nexus-worker/ # worker binary
| +-- nexus-events-core/ # security event writers
| +-- nexus-executor/ # build executor experiments
| +-- nexus-trace-core/ # OpenTelemetry tracing helpers
| +-- nexus-test-harness/ # integration test helpers
| +-- sbfb-manifest/ # SBFB.json parser shared by daemon/factory
| +-- sbfb-factory/ # Factory CLI/process/operator server
+-- web/ # Shell UI (React + TypeScript + Vite)
+-- tools/
| +-- factory-operator/ # Local control-center front (greenfield S80 Phase B: React 19 + Tailwind v4 oklch + Base UI)
+-- examples/
| +-- sbfb-explorer/ # Protocol Explorer app
| +-- sbfb-ideas/ # Ideas Hub app
| +-- sbfb-factory-viewer/ # Factory Viewer app
+-- docs/
| +-- security/THREAT_MODEL.md # STRIDE + LINDDUN threat model
| +-- trust/TRUST_TAXONOMY.md # N0-N5 trust levels
| +-- factory/FACTORY_GATES.md # FG0-FG10 target gate model
| +-- release/GATE1_TEST_PROTOCOL.md # Closed pilot procedures
+-- prompts/agent/ # Portable agent prompts
+-- .planning/ # Sprint plans, verification, research
| Layer | Technology |
|---|---|
| Protocol / daemon | Rust 2021, iroh 0.98, iroh-blobs 0.100, iroh-docs |
| Crypto | Ed25519, BLAKE3, ChaCha20-Poly1305 for QUIC, Argon2id, AES-256-GCM, FROST primitives |
| Storage | SQLite, local app storage, selected iroh-docs replicated namespaces |
| Search | SQLite FTS5 with BM25 ranking |
| LLM inference | Ollama by default; llama.cpp behind feature flags |
| Frontend | React 19, TypeScript, Tailwind CSS, shadcn/ui, Zustand, React Query, Vite |
| CI | Self-hosted Woodpecker (ci.sbfb.world) plus GitHub Actions |
| Installers | cargo-packager config for NSIS, .deb, AppImage, and .dmg |
Latest repo-recorded verification:
Core commands:
# Rust format, lint, and tests.
cargo fmt --all --check
cargo clippy --workspace --all-targets --locked -- -D warnings
cargo nextest run --workspace --locked
cargo test --workspace --locked --doc
# Release builds.
cargo build -p nexus-shell-daemon --release
cargo build -p sbfb-factory --release
# Frontend.
cd web
npm install
npm run lint
npx tsc --noEmit -p tsconfig.app.json
npm run test:unit
npm run build
npm run size
The SBFB protocol, daemon, worker, and tooling are licensed under AGPL-3.0-or-later. Modifications to these network services must keep source available under the license.
Apps published on the SBFB network are not automatically AGPL. Each app carries its own license. To be recommended as a higher-trust app, it should publish source, declare a license, and provide verifiable provenance.
SBFB is in closed pilot phase.
The v1.0 tag exists on the configured Git remotes. Public release assets, hashes, and tester instructions are still a pilot boundary, not a broad public distribution guarantee.
Works today:
hooks/register.ts 114 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { castEvent, castFileName, castHeader, currentPhase, maxSprint, rowToText } from './lib'
4
5type $ = EngineInterface
6
7const WIDTH = 120
8const HEIGHT = 30
9const FLUSH_MS = 2000
10// $.fs.read plafonne à 4 Mio : on reste en dessous pour que le .cast reste relisible.
11const MAX_BYTES = 3_500_000
12
13// Repart de zéro à chaque rechargement : un rechargement ouvre un nouveau fichier.
14const rec: {
15 configuredRoot: string
16 path: string | null
17 header: string
18 lines: string[]
19 bytes: number
20 startedAt: number
21 isOn: boolean
22 isDirty: boolean
23 isFull: boolean
24} = { configuredRoot: '', path: null, header: '', lines: [], bytes: 0, startedAt: 0, isOn: true, isDirty: false, isFull: false }
25
26async function resolveRoot($: $): Promise<string | null> {
27 const candidates: string[] = []
28 if (rec.configuredRoot) candidates.push(rec.configuredRoot)
29 try {
30 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: await $.session.cwd(), timeoutMs: 5000 })
31 if (top.exitCode === 0) candidates.push(top.stdout.trim())
32 } catch {
33 // pas de git
34 }
35 for (const c of candidates) {
36 const base = c.replace(/\\/g, '/').replace(/\/$/, '')
37 if (await $.fs.exists(`${base}/scripts/agent/agentctl.py`)) return base
38 }
39 return null
40}
41
42async function open($: $, root: string): Promise<void> {
43 const entries = await $.fs.list(`${root}/.planning/active`).catch(() => [])
44 const sprint = maxSprint(entries.map(e => e.name))
45 const phase = sprint === null ? null : currentPhase(entries, sprint)
46 rec.startedAt = await $.clock.now()
47 rec.path = `${root}/.planning/terminal/${castFileName(sprint, phase, new Date(rec.startedAt).toISOString())}`
48 rec.header = castHeader(WIDTH, HEIGHT, rec.startedAt / 1000)
49 rec.lines = []
50 rec.bytes = rec.header.length
51 rec.isFull = false
52 rec.isDirty = true
53 await flush($)
54}
55
56async function push($: $, text: string): Promise<void> {
57 if (rec.path === null || !rec.isOn || rec.isFull) return
58 const line = castEvent(((await $.clock.now()) - rec.startedAt) / 1000, text)
59 if (rec.bytes + line.length > MAX_BYTES) {
60 rec.lines.push(castEvent(((await $.clock.now()) - rec.startedAt) / 1000, '\r\n[enregistrement arrêté : taille maximale atteinte]\r\n'))
61 rec.isFull = true
62 } else {
63 rec.lines.push(line)
64 rec.bytes += line.length + 1
65 }
66 rec.isDirty = true
67}
68
69async function flush($: $): Promise<void> {
70 if (rec.path === null || !rec.isDirty) return
71 rec.isDirty = false
72 await $.fs.write(rec.path, `${[rec.header, ...rec.lines].join('\n')}\n`)
73}
74
75export const register: Register = (on, options) => {
76 rec.configuredRoot = typeof options.nexusRoot === 'string' ? options.nexusRoot.trim() : ''
77
78 on('session.start', async ($, e, next) => {
79 const started = await next(e)
80 const root = await resolveRoot($)
81 if (root === null) return started
82 await $.command.register({ name: 'nexus-rec', description: "Enregistrement .cast de la session : on, off ou état", argumentHint: 'on|off' })
83 await open($, root)
84 $.clock.every(FLUSH_MS, () => void flush($))
85 return started
86 })
87
88 on('session.append', async ($, e, next) => {
89 const stored = await next(e)
90 if (e.agentId === undefined && rec.path !== null && rec.isOn) {
91 const text = rowToText({ door: e.door, role: e.message.role, isMeta: e.message.isMeta, content: e.message.content })
92 if (text !== null) await push($, text)
93 }
94 return stored
95 }).catch(($, e, next) => next(e))
96
97 on('session.end', async ($, e, next) => {
98 await flush($).catch(() => undefined)
99 return next(e)
100 })
101
102 on('command.run', { command: 'nexus-rec' }, async ($, e) => {
103 const arg = e.args.trim().toLowerCase()
104 if (arg === 'off') {
105 await flush($)
106 rec.isOn = false
107 } else if (arg === 'on') {
108 rec.isOn = true
109 }
110 const state = rec.path === null ? 'aucun fichier' : rec.path
111 return { text: `Enregistrement ${rec.isOn ? 'actif' : 'en pause'}${rec.isFull ? ' (plein)' : ''} → ${state} (${rec.lines.length} événements)` }
112 })
113}
114hooks/lib.ts 155 lines1// Fonctions pures de l'enregistreur asciicast v2, testables sans I/O.
2// Format et nommage repris des .cast existants de nexus/.planning/terminal
3// et de crates/sbfb-factory/src/terminal.rs:27.
4
5export type Block = { type: string; [field: string]: unknown }
6export type Row = { door: string; role?: string; isMeta?: boolean; content: readonly Block[] }
7
8const RESET = '\x1b[0m'
9const DIM = '\x1b[2m'
10const PROMPT = '\x1b[1;36m'
11const TOOL = '\x1b[33m'
12const ERROR = '\x1b[31m'
13const RESULT_LINES = 12
14
15export function maxSprint(names: readonly string[]): number | null {
16 let best: number | null = null
17 for (const name of names) {
18 const m = /^sprint(\d+)_/.exec(name)
19 if (m) best = Math.max(best ?? 0, Number(m[1]))
20 }
21 return best
22}
23
24export function currentPhase(entries: readonly { name: string; mtimeMs: number }[], sprint: number): string | null {
25 const re = new RegExp(`^sprint${sprint}_phase_([a-z0-9]+)_preflight\\.md$`, 'i')
26 let best: { phase: string; mtimeMs: number } | null = null
27 for (const e of entries) {
28 const m = re.exec(e.name)
29 if (m && (best === null || e.mtimeMs > best.mtimeMs)) best = { phase: m[1]!, mtimeMs: e.mtimeMs }
30 }
31 return best?.phase ?? null
32}
33
34/** `sprint{N}_phase_{X}_{horodatage}.cast`, comme terminal.rs:27 (":" interdit sous Windows). */
35export function castFileName(sprint: number | null, phase: string | null, iso: string): string {
36 const ts = iso.replace(/:/g, '-')
37 return `sprint${sprint ?? 0}_phase_${(phase ?? 'session').toUpperCase()}_${ts}.cast`
38}
39
40export function castHeader(width: number, height: number, epochSeconds: number): string {
41 return JSON.stringify({
42 version: 2,
43 width,
44 height,
45 timestamp: Math.floor(epochSeconds),
46 env: { TERM: 'xterm-256color', SHELL: 'claude' },
47 })
48}
49
50export function castEvent(seconds: number, text: string): string {
51 return JSON.stringify([Math.round(seconds * 1e6) / 1e6, 'o', text])
52}
53
54const SECRET_PATTERNS: readonly RegExp[] = [
55 /(x-sbfb-token\s*[:=]\s*)[^\s"']+/gi,
56 /((?:CODEBERG|GITHUB|GH|NEO4J|ANTHROPIC|OPENAI|SBFB)_[A-Z_]*(?:TOKEN|KEY|PASSWORD|SECRET)\s*[:=]\s*)[^\s"']+/gi,
57 /(Bearer\s+)[A-Za-z0-9._~+/=-]{16,}/g,
58 /()\bgh[pousr]_[A-Za-z0-9]{20,}/g,
59 /()\bsk-[A-Za-z0-9_-]{20,}/g,
60]
61
62export function maskSecrets(text: string): string {
63 let out = text
64 for (const re of SECRET_PATTERNS) out = out.replace(re, (_all, keep: string) => `${keep}***`)
65 return out
66}
67
68function terminalText(text: string): string {
69 return text.replace(/\r?\n/g, '\r\n')
70}
71
72function textOf(blocks: readonly Block[]): string {
73 return blocks
74 .filter(b => b.type === 'text' && typeof b.text === 'string')
75 .map(b => b.text as string)
76 .join('\n')
77}
78
79function summarize(input: unknown): string {
80 if (input === null || typeof input !== 'object') return ''
81 const o = input as Record<string, unknown>
82 for (const key of ['command', 'file_path', 'pattern', 'description', 'url', 'query', 'prompt']) {
83 const v = o[key]
84 if (typeof v === 'string') return v.split('\n')[0]!.slice(0, 160)
85 }
86 return JSON.stringify(o).slice(0, 160)
87}
88
89function resultText(block: Block): string {
90 const c = block.content
91 if (typeof c === 'string') return c
92 if (Array.isArray(c)) {
93 return c
94 .map(part => (part && typeof part === 'object' && (part as Block).type === 'text' ? String((part as Block).text ?? '') : '[contenu non texte]'))
95 .join('\n')
96 }
97 return ''
98}
99
100function clip(text: string, max: number): string {
101 const lines = text.split('\n')
102 if (lines.length <= max) return text
103 return `${lines.slice(0, max).join('\n')}\n… (+${lines.length - max} lignes)`
104}
105
106function tag(text: string, name: string): string | null {
107 const m = new RegExp(`<${name}>([\\s\\S]*?)</${name}>`).exec(text)
108 return m ? m[1]!.trim() : null
109}
110
111/** Les lignes d'une commande slash arrivent balisées (<command-name>, <local-command-stdout>…). */
112export function commandText(text: string): string | null {
113 if (!text.trim()) return null
114 const name = tag(text, 'command-name')
115 if (name !== null) {
116 const args = tag(text, 'command-args')
117 return `\n${PROMPT}❯ ${name}${args ? ` ${args}` : ''}${RESET}\n`
118 }
119 const output = tag(text, 'local-command-stdout') ?? tag(text, 'local-command-stderr')
120 const body = (output ?? text.replace(/<[^>]+>/g, '')).trim()
121 return body ? `${DIM}${clip(body, RESULT_LINES)}${RESET}\n` : null
122}
123
124/** Une ligne du transcript → texte de terminal (ANSI), ou null si rien à montrer. */
125export function rowToText(row: Row): string | null {
126 if (row.isMeta) return null
127 let out = ''
128 if (row.door === 'prompt') {
129 const text = textOf(row.content)
130 if (!text) return null
131 out = `\n${PROMPT}❯ ${text}${RESET}\n`
132 } else if (row.door === 'response') {
133 const parts: string[] = []
134 for (const b of row.content) {
135 if (b.type === 'text' && typeof b.text === 'string' && b.text.trim()) parts.push(b.text)
136 if (b.type === 'tool_use') parts.push(`${TOOL}● ${String(b.name ?? '?')}${RESET}${DIM} ${summarize(b.input)}${RESET}`)
137 }
138 if (parts.length === 0) return null
139 out = `\n${parts.join('\n')}\n`
140 } else if (row.door === 'tool-result') {
141 const parts = row.content
142 .filter(b => b.type === 'tool_result')
143 .map(b => `${b.is_error === true ? ERROR : DIM} ⎿ ${clip(resultText(b), RESULT_LINES).replace(/\n/g, '\n ')}${RESET}`)
144 if (parts.length === 0) return null
145 out = `${parts.join('\n')}\n`
146 } else if (row.door === 'command') {
147 const text = commandText(textOf(row.content))
148 if (!text) return null
149 out = text
150 } else {
151 return null
152 }
153 return terminalText(maskSecrets(out))
154}
155