Masquage persistant des sessions Claude découvertes (lancées hors Arboretum) qui polluent la liste — calqué sur repos.hidden : - table hidden_sessions (#9) clé claudeSessionId ; helpers DB - DiscoveryService pose le champ additif SessionSummary.hidden (resume/fork restent possibles, survit à un re-scan) - routes POST/DELETE /sessions/:id/hide, POST /sessions/hide-discovered (masse, un clic), GET /sessions?includeHidden=true - UI : bouton Masquer par ligne, « Masquer l'historique externe », toggle « Afficher les masquées », badge masquée ; store + i18n EN/FR + audit Terminal plein écran (corrige le sizing) : - SessionView root s'étire (flex-1) → xterm calcule les bonnes dimensions - refit robuste (requestAnimationFrame + après resync), h-full sur le wrapper - bouton plein écran (API Fullscreen), tabbar mobile masquée en fullbleed DialogPrompt : options en liste verticale pleine largeur (plus de troncature ni de chevauchement sur les libellés longs / multi-lignes). Doc : centre d'aide (sessions), README EN/FR, site (showcase), CLAUDE.md. Tests : discovery (hidden) + route hide/unhide/hide-discovered.
146 lines
5.7 KiB
TypeScript
146 lines
5.7 KiB
TypeScript
// Service de découverte : second producteur de SessionSummary (à côté du PtyManager).
|
|
// Scanne ~/.claude/projects (JSONL) + ~/.claude/sessions (registre), corrèle par claudeSessionId,
|
|
// calcule la vivacité, met en cache et notifie les changements. Lecture seule du disque.
|
|
import { EventEmitter } from 'node:events';
|
|
import { homedir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import type { SessionSummary } from '@arboretum/shared';
|
|
import { scanProjects, type DiscoveredJsonl } from './jsonl-discovery.js';
|
|
import { readRegistry, type RegistryEntry } from './session-registry.js';
|
|
import { listHiddenSessionIds, type Db } from '../db/index.js';
|
|
import type { PtyManager } from './pty-manager.js';
|
|
|
|
const DEFAULT_REFRESH_MS = 10_000;
|
|
|
|
export interface DiscoveryServiceEvents {
|
|
/** session découverte nouvelle ou modifiée (relayée en session_update par la gateway). */
|
|
discovery_update: [SessionSummary];
|
|
}
|
|
|
|
export interface DiscoveryOptions {
|
|
db: Db;
|
|
ptyManager: PtyManager;
|
|
projectsDir?: string;
|
|
sessionsDir?: string;
|
|
refreshMs?: number;
|
|
}
|
|
|
|
export class DiscoveryService extends EventEmitter<DiscoveryServiceEvents> {
|
|
private readonly db: Db;
|
|
private readonly projectsDir: string;
|
|
private readonly sessionsDir: string;
|
|
private readonly ptyManager: PtyManager;
|
|
private readonly refreshMs: number;
|
|
private timer: NodeJS.Timeout | null = null;
|
|
private cache: SessionSummary[] = [];
|
|
private byId = new Map<string, DiscoveredJsonl>();
|
|
private prevJson = new Map<string, string>();
|
|
|
|
constructor(opts: DiscoveryOptions) {
|
|
super();
|
|
this.db = opts.db;
|
|
this.ptyManager = opts.ptyManager;
|
|
this.projectsDir = opts.projectsDir ?? join(homedir(), '.claude', 'projects');
|
|
this.sessionsDir = opts.sessionsDir ?? join(homedir(), '.claude', 'sessions');
|
|
this.refreshMs = opts.refreshMs ?? DEFAULT_REFRESH_MS;
|
|
}
|
|
|
|
start(): void {
|
|
if (this.timer) return;
|
|
void this.refresh(); // premier scan asynchrone : ne bloque pas le boot
|
|
this.timer = setInterval(() => void this.refresh(), this.refreshMs);
|
|
this.timer.unref(); // ne maintient pas le process en vie
|
|
}
|
|
|
|
stop(): void {
|
|
if (this.timer) {
|
|
clearInterval(this.timer);
|
|
this.timer = null;
|
|
}
|
|
}
|
|
|
|
/** Cache courant (jamais de scan synchrone dans le chemin chaud). */
|
|
list(): SessionSummary[] {
|
|
return this.cache;
|
|
}
|
|
|
|
/** 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
|
|
* anti-corruption : la route /resume doit refuser une session devenue vivante depuis le dernier scan.
|
|
*/
|
|
isClaudeSessionLive(claudeSessionId: string): boolean {
|
|
const entry = readRegistry(this.sessionsDir).find((r) => r.claudeSessionId === claudeSessionId);
|
|
return entry?.live ?? false;
|
|
}
|
|
|
|
/** Recalcule le cache depuis le disque. Tolérant : ne lève jamais. */
|
|
async refresh(): Promise<void> {
|
|
const regBySid = new Map<string, RegistryEntry>();
|
|
for (const r of readRegistry(this.sessionsDir)) {
|
|
if (r.claudeSessionId) regBySid.set(r.claudeSessionId, r);
|
|
}
|
|
const known = this.ptyManager.knownClaudeSessionIds();
|
|
// Sessions masquées par l'utilisateur : on les garde dans le cache (resume/fork possibles) mais
|
|
// marquées `hidden` → la route /sessions les exclut par défaut.
|
|
const hiddenIds = listHiddenSessionIds(this.db);
|
|
|
|
// Dédoublonnage des JSONL par claudeSessionId (on retient le plus récent).
|
|
const latest = new Map<string, DiscoveredJsonl>();
|
|
for (const d of await scanProjects(this.projectsDir)) {
|
|
const prev = latest.get(d.claudeSessionId);
|
|
if (!prev || d.mtimeMs > prev.mtimeMs) latest.set(d.claudeSessionId, d);
|
|
}
|
|
|
|
const summaries: SessionSummary[] = [];
|
|
const byId = new Map<string, DiscoveredJsonl>();
|
|
for (const d of latest.values()) {
|
|
if (known.has(d.claudeSessionId)) continue; // déjà gérée comme session managée
|
|
byId.set(d.claudeSessionId, d);
|
|
const r = regBySid.get(d.claudeSessionId);
|
|
const live = r?.live ?? false;
|
|
summaries.push({
|
|
id: d.claudeSessionId, // les découvertes s'identifient par leur claudeSessionId
|
|
cwd: d.cwd,
|
|
command: 'claude',
|
|
title: d.title,
|
|
status: live ? 'running' : 'exited',
|
|
live,
|
|
createdAt: new Date(d.mtimeMs).toISOString(),
|
|
endedAt: null,
|
|
exitCode: null,
|
|
clients: 0,
|
|
source: 'discovered',
|
|
claudeSessionId: d.claudeSessionId,
|
|
pid: live ? (r?.pid ?? null) : null,
|
|
resumable: !live, // morte → --resume direct ; vivante → fork/observe (jamais resume : corruption)
|
|
attachable: false, // Arboretum ne tient pas le PTY d'une session externe
|
|
registryStatus: r?.status ?? null,
|
|
hidden: hiddenIds.has(d.claudeSessionId),
|
|
});
|
|
}
|
|
|
|
this.cache = summaries;
|
|
this.byId = byId;
|
|
|
|
// Diff : n'émettre que les sessions nouvelles ou modifiées.
|
|
const nextJson = new Map<string, string>();
|
|
for (const s of summaries) {
|
|
const j = JSON.stringify(s);
|
|
nextJson.set(s.id, j);
|
|
if (this.prevJson.get(s.id) !== j) this.emit('discovery_update', s);
|
|
}
|
|
this.prevJson = nextJson;
|
|
}
|
|
}
|
|
|
|
/** Fusionne sessions managées + découvertes en dédoublonnant par claudeSessionId (managé prioritaire). */
|
|
export function mergeSessions(managed: SessionSummary[], discovered: SessionSummary[]): SessionSummary[] {
|
|
const managedSids = new Set(managed.map((s) => s.claudeSessionId).filter((x): x is string => x != null));
|
|
return [...managed, ...discovered.filter((s) => !(s.claudeSessionId && managedSids.has(s.claudeSessionId)))];
|
|
}
|