chore(typo): retire tous les tirets cadratins/demi-cadratins + garde CI
Some checks failed
CI / Build & test (Node 24) (push) Has been cancelled
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
CI / No em/en dashes (push) Has been cancelled
CI / Build & test (Node 22) (push) Has been cancelled
Deploy site (production) / build-and-deploy (push) Successful in 20s

Remplace les 547 tirets cadratins (U+2014) et demi-cadratins (U+2013) des fichiers versionnés par la ponctuation contextuelle adaptée (point médian, deux-points, virgule, parenthèses ; tiret simple pour les plages), sur 122 fichiers (appli, vitrine, doc, tests, workflows, scripts).

Ajoute le job CI « lint-dashes » (git grep -P) qui échoue si un tiret cadratin/demi-cadratin réapparaît, hors logo binaire et captures brutes du terminal (fidélité des fixtures de détection de dialogue).
This commit is contained in:
2026-07-17 16:44:00 +02:00
parent 985531a986
commit 65ef616867
122 changed files with 538 additions and 521 deletions

View File

@@ -103,7 +103,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
projectsDir: config.claudeProjectsDir,
sessionsDir: config.claudeSessionsDir,
});
// Archivage auto des sessions terminées (P10). Démarré dans runDaemon() jamais ici (tests).
// Archivage auto des sessions terminées (P10). Démarré dans runDaemon() : jamais ici (tests).
const sessionArchive = new SessionArchiveService({ db });
// Watcher FS temps réel (P7) : armé à la demande sur les worktrees regardés via les commandes
// WS watch/unwatch. closeAll() au drain (libération des descripteurs).
@@ -112,9 +112,9 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
// Démarré dans runDaemon() (jamais ici) → le scan ne tourne pas pendant les tests qui appellent buildApp.
const repoDiscovery = new RepoDiscoveryService(db, worktrees);
const groups = new GroupManager(db);
// P11 bus de diffusion des réglages : PATCH /settings émet, la gateway relaie au topic 'settings'.
// P11 · bus de diffusion des réglages : PATCH /settings émet, la gateway relaie au topic 'settings'.
const settingsBus = new SettingsBus();
// P12 services git distants : `box` (SecretBox) câblé ici pour chiffrer les secrets des credentials.
// P12 · services git distants : `box` (SecretBox) câblé ici pour chiffrer les secrets des credentials.
const gitCredentials = new GitCredentialsManager(db, box);
const clones = new CloneManager(db, worktrees, gitCredentials);
@@ -176,7 +176,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
};
// Garde globale : auth sur tout /api/** et /ws ; check Origin strict quand l'en-tête est présent
// (anti cross-site WebSocket hijacking le cookie SameSite=Strict ne suffit pas pour les upgrades).
// (anti cross-site WebSocket hijacking : le cookie SameSite=Strict ne suffit pas pour les upgrades).
app.addHook('preValidation', async (req, reply) => {
const isApi = req.url.startsWith('/api/');
const isWs = req.url.startsWith('/ws');
@@ -207,12 +207,12 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
registerAuditRoutes(app, db);
registerDataRoutes(app, db, auth);
// La route websocket doit être déclarée APRÈS le chargement du plugin (contexte
// encapsulé) sinon le handler reçoit la signature REST (request, reply).
// encapsulé) : sinon le handler reçoit la signature REST (request, reply).
void app.register(async (scoped) => {
registerWsGateway(scoped, manager, discovery, sessionArchive, worktrees, groups, settingsBus, clones, serverVersion);
});
// SPA buildée embarquée dans le paquet npm (public/) absente en dev (vite dev sert le front)
// SPA buildée embarquée dans le paquet npm (public/) : absente en dev (vite dev sert le front)
const publicDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'public');
if (existsSync(publicDir)) {
void app.register(fastifyStatic, { root: publicDir, wildcard: false });

View File

@@ -131,7 +131,7 @@ export function renderSystemdUnit(input: { exec: string; scriptArgs: string[]; p
const pathLine = input.pathEnv ? `\nEnvironment="PATH=${input.pathEnv}"` : '';
// KillSignal=SIGTERM + TimeoutStopSec=10 collent au drain de runDaemon (SIGTERM → drain 1s → close).
return `[Unit]
Description=Arboretum git worktree & Claude Code dashboard
Description=Arboretum · git worktree & Claude Code dashboard
After=network-online.target
Wants=network-online.target
@@ -195,14 +195,14 @@ ${args}
export function printTokenBanner(token: string, url: string): void {
console.log('\n┌──────────────────────────────────────────────────────────────────┐');
console.log('│ First start your access token (shown once, store it safely): │');
console.log('│ First start : your access token (shown once, store it safely): │');
console.log('└──────────────────────────────────────────────────────────────────┘');
console.log(`\n ${token}\n`);
console.log(` Login at: ${url}/\n`);
}
export function printUsage(version: string): void {
console.log(`Arboretum v${version} git worktree & Claude Code dashboard
console.log(`Arboretum v${version} · git worktree & Claude Code dashboard
Usage:
arboretum [flags] Start the daemon (default)
@@ -218,7 +218,7 @@ Daemon flags:
--allow-origin <url> Additional allowed Origin (repeatable)
--db <path> SQLite database path
--vapid-contact <mailto|url> VAPID contact subject for Web Push
--i-know-this-exposes-a-terminal Acknowledge a non-loopback bind (avoid prefer Tailscale Serve)
--i-know-this-exposes-a-terminal Acknowledge a non-loopback bind (avoid, prefer Tailscale Serve)
Install flags (daemon flags above are propagated to the service):
--bin-path <path> Use this binary in the service instead of node + script
@@ -257,7 +257,7 @@ function bootstrapToken(serviceArgs: string[]): void {
try {
const token = new AuthService(db).ensureBootstrapToken();
if (token) printTokenBanner(token, url);
else console.log('\nAn access token already exists in this database — manage tokens from Settings.\n');
else console.log('\nAn access token already exists in this database. Manage tokens from Settings.\n');
} finally {
db.close();
}
@@ -294,7 +294,7 @@ export async function runInstall(argv: string[]): Promise<void> {
run('systemctl', ['--user', 'enable', '--now', SERVICE_NAME], { check: true });
// enable-linger best-effort : absent en CI / sans session loginctl, non bloquant.
if (run('loginctl', ['enable-linger', process.env.USER ?? '']) !== 0) {
console.warn('Warning: could not enable linger — the service may not start at boot.');
console.warn('Warning: could not enable linger. The service may not start at boot.');
}
}
console.log(`\nArboretum service installed. Logs: journalctl --user -u ${SERVICE_NAME} -f`);

View File

@@ -15,7 +15,7 @@ export interface Config {
claudeHome: string;
/** true si --claude-home a été passé explicitement → a priorité sur le réglage claude_home. */
claudeHomeFromFlag: boolean;
/** ~/.claude/projects (transcripts JSONL) surchargeable via --claude-home (tests). */
/** ~/.claude/projects (transcripts JSONL) : surchargeable via --claude-home (tests). */
claudeProjectsDir: string;
/** ~/.claude/sessions (registre des sessions CLI vivantes). */
claudeSessionsDir: string;
@@ -35,11 +35,11 @@ export function loadConfig(argv = process.argv.slice(2)): Config {
'allow-origin': { type: 'string', multiple: true },
'print-token': { type: 'boolean', default: false },
'i-know-this-exposes-a-terminal': { type: 'boolean', default: false },
// racine de l'install Claude (~/.claude par défaut) surchargée par les tests d'acceptation.
// racine de l'install Claude (~/.claude par défaut) : surchargée par les tests d'acceptation.
'claude-home': { type: 'string' },
// sujet VAPID des notifications push (contact requis par la spec Web Push).
'vapid-contact': { type: 'string' },
// désactive la découverte auto des repos (boot + périodique) utilisé par les tests d'acceptation.
// désactive la découverte auto des repos (boot + périodique) : utilisé par les tests d'acceptation.
'no-discover': { type: 'boolean', default: false },
},
strict: true,

View File

@@ -1,7 +1,7 @@
// Journal d'audit : trace persistante des opérations sensibles (création/révocation de tokens,
// changements de réglages, génération de secrets, abonnements push, CRUD groupes). Exigence de
// conformité entreprise (GDPR/SOX/ISO 27001). Règle ABSOLUE : ne JAMAIS journaliser un secret en
// clair `details` ne contient que des métadonnées non sensibles (ids, labels, compteurs).
// clair : `details` ne contient que des métadonnées non sensibles (ids, labels, compteurs).
import { randomUUID } from 'node:crypto';
import type { AuditLogEntry } from '@arboretum/shared';
import type { Db } from '../db/index.js';

View File

@@ -49,7 +49,7 @@ function isExecutable(path: string): boolean {
/**
* Résout le binaire `claude`. Si `configuredPath` est fourni (réglage UI), il est utilisé tel quel
* validé exécutable, message clair sinon et JAMAIS mis en cache (modifiable à chaud). Sinon :
* (validé exécutable, message clair sinon) et JAMAIS mis en cache (modifiable à chaud). Sinon :
* `which claude`, mis en cache. Un service systemd/launchd démarre avec un PATH minimal sans
* ~/.local/bin → `which claude` y échoue ; d'où le réglage de chemin explicite (et le PATH figé par
* `arboretum install`).
@@ -93,7 +93,7 @@ export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec {
}
const args: string[] = [];
if (opts.resume) {
// `--resume` doit toujours s'exécuter dans le cwd d'origine (garanti par l'appelant spike S1).
// `--resume` doit toujours s'exécuter dans le cwd d'origine (garanti par l'appelant, spike S1).
args.push('--resume', opts.resume.claudeSessionId);
if (opts.resume.fork) args.push('--fork-session');
}

View File

@@ -1,4 +1,4 @@
// Typage de dialogue à partir de l'écran reconstruit (ScreenReader) fonctions PURES.
// Typage de dialogue à partir de l'écran reconstruit (ScreenReader) : fonctions PURES.
// L'écran sert à TYPER le dialogue et extraire ses options ; l'état (waiting vrai/faux) vient du
// registre (source primaire). Tolérant aux variations de rendu inter-versions (texte aplati + regex).
import type { DialogKind, DialogOption } from '@arboretum/shared';
@@ -23,7 +23,7 @@ export function parseOptions(lines: string[]): DialogOption[] {
/**
* Retourne le dialogue typé visible à l'écran, ou null si aucun. `trust` et `question` priment sur
* `permission` (un écran AskUserQuestion contient aussi « Esc to cancel »). Un écran numéroté non
* typé est traité en `permission` générique (best-effort le fallback reste le terminal web).
* typé est traité en `permission` générique (best-effort, le fallback reste le terminal web).
*/
export function classifyDialog(lines: string[]): ClassifiedDialog | null {
const text = lines.join('\n');

View File

@@ -64,13 +64,13 @@ export class DiscoveryService extends EventEmitter<DiscoveryServiceEvents> {
return this.cache;
}
/** Session découverte (avec son cwd d'origine lu sur disque) pour resume/fork. null si absente. */
/** Session découverte (avec son cwd d'origine lu sur disque) : pour resume/fork. null si absente. */
getDiscovered(claudeSessionId: string): DiscoveredJsonl | null {
return this.byId.get(claudeSessionId) ?? null;
}
/**
* Vivacité FRAÎCHE d'une session (relit le registre, ne se fie pas au cache) garde-fou
* Vivacité FRAÎCHE d'une session (relit le registre, ne se fie pas au cache) : garde-fou
* anti-corruption : la route /resume doit refuser une session devenue vivante depuis le dernier scan.
*/
isClaudeSessionLive(claudeSessionId: string): boolean {

View File

@@ -23,7 +23,7 @@ interface WatchEntry {
refCount: number;
/** nombre de sessions vivantes épinglant ce worktree. */
sessionPins: number;
/** épingle « permanente » (checkout principal d'un repo enregistré) jamais évincée (P11). */
/** épingle « permanente » (checkout principal d'un repo enregistré) : jamais évincée (P11). */
repoPins: number;
lastUsed: number;
debounce: NodeJS.Timeout | null;

View File

@@ -1,4 +1,4 @@
// Clients des services git distants (Gitea / GitLab / GitHub) P12. Uniquement `fetch` global
// Clients des services git distants (Gitea / GitLab / GitHub) : P12. Uniquement `fetch` global
// (Node ≥ 22), AUCUNE dépendance (pas d'octokit/gitbeaker). Chaque client expose verify() (test de
// connectivité/auth) et listRepos() (paginé). Erreurs typées : AUTH_FAILED / RATE_LIMITED / UNREACHABLE.
import type { GitAuthType, GitService, RemoteRepoSummary } from '@arboretum/shared';

View File

@@ -1,6 +1,6 @@
// Gestion des credentials des services git distants (P12). Les secrets (PAT/app password) sont
// chiffrés par SecretBox AVANT insertion et ne ressortent JAMAIS via l'API (résumés sans secret).
// getSecret()/authFor() sont INTERNES (clone, listRepos, test) jamais routés.
// getSecret()/authFor() sont INTERNES (clone, listRepos, test) : jamais routés.
import { randomUUID } from 'node:crypto';
import type {
CreateGitCredentialRequest,
@@ -137,7 +137,7 @@ export class GitCredentialsManager {
return true;
}
/** Secret déchiffré INTERNE (clone/listRepos/test). Jamais exposé par une route. */
/** Secret déchiffré : INTERNE (clone/listRepos/test). Jamais exposé par une route. */
getSecret(id: string): string | null {
const row = this.getRow(id);
if (!row?.secret_encrypted) return null;

View File

@@ -199,7 +199,7 @@ export async function branchExists(repoPath: string, branch: string): Promise<{
return { local, remote };
}
/** Branches locales + suivies de `origin` (noms courts) + branche par défaut pour un sélecteur de base. */
/** Branches locales + suivies de `origin` (noms courts) + branche par défaut : pour un sélecteur de base. */
export async function listBranches(repoPath: string): Promise<{ local: string[]; remote: string[]; default: string | null }> {
const local: string[] = [];
const remote: string[] = [];
@@ -476,7 +476,7 @@ export async function fetchRemote(worktreePath: string): Promise<void> {
await git(worktreePath, ['fetch', '--all', '--prune'], GIT_PUSH_TIMEOUT_MS);
}
/** `git pull` `ff-only` par défaut (jamais de merge surprise) ; `rebase` optionnel. */
/** `git pull` : `ff-only` par défaut (jamais de merge surprise) ; `rebase` optionnel. */
export async function pull(worktreePath: string, mode: 'ff-only' | 'rebase' = 'ff-only'): Promise<void> {
const args = mode === 'rebase' ? ['-c', 'rebase.autoStash=false', 'pull', '--rebase'] : ['pull', '--ff-only'];
await git(worktreePath, args, GIT_PUSH_TIMEOUT_MS);
@@ -532,7 +532,7 @@ async function resolveStartPoint(repoPath: string, baseRef?: string): Promise<st
/**
* Crée un worktree en résolvant la branche selon `mode` (voir `WorktreeBranchMode`). Renvoie l'action
* effective. En mode `auto`, on choisit checkout / suivi-remote / création selon l'existence réelle de
* la branche indispensable pour les groupes hétérogènes (branche présente dans certains dépôts seulement).
* la branche : indispensable pour les groupes hétérogènes (branche présente dans certains dépôts seulement).
*/
export async function addWorktree(
repoPath: string,
@@ -583,7 +583,7 @@ export async function push(repoPath: string): Promise<void> {
}
/**
* Crée/bascule une branche dans le checkout (worktree) en `repoPath` utilisé pour démarrer une
* Crée/bascule une branche dans le checkout (worktree) en `repoPath` : utilisé pour démarrer une
* session sur la branche principale sans worktree dédié. `create` → `git switch -c <branch>` (échoue
* si la branche existe) ; sinon `git switch <branch>` (branche existante). Pas de `--` : l'argument
* est une réf (pas un pathspec) et le nom est déjà filtré en amont par `isValidBranchName` (anti-flag).
@@ -610,13 +610,13 @@ const GIT_CLONE_TIMEOUT_MS = 10 * 60_000; // 10 min : un clone réseau peut êtr
export interface CloneProgress {
phase: string;
/** pourcentage 0100 si git le rapporte, sinon null. */
/** pourcentage 0-100 si git le rapporte, sinon null. */
percent: number | null;
}
/**
* Clone un dépôt via `git clone --progress` (P12). `spawn` (et non execFile) pour streamer la
* progression depuis stderr. `env` éphémère (cf. withGitAuth) porte l'auth JAMAIS dans l'URL.
* progression depuis stderr. `env` éphémère (cf. withGitAuth) porte l'auth : JAMAIS dans l'URL.
* `--` sépare l'URL/dest des options. L'appelant valide `dest` (sous scanRoots, non existant).
*/
export function cloneRepo(opts: {

View File

@@ -1,5 +1,5 @@
// Gestion des groupes de travail (P5) : un groupe = collection nommée de repos (many-to-many).
// Membership légère et persistée ; les worktrees/sessions du groupe ne sont PAS stockés ici
// Membership légère et persistée ; les worktrees/sessions du groupe ne sont PAS stockés ici :
// ils restent servis par WorktreeManager/PtyManager et filtrés côté client par repoId.
// Tout est synchrone : aucune I/O git/fs, node:sqlite est synchrone.
import { EventEmitter } from 'node:events';

View File

@@ -2,7 +2,7 @@ import { parse, resolve, sep } from 'node:path';
/**
* Plus long ancêtre commun d'un ensemble de chemins absolus (par segments, jamais par préfixe
* de chaîne `/a/bc` n'est PAS un ancêtre de `/a/bcd`). Pour un seul chemin, renvoie ce chemin.
* de chaîne : `/a/bc` n'est PAS un ancêtre de `/a/bcd`). Pour un seul chemin, renvoie ce chemin.
*/
export function commonAncestorDir(paths: string[]): string {
const first = paths[0];

View File

@@ -10,7 +10,7 @@ const HEAD_BYTES = 256 * 1024;
const TAIL_BYTES = 64 * 1024;
const TITLE_MAX = 120;
/** Reproduit le nom de dossier ~/.claude/projects à partir d'un cwd (validé 100 % spike S4). */
/** Reproduit le nom de dossier ~/.claude/projects à partir d'un cwd (validé 100 %, spike S4). */
export function munge(cwd: string): string {
return cwd.replace(/[^A-Za-z0-9]/g, '-');
}
@@ -82,7 +82,7 @@ function extractMeta(objs: Array<Record<string, unknown>>, meta: Meta): void {
setOnce('cwd', o.cwd);
setOnce('gitBranch', o.gitBranch);
setOnce('version', o.version);
// Titre : on retient la dernière valeur vue (la plus récente) head puis tail → la queue gagne.
// Titre : on retient la dernière valeur vue (la plus récente) : head puis tail → la queue gagne.
const ai = asString(o.aiTitle);
if (ai) meta.aiTitle = ai;
const sum = asString(o.summary);

View File

@@ -1,11 +1,11 @@
// Création d'un nouveau projet : un dossier `<root>/<name>` créé sous une racine existante, dans
// lequel on lance ensuite une session. Couche PURE (testable sans fs) la création réelle du
// lequel on lance ensuite une session. Couche PURE (testable sans fs) : la création réelle du
// dossier, le `git init` et le spawn vivent dans `routes/projects.ts`.
import { join } from 'node:path';
import { isSafeAbsolutePath } from './git.js';
/**
* Valide un nom de projet : UN SEUL segment de dossier. Anti-traversal de base refuse les noms
* Valide un nom de projet : UN SEUL segment de dossier. Anti-traversal de base : refuse les noms
* vides, trop longs, `.`/`..`, et tout caractère de séparation (`/`, `\`) ou NUL. La résolution du
* chemin complet (et sa re-validation) est faite par {@link resolveProjectDir}.
*/

View File

@@ -129,7 +129,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
throw Object.assign(new Error(`Not a directory: ${dir}`), { statusCode: 400 });
}
}
// Un resume/fork est toujours une session claude (le cwd d'origine est garanti par l'appelant S1).
// Un resume/fork est toujours une session claude (le cwd d'origine est garanti par l'appelant, S1).
const command = opts.resume ? 'claude' : (opts.command ?? 'claude');
// Override de chemin du binaire claude (réglage UI) lu à chaque spawn → effet sans redémarrage.
const claudeBinPath = command === 'claude' ? readClaudeBinPath(this.db) : null;
@@ -197,7 +197,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
return summary;
}
/** Résout le claudeSessionId du CLI en pollant le registre par pid, puis le persiste (waitReady S1). */
/** Résout le claudeSessionId du CLI en pollant le registre par pid, puis le persiste (waitReady, S1). */
private captureClaudeSessionId(s: ManagedSession): void {
const deadline = Date.now() + CLAUDE_ID_TIMEOUT_MS;
const tick = (): void => {
@@ -307,7 +307,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
}
/**
* Ré-émet un `session_update` pour une session historique (P10) utilisé au dés-archivage pour
* Ré-émet un `session_update` pour une session historique (P10) : utilisé au dés-archivage pour
* que tous les clients rafraîchissent le row (le champ `archived` repasse à false). No-op si la
* session est vivante (déjà couverte par le flux live) ou inconnue.
*/
@@ -345,7 +345,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
return true;
}
/** Arrêt du daemon : SIGTERM à toutes les sessions (le CLI nettoie son registre sur SIGTERM spike S1). */
/** Arrêt du daemon : SIGTERM à toutes les sessions (le CLI nettoie son registre sur SIGTERM, spike S1). */
shutdown(): void {
for (const id of this.live.keys()) this.kill(id);
}
@@ -396,7 +396,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
/**
* Répond à un dialogue Claude sans clavier (P4-A) : traduit une intention de haut
* niveau en keystrokes PTY, validée contre l'état fin du tracker (P3-B).
* - 'select' N : positionne le curseur sur l'option N puis confirme (`"N\r"`) protocole acté spike S3.
* - 'select' N : positionne le curseur sur l'option N puis confirme (`"N\r"`) : protocole acté spike S3.
* - 'confirm' : valide l'option pré-sélectionnée (`"\r"`).
* - 'deny' : refus universel (Esc).
* Réutilise le chemin write (mono-utilisateur : tout interactif peut répondre, observers non).
@@ -417,7 +417,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
s.proc.write(`${optionN}\r`);
return 'ok';
}
// confirm/deny n'exigent qu'un état d'attente (le dialogue Trust précède le registre S1).
// confirm/deny n'exigent qu'un état d'attente (le dialogue Trust précède le registre, S1).
if (act?.activity !== 'waiting') return 'invalid';
s.proc.write(action === 'deny' ? '\x1b' : '\r');
return 'ok';
@@ -446,10 +446,10 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
// ---- interne ----
/**
* Push P4-B : notifie sur le FRONT MONTANT vers un état qui requiert l'attention `waiting`
* Push P4-B : notifie sur le FRONT MONTANT vers un état qui requiert l'attention : `waiting`
* (un dialogue bloque) ou `idle` atteint depuis `busy` (Claude a terminé sa réponse, la session
* redevient disponible). Le tracker réémet souvent le même état → on ne réagit qu'au changement.
* Debounce annulable : un état ultra-bref (Claude repart tout seul) ne déclenche pas de notif
* Debounce annulable : un état ultra-bref (Claude repart tout seul) ne déclenche pas de notif :
* à l'échéance on revérifie l'état réel. Cible tous les abonnements (un seul utilisateur).
*/
private maybeNotify(s: ManagedSession, next: SessionActivity | null): void {

View File

@@ -8,7 +8,7 @@ import type { SecretBox } from './secret-box.js';
import { recordAudit } from './audit-log.js';
// web-push est publié en CommonJS : on le charge via require (verbatimModuleSyntax + NodeNext),
// typé par l'import type même pattern que @xterm/headless dans screen-reader.ts.
// typé par l'import type, même pattern que @xterm/headless dans screen-reader.ts.
const require = createRequire(import.meta.url);
const webpush = require('web-push') as typeof import('web-push');
@@ -33,7 +33,7 @@ interface SubRow {
auth: string;
}
/** Envoi d'une notif à un abonnement injectable pour les tests ; défaut = web-push réel. */
/** Envoi d'une notif à un abonnement : injectable pour les tests ; défaut = web-push réel. */
export type PushSender = (
subscription: { endpoint: string; keys: { p256dh: string; auth: string } },
payload: string,
@@ -70,7 +70,7 @@ export class PushService {
this.vapidPrivate = priv;
}
/** Clé publique VAPID sûre à exposer (applicationServerKey côté navigateur). */
/** Clé publique VAPID : sûre à exposer (applicationServerKey côté navigateur). */
publicKey(): string {
return this.vapidPublic;
}

View File

@@ -1,5 +1,5 @@
// Planificateur de la découverte auto des repos : scan au démarrage + re-scan périodique.
// Calqué sur DiscoveryService (sessions) start()/stop() avec timer .unref(). Démarré depuis
// Calqué sur DiscoveryService (sessions) : start()/stop() avec timer .unref(). Démarré depuis
// runDaemon() UNIQUEMENT (jamais buildApp), ce qui isole naturellement les tests vitest du scan.
// Lui-même sans état : il lit les racines/l'intervalle dans `settings` et délègue à WorktreeManager.
import type { Db } from '../db/index.js';

View File

@@ -1,5 +1,5 @@
// Découverte auto des dépôts git : marche bornée du système de fichiers à la recherche de `.git`.
// Fonction PURE et tolérante (ne lève jamais) testable isolément comme parseWorktreePorcelain.
// Fonction PURE et tolérante (ne lève jamais) : testable isolément comme parseWorktreePorcelain.
// N'appelle JAMAIS git (détection par présence de `.git`) : la validation réelle (isRepo) et la
// résolution de default_branch se font paresseusement à l'enregistrement, pas par dépôt scanné.
import { readdir } from 'node:fs/promises';
@@ -39,7 +39,7 @@ interface Frame {
* - un dossier contenant `.git` (fichier OU dossier → couvre les worktrees liés) est un repo :
* on l'enregistre ; en profondeur on NE descend PAS dedans (sous-modules/worktrees imbriqués
* ignorés). EXCEPTION : une racine fournie (depth 0) qui est elle-même un repo est aussi un
* conteneur on l'enregistre ET on continue de descendre pour trouver les repos internes ;
* conteneur : on l'enregistre ET on continue de descendre pour trouver les repos internes ;
* - on n'empile que les vrais sous-dossiers (`d.isDirectory()`), donc les symlinks ne sont PAS
* suivis (anti-cycle + anti-sortie de racine), et on saute dotdirs + excludeDirs ;
* - bornes : `maxDepth`, `maxRepos`, et un éventuel `signal` (timeout global) ;

View File

@@ -50,7 +50,7 @@ export function normalizeScanRoots(raw: unknown): string[] | null {
return out;
}
/** Valide un intervalle (entier 0MAX_SCAN_INTERVAL_MIN). Retourne null si invalide. */
/** Valide un intervalle (entier 0-MAX_SCAN_INTERVAL_MIN). Retourne null si invalide. */
export function normalizeScanIntervalMin(raw: unknown): number | null {
if (typeof raw !== 'number' || !Number.isInteger(raw)) return null;
if (raw < 0 || raw > MAX_SCAN_INTERVAL_MIN) return null;

View File

@@ -1,6 +1,6 @@
// Reconstruction d'écran via @xterm/headless : terminal headless PERSISTANT par session, alimenté
// incrémentalement par le flux PTY. Remplace le strip ANSI naïf (qui « mange les espaces » et casse
// la détection des dialogues verdict S1/S3). Aucune dépendance DOM (usage Node).
// la détection des dialogues, verdict S1/S3). Aucune dépendance DOM (usage Node).
import { createRequire } from 'node:module';
import type { Terminal as XtermTerminal } from '@xterm/headless';

View File

@@ -68,7 +68,7 @@ export class SessionArchiveService extends EventEmitter<SessionArchiveEvents> {
}
}
/** Diffuse l'archivage d'une session (déjà archivée + auditée par l'appelant ex. route manuelle). */
/** Diffuse l'archivage d'une session (déjà archivée + auditée par l'appelant, ex. route manuelle). */
emitArchived(id: string, archivedAt: string): void {
this.emit('session_archived', { id, archivedAt });
}

View File

@@ -78,7 +78,7 @@ export interface WorktreeManagerEvents {
repo_removed: [string];
worktree_update: [{ repoId: string; worktree: WorktreeSummary }];
worktree_removed: [{ repoId: string; path: string }];
/** P7 le détail (liste des changements/diff) d'un worktree regardé a changé ; relayé en push
/** P7 : le détail (liste des changements/diff) d'un worktree regardé a changé ; relayé en push
* ciblé `worktree_changes` aux seules connexions ayant `watch`é cette clé. */
worktree_changes: [{ repoId: string; path: string }];
}
@@ -136,7 +136,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
private readonly fsWatcher?: FsWatcherService,
) {
super();
// P7 un changement FS sur un worktree regardé invalide le cache, rediffuse le status frais
// P7 : un changement FS sur un worktree regardé invalide le cache, rediffuse le status frais
// (worktree_update : compteurs légers pour tout le dashboard) et signale aux clients qui le
// regardent de re-fetcher le détail (worktree_changes ciblé). On résout repoId → row à la volée.
this.fsWatcher?.on('worktree_fs_change', ({ repoId, path }) => {
@@ -207,7 +207,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
}
/**
* P11 arme un watcher PERMANENT sur le checkout principal de chaque repo visible : un
* P11 : arme un watcher PERMANENT sur le checkout principal de chaque repo visible : un
* `git checkout`/`switch` en CLI sur le principal est ainsi rediffusé en temps réel sans qu'un
* client ne l'ait « regardé ». Appelé depuis runDaemon (jamais buildApp → pas d'effet en tests purs).
*/
@@ -252,7 +252,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
/**
* Découvre les repos git sous `roots` et auto-enregistre les NOUVEAUX (path absent de la DB).
* Idempotent et anti-résurrection : un path déjà présent visible OU masqué n'est jamais
* Idempotent et anti-résurrection : un path déjà présent (visible OU masqué) n'est jamais
* réécrit (INSERT ... ON CONFLICT DO NOTHING). Les scans concurrents sont coalescés. Tolérant :
* ne lève pas (le scanner avale les erreurs FS). N'appelle aucun git pendant le scan
* (default_branch=NULL, résolu paresseusement par rowToSummary à l'affichage).
@@ -302,7 +302,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
/**
* Sessions (managées + découvertes) dont le cwd correspond à ce chemin de worktree.
* Les sessions explicitement masquées (`hidden`) sont exclues cohérent avec `/api/v1/sessions`
* Les sessions explicitement masquées (`hidden`) sont exclues, cohérent avec `/api/v1/sessions`
* (sans quoi le masquage était ignoré dans les fiches worktree). Le tri managées/externes est laissé
* au client (interrupteur « afficher les externes »), qui dispose du champ `source`. La garde de
* suppression réclame en revanche TOUTES les sessions vivantes (`includeHidden`) pour rester sûre.
@@ -351,7 +351,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
// quand l'utilisateur active « afficher les masqués ».
const rows = this.db.prepare('SELECT id FROM repos WHERE hidden = 0 ORDER BY created_at ASC').all() as Array<{ id: string }>;
// Tolérance par repo : avec la découverte auto, un repo douteux (git en échec, chemin disparu,
// permission) ne doit JAMAIS faire planter tout l'endpoint il ne contribue alors aucun worktree.
// permission) ne doit JAMAIS faire planter tout l'endpoint, il ne contribue alors aucun worktree.
const lists = await Promise.all(rows.map((r) => this.listRepoWorktrees(r.id).catch(() => [])));
return lists.flat();
}
@@ -420,7 +420,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
});
}
/** Branches locales/remote + branche par défaut d'un repo alimente le sélecteur de base côté UI. */
/** Branches locales/remote + branche par défaut d'un repo : alimente le sélecteur de base côté UI. */
async listRepoBranches(repoId: string): Promise<{ local: string[]; remote: string[]; default: string | null }> {
const row = this.getRepoRow(repoId);
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
@@ -444,16 +444,16 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
const mode = opts.mode ?? 'all';
return this.withLock(repoId, async () => {
if (opts.amend && !(await isUnpushed(w.path))) {
throw httpError(409, 'ALREADY_PUSHED', 'Last commit is already pushed amend would rewrite shared history');
throw httpError(409, 'ALREADY_PUSHED', 'Last commit is already pushed: amend would rewrite shared history');
}
const st = await worktreeStatus(w.path);
// Garde-fou « rien à committer » (sauf amend, qui peut ne changer que le message).
if (!opts.amend) {
if (mode === 'all' && st.dirtyCount === 0) {
throw httpError(409, 'NOTHING_TO_COMMIT', 'Nothing to commit working tree is clean');
throw httpError(409, 'NOTHING_TO_COMMIT', 'Nothing to commit: working tree is clean');
}
if (mode === 'staged' && (st.stagedCount ?? 0) === 0) {
throw httpError(409, 'NOTHING_STAGED', 'Nothing staged stage files first');
throw httpError(409, 'NOTHING_STAGED', 'Nothing staged: stage files first');
}
}
try {
@@ -646,17 +646,17 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
return this.withLock(repoId, async () => {
if (!force) {
if ((await worktreeStatus(w.path)).dirtyCount > 0) {
throw httpError(409, 'WORKTREE_DIRTY', 'Worktree has uncommitted changes commit or pass force');
throw httpError(409, 'WORKTREE_DIRTY', 'Worktree has uncommitted changes: commit or pass force');
}
if ((await worktreeStatus(row.path)).dirtyCount > 0) {
throw httpError(409, 'DIRTY_TREE', 'Main checkout has uncommitted changes commit/stash or pass force');
throw httpError(409, 'DIRTY_TREE', 'Main checkout has uncommitted changes: commit/stash or pass force');
}
}
try {
await removeWorktree(row.path, w.path, force);
} catch (err) {
if (!force && isDirtyWorktreeError(err)) {
throw httpError(409, 'WORKTREE_DIRTY', 'Worktree has uncommitted changes pass force to promote anyway');
throw httpError(409, 'WORKTREE_DIRTY', 'Worktree has uncommitted changes: pass force to promote anyway');
}
throw httpError(500, 'WORKTREE_REMOVE_FAILED', (err as Error).message);
}
@@ -672,7 +672,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
}
/**
* Lance UNE session dans le checkout principal du repo (`repo.path`) pour « bosser sur la branche
* Lance UNE session dans le checkout principal du repo (`repo.path`) : pour « bosser sur la branche
* principale » sans créer de worktree. Si `branch` est fourni, crée/bascule d'abord cette branche
* dans ce checkout (`git switch[-c]`), refusé si l'arbre est sale (on n'écrase pas un HEAD modifié).
* Volontairement SANS hooks ni pré-trust (contraste avec createWorktree) : le checkout principal
@@ -691,7 +691,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
if (branch !== undefined) {
// garde-fou : ne pas basculer le HEAD du checkout principal s'il a des changements non sauvegardés.
if ((await worktreeStatus(row.path)).dirtyCount > 0) {
throw httpError(409, 'DIRTY_TREE', 'Main checkout has uncommitted changes commit or stash before switching branch');
throw httpError(409, 'DIRTY_TREE', 'Main checkout has uncommitted changes: commit or stash before switching branch');
}
try {
await switchBranch(row.path, { branch, create: req.newBranch ?? true });
@@ -731,14 +731,14 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
if (resolve(w.path) === resolve(row.path)) throw httpError(400, 'IS_MAIN_WORKTREE', 'Cannot remove the main worktree');
// garde-fou : une session vivante tourne dans ce worktree → exiger une confirmation explicite.
if (!force && this.sessionsForCwd(w.path, { includeHidden: true }).some((s) => s.live)) {
throw httpError(409, 'SESSION_LIVE_IN_WORKTREE', 'A live session runs in this worktree pass force to delete anyway');
throw httpError(409, 'SESSION_LIVE_IN_WORKTREE', 'A live session runs in this worktree: pass force to delete anyway');
}
return this.withLock(repoId, async () => {
try {
await removeWorktree(row.path, w.path, force);
} catch (err) {
if (!force && isDirtyWorktreeError(err)) {
throw httpError(409, 'WORKTREE_DIRTY', 'Worktree has uncommitted changes pass force to delete anyway');
throw httpError(409, 'WORKTREE_DIRTY', 'Worktree has uncommitted changes: pass force to delete anyway');
}
throw httpError(500, 'WORKTREE_REMOVE_FAILED', (err as Error).message);
}

View File

@@ -29,7 +29,7 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
`,
},
{
// P2 découverte & reprise : corrélation avec les sessions Claude sur disque.
// P2 : découverte & reprise : corrélation avec les sessions Claude sur disque.
id: 2,
sql: `
ALTER TABLE sessions ADD COLUMN claude_session_id TEXT;
@@ -38,7 +38,7 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
`,
},
{
// P3 repos enregistrés. Les worktrees sont dérivés à la volée de git (non persistés).
// P3 : repos enregistrés. Les worktrees sont dérivés à la volée de git (non persistés).
id: 3,
sql: `
CREATE TABLE repos (
@@ -53,7 +53,7 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
`,
},
{
// P4 abonnements Web Push. Liés au token d'auth (token_id) ; clés VAPID en settings.
// P4 : abonnements Web Push. Liés au token d'auth (token_id) ; clés VAPID en settings.
id: 4,
sql: `
CREATE TABLE push_subscriptions (
@@ -70,7 +70,7 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
`,
},
{
// P5 groupes de travail. Un groupe = collection de repos (many-to-many).
// P5 : groupes de travail. Un groupe = collection de repos (many-to-many).
// Worktrees/sessions NON persistés ici : servis par WorktreeManager/PtyManager
// et filtrés côté client par repoId. CASCADE s'appuie sur PRAGMA foreign_keys = ON (cf. openDb).
id: 5,
@@ -103,7 +103,7 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
{
// Journal d'audit (conformité entreprise : GDPR/SOX/ISO 27001). Trace les mutations
// sensibles (tokens, réglages, secrets, abonnements push, groupes). Ne contient JAMAIS
// de secret en clair `details` est un JSON de métadonnées non sensibles.
// de secret en clair, `details` est un JSON de métadonnées non sensibles.
id: 7,
sql: `
CREATE TABLE audit_logs (
@@ -119,7 +119,7 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
`,
},
{
// P6 session de groupe multi-repo. Une session peut couvrir plusieurs répertoires (--add-dir)
// P6 : session de groupe multi-repo. Une session peut couvrir plusieurs répertoires (--add-dir)
// et appartenir à un groupe. `added_dirs` : JSON array de chemins absolus (NULL si mono-repo).
// `group_id` : pas de FK (ALTER ADD COLUMN sqlite n'en pose pas) ; nettoyé à la suppression du groupe.
id: 8,
@@ -143,7 +143,7 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
`,
},
{
// P10 archivage automatique des sessions terminées (rétention configurable, défaut 30 j).
// P10 : archivage automatique des sessions terminées (rétention configurable, défaut 30 j).
// `archived_at` NULL = active. Soft-archive : JAMAIS de DELETE (resume/fork restent intacts).
// Sémantique distincte de hidden_sessions (#9) : `hidden` est manuel et indexé par
// claude_session_id ; `archived` est automatique par ancienneté, indexé par `id`, et ne
@@ -155,7 +155,7 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
`,
},
{
// P12 connexions aux services git distants (Gitea/GitLab/GitHub). Les SECRETS (PAT, app
// P12 : connexions aux services git distants (Gitea/GitLab/GitHub). Les SECRETS (PAT, app
// password, tokens OAuth) sont chiffrés par SecretBox AVANT insertion (colonnes *_encrypted) :
// la base ne contient jamais de secret en clair. `ssh_key_path`/`oauth_*` sont posés dès
// maintenant (schéma stable) mais exploités en P12b/P12c. `test_result` = dernier diagnostic.
@@ -181,7 +181,7 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
`,
},
{
// P12 un repo enregistré peut provenir d'un clone : on retient sa source (remote_url),
// P12 : un repo enregistré peut provenir d'un clone : on retient sa source (remote_url),
// le service détecté et le credential utilisé. PAS de FK sur credential_id (cohérent avec
// sessions.group_id #8) : la suppression d'un credential nullifie ce champ côté manager.
id: 12,
@@ -205,7 +205,7 @@ export function openDb(path: string): Db {
}
/**
* Restreint la base (et ses fichiers WAL/SHM) à 0o600 proprio uniquement. La base contient des
* Restreint la base (et ses fichiers WAL/SHM) à 0o600, proprio uniquement. La base contient des
* secrets (server_secret, clé privée VAPID, hashs de tokens) : elle ne doit jamais être lisible par
* d'autres utilisateurs du système. Best-effort : ignoré sur les FS sans permissions POSIX.
*/
@@ -268,7 +268,7 @@ export function unhideSession(db: Db, claudeSessionId: string): void {
db.prepare('DELETE FROM hidden_sessions WHERE claude_session_id = ?').run(claudeSessionId);
}
// ---- Archivage des sessions terminées (par id, soft-archive P10) ----
// ---- Archivage des sessions terminées (par id, soft-archive, P10) ----
/** Archive une session managée (idempotent : ne touche pas une session déjà archivée). */
export function archiveSession(db: Db, id: string, archivedAt: string): void {

View File

@@ -40,7 +40,7 @@ export async function runDaemon(config: Config): Promise<void> {
if (config.autoDiscover) repoDiscovery.start(); // découverte auto des repos : scan au boot + re-scan périodique
const url = `http://${config.bind === '0.0.0.0' ? '127.0.0.1' : config.bind}:${config.port}`;
app.log.info(`Arboretum v${pkg.version} ${url}`);
app.log.info(`Arboretum v${pkg.version} : ${url}`);
if (bootstrapToken) {
// Affiché une seule fois : le hash seul est stocké.
printTokenBanner(bootstrapToken, url);
@@ -52,7 +52,7 @@ export async function runDaemon(config: Config): Promise<void> {
const shutdown = (signal: string): void => {
if (shuttingDown) return;
shuttingDown = true;
app.log.info(`${signal} received draining sessions then exiting`);
app.log.info(`${signal} received: draining sessions then exiting`);
discovery.stop();
sessionArchive.stop();
repoDiscovery.stop();

View File

@@ -80,7 +80,7 @@ export function registerAuthRoutes(
const body = req.body as Partial<CreateTokenRequest> | null;
const label = typeof body?.label === 'string' ? body.label.trim() : '';
if (label.length < 1 || label.length > 64) {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'label must be 164 characters' } });
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'label must be 1-64 characters' } });
}
const { id, token } = auth.createTokenRecord(label);
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'token.create', resourceId: id, details: { label } });

View File

@@ -101,7 +101,7 @@ export function registerGroupRoutes(
});
// Session de groupe (P6) : UNE session Claude couvrant tous les repos du groupe (--add-dir).
// Les répertoires sont résolus côté serveur depuis les propres worktrees du groupe le client
// Les répertoires sont résolus côté serveur depuis les propres worktrees du groupe : le client
// ne passe jamais de chemin brut. `branch` présent → worktree de cette branche par repo ;
// absent → le worktree principal de chaque repo.
app.post('/api/v1/groups/:id/session', async (req, reply) => {

View File

@@ -63,7 +63,7 @@ export function registerSessionRoutes(
const claudeSessionId = managed ? managed.claudeSessionId : id;
// Garde-fou anti-corruption : jamais de resume direct d'une session vivante (vérif FRAÎCHE).
if (discovery.isClaudeSessionLive(claudeSessionId) || manager.findLiveByClaudeSessionId(claudeSessionId)) {
return reply.status(409).send({ error: { code: 'SESSION_LIVE', message: 'Session is live fork it instead' } });
return reply.status(409).send({ error: { code: 'SESSION_LIVE', message: 'Session is live, fork it instead' } });
}
try {
// Session de groupe (P6) : re-relie les mêmes répertoires (--add-dir) et son groupe au resume.
@@ -104,7 +104,7 @@ export function registerSessionRoutes(
});
// Masque une session découverte (ou managée morte) : exclue de la liste sauf ?includeHidden=true.
// Reste reprenable/forkable c'est un filtre d'affichage, pas une suppression.
// Reste reprenable/forkable : c'est un filtre d'affichage, pas une suppression.
app.post('/api/v1/sessions/:id/hide', async (req, reply) => {
const { id } = req.params as { id: string };
const sid = resolveClaudeSid(id);

View File

@@ -1,6 +1,6 @@
// Réglages exposés à l'UI (onglet Réglages). Frontière de sécurité CENTRALE : la table `settings`
// contient aussi des SECRETS (server_secret, vapid_private). Ces routes n'exposent QUE des champs
// non sensibles et n'écrivent QUE des clés explicitement allow-listées jamais les secrets.
// non sensibles et n'écrivent QUE des clés explicitement allow-listées : jamais les secrets.
import type { FastifyInstance } from 'fastify';
import type { ServerInfo, SettingsResponse, UpdateSettingsRequest } from '@arboretum/shared';
import type { Config } from '../config.js';
@@ -119,7 +119,7 @@ export function registerSettingsRoutes(
details: { keys: Object.keys(body) },
});
const snap = snapshot();
// P11 diffuse le nouvel état non sensible à tous les clients abonnés au topic 'settings'.
// P11 · diffuse le nouvel état non sensible à tous les clients abonnés au topic 'settings'.
settingsBus.emit('settings_update', snap.settings);
return reply.send(snap);
});

View File

@@ -60,7 +60,7 @@ export function registerWorktreeRoutes(app: FastifyInstance, wt: WorktreeManager
}
});
// Branches du repo (locales + suivies de origin + défaut) alimente le sélecteur de base côté UI.
// Branches du repo (locales + suivies de origin + défaut) : alimente le sélecteur de base côté UI.
app.get('/api/v1/repos/:id/branches', async (req, reply) => {
const { id } = req.params as { id: string };
try {

View File

@@ -67,7 +67,7 @@ export function registerWsGateway(
const onSessionExit = (e: { sessionId: string; exitCode: number | null; signal: number | null }): void => {
if (subscribedSessions) send({ type: 'session_exit', ...e });
};
// P10 une session managée vient d'être archivée (auto par ancienneté ou manuellement).
// P10 · une session managée vient d'être archivée (auto par ancienneté ou manuellement).
const onSessionArchived = (e: { id: string; archivedAt: string }): void => {
if (subscribedSessions) send({ type: 'session_archived', sessionId: e.id });
};
@@ -93,15 +93,15 @@ export function registerWsGateway(
const onGroupRemoved = (groupId: string): void => {
if (subscribedGroups) send({ type: 'group_removed', groupId });
};
// P11 un réglage a changé : relayé aux connexions abonnées au topic 'settings'.
// P11 · un réglage a changé : relayé aux connexions abonnées au topic 'settings'.
const onSettingsUpdate = (settings: SettingsBroadcast): void => {
if (subscribedSettings) send({ type: 'settings_update', settings });
};
// P12 progression d'un clone : relayée aux abonnés du topic 'clones'.
// P12 · progression d'un clone : relayée aux abonnés du topic 'clones'.
const onCloneUpdate = (operation: CloneOperation): void => {
if (subscribedClones) send({ type: 'clone_update', operation });
};
// P7 détail d'un worktree modifié : poussé UNIQUEMENT si cette connexion le regarde.
// P7 · détail d'un worktree modifié : poussé UNIQUEMENT si cette connexion le regarde.
const onWorktreeChanges = (e: { repoId: string; path: string }): void => {
if (watched.has(watchKey(e.repoId, e.path))) send({ type: 'worktree_changes', ...e });
};