SLOPSHOPPER

nexus-terminal-rec

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

newcommandprocesstimer
★ 2v0.1.0NOASSERTIONupdated 2026-10-19SBFB50/SBFB/.claude/skills/nexus-terminal-rec
A shopper browsing a rack in a slop shop
README

SBFB -- Decentralized P2P App Distribution and Compute

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.

What Is SBFB?

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.

Current Maturity

AreaStatusWhat this means
Local daemon, shell UI, iframe sandbox, bridgeCode-backedA node can run a local shell, open sandboxed apps, and broker app requests through postMessage.
P2P app distributionCode-backed / pilotiroh gossip, blobs, docs, feed sync, and blob cache exist. LAN/WAN pilot validation is narrower than a public production network.
Source provenanceCode-backed self-attestationprovenance.json and Ed25519 signatures exist. This is not a third-party audit or independent reproducible build.
Factory CLICode-backedcreate, validate, preview, and publish exist. The automated publish path currently covers a subset of the full FG0-FG10 gate model.
Factory Viewer / OperatorCode-backed local toolsRead-only viewer and local privileged operator exist for pilot/dev workflows.
Local search and Proof CardsCode-backed local featuresSQLite FTS5 and deterministic Proof Cards exist. Cross-node SearchManifest discovery is future work.
Curator primitivesCode-backed primitivesSigned vouch/disendorse/feed operations exist. Full governance timelines, dissent UI, and social process are future work.
Worker computeCode-backed primitives / pilotConsent, 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.
InstallersBuilt and internally validatedWindows 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 pilotIn progressThe 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.

Architecture

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

Key Features

  • Peer-to-peer distribution -- app archives propagate through gossip and iroh-blobs as BLAKE3-addressed content. No central host is required for the archive itself.
  • Source-verifiable publish path -- deploy-from-repo writes a 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.
  • Browser sandbox -- app iframes run with sandbox="allow-scripts" and no allow-same-origin; untrusted content receives CSP including connect-src 'none'.
  • Bridge API -- apps use 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.
  • Curator lists instead of central moderation -- communities can sign recommendations or warnings. Users choose curators; no curator can delete an archive from the network.
  • App storage -- the storage bridge supports app namespaces. Some namespaces, currently including sbfb-ideas, use iroh-docs replication; other app storage may remain local until explicitly wired.
  • Local full-text search -- SQLite FTS5 indexes local browse/feed material and returns BM25-ranked results. SearchManifest-based cross-node discovery is future work.
  • Proof Cards -- local deterministic evidence scores (0-100) summarize provenance, feed, curator, license, and freshness signals. A Proof Card is an explanation surface, not a signed security certificate.
  • Distributed compute primitives -- workers enforce 4-level consent, watts/VRAM/hour caps, task signatures, result signatures, and kudos credit. Public worker quorum and exact model/GPU proof are not complete.
  • Kudos v1 -- compute/task contributions use a non-monetary, non-transferable ledger with logarithmic returns, EMA decay, BLAKE3 hash-chain, and fairness metrics. Multi-family kudos and governance weighting are design work.
  • Append-only public feed -- feed entries are Ed25519-signed and BLAKE3 hash-chained per author. Nodes verify the history they have observed; global completeness across all peers remains a network/pilot question.
  • Key rotation -- Ed25519 rotation announcements exist with a 7-day default transition window; other windows can be encoded by the announcement.
  • Cross-platform packaging -- cargo-packager config exists for Windows NSIS, Linux .deb, and macOS .dmg. Public asset distribution is separate from the source tree.
  • AGPL-3.0-or-later -- protocol, daemon, worker, and tooling remain free software. Apps published on the network keep their own licenses.

How It Works

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.

Quick Start

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.

Publishing An App

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:

  • Spec/process: FG0-FG10 are documented in docs/factory/FACTORY_GATES.md.
  • Automated today: manifest pre-validation plus FG4 diff, FG5 sandbox/path checks, FG6 secret/dependency scan, deploy/publish, and FG8 provenance check.
  • Separate command: FG7 preview readiness can be run through the preview check.
  • Future/process: FG0-FG3 classification/scope/template/manifest depth and FG10 curator review are not a single fully enforced publish pipeline yet.

SBFB.json Manifest

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.

Example Apps

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

Bridge SDK

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.

GPU And CPU Compute

A node can contribute GPU or CPU compute through nexus-worker. The current worker stack supports:

  • Ollama over HTTP as the default backend.
  • Optional embedded llama.cpp when built with the llm_llama_cpp feature and a valid GGUF model path.
  • Optional GPU ephemeral behavior such as VRAM wipe when the relevant feature and runtime prerequisites are enabled.
  • 4 consent levels:
  • L1 -- my projects only (default).
  • L2 -- projects marked open-source/provenance-present.
  • L3 -- manual allowlist.
  • L4 -- all public projects.
  • Per-project caps on watts, VRAM, and daily hours.
  • Signed task, claim, and result records.
  • Kudos credit after validation.

Important limits:

  • Workers see task prompts in clear text. Apps should redact PII before submission.
  • The validator checks signatures, task state, and quorum paths where enabled; it does not yet prove that a specific model file ran on a specific GPU.
  • 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.
  • SHA256 quorum exists as a foundation for build/redundancy paths; public multi-worker quorum at scale is still a pilot/future item.

Security Model

The threat model follows STRIDE + LINDDUN. Current defense layers:

  1. Iframe sandbox -- sandbox="allow-scripts" without allow-same-origin; app JavaScript cannot access shell DOM, cookies, or localStorage.
  2. Content Security Policy -- untrusted app content receives strict CSP, including connect-src 'none' and sandbox directives.
  3. postMessage bridge -- UUID correlation, typed request schema, global method allowlist, and iframe source validation.
  4. Loopback HTTP auth -- 256-bit bearer token, Host allowlist, and Origin checks for browser/HTTP calls.
  5. Local IPC auth -- Unix SO_PEERCRED and Windows Named Pipe DACLs are used for local non-browser clients when those transports are available.
  6. Provenance and feed integrity -- Ed25519 signatures, BLAKE3 content hashes, and append-only hash-chain feed entries.

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

Trust Levels

Six cumulative trust levels describe what a node can currently know about an app. They are evidence labels, not endorsements.

LevelLabelWhat it means
N0Upload directAn archive exists on the network. Origin unknown.
N1Source readableA public source repository is declared.
N2Provenance attestedA local Ed25519 self-attestation links source metadata to an artifact hash.
N3Signature verifiedThe local daemon verified the provenance signature and proof-chain fields it has.
N4Reproducible buildA third party rebuilt from source and got the same hash. Future.
N5Feed verifiedThe observed publication history is hash-chain integral. Global completeness still depends on network sync and cross-peer validation.

Details: docs/trust/TRUST_TAXONOMY.md

Factory, RRV, And Babel

The repo now separates protocol primitives from product experiments:

  • Factory is the app creation/publish toolchain. It has a CLI, read-only Factory Viewer app, local privileged Factory Operator, manifest generation, preview, secret scanning, sandbox checks, and provenance hooks.
  • RRV means "Recherche Reseau Verifiable": a search/explanation layer that can inspect protocol facts, app metadata, proofs, and eventually source/code evidence. The local FTS5 and Proof Card pieces exist; SearchManifest and broader @dev/@web modes are future work.
  • Babel is the intended dogfood/canary app for Factory and RRV. It is an application built on the protocol, not part of the protocol itself. The MVP should be a static reader with public-domain fixtures before any live translation network is treated as mature.

Project Structure

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

Tech Stack

LayerTechnology
Protocol / daemonRust 2021, iroh 0.98, iroh-blobs 0.100, iroh-docs
CryptoEd25519, BLAKE3, ChaCha20-Poly1305 for QUIC, Argon2id, AES-256-GCM, FROST primitives
StorageSQLite, local app storage, selected iroh-docs replicated namespaces
SearchSQLite FTS5 with BM25 ranking
LLM inferenceOllama by default; llama.cpp behind feature flags
FrontendReact 19, TypeScript, Tailwind CSS, shadcn/ui, Zustand, React Query, Vite
CISelf-hosted Woodpecker (ci.sbfb.world) plus GitHub Actions
Installerscargo-packager config for NSIS, .deb, AppImage, and .dmg

Tests

Latest repo-recorded verification:

  • 1486 Rust tests in the workspace nextest suite.
  • 279 Vitest tests for frontend, bridge, and API schemas.
  • 6/6 size-limit checks.

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

License

AGPL-3.0-or-later

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.

Status

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:

  • Local daemon, launcher, shell UI, iframe sandbox, and bridge.
  • P2P app distribution primitives: gossip, blobs, feed, local cache.
  • Deploy from source with Ed25519 self-attested provenance.
  • Public feed, signed curator operations, endorsement/disendorsement, and subscriptions.
  • Local FTS5 s
Source 2 files
hooks/register.ts 114 lines
1import 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}
114
hooks/lib.ts 155 lines
1// 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