All checks were successful
CI / Build & test (Node 22) (push) Successful in 10m17s
CI / Build & test (Node 24) (push) Successful in 10m13s
CI / No em/en dashes (push) Successful in 4s
Deploy site (production) / build-and-deploy (push) Successful in 24s
Release / Publish to Gitea npm registry (push) Successful in 10m17s
VSCode Release / Package VSIX (push) Successful in 9m39s
Desktop Release / Build Linux (AppImage + deb) (push) Successful in 15m41s
CI / Pack & boot smoke (Node 22) (push) Successful in 10m3s
« Démarrer le projet » : un repo définit une fois ses commandes de démarrage (serveur de dev, API, base de données), un clic ouvre un terminal PTY par commande dans le dock IDE. Serveur (additif, PROTOCOL_VERSION inchangé) : - LaunchCommand[] persistées sur repos.launch_commands (migration 13) ; champ additif SessionSummary.launchRunId. - POST /repos/:id/launch : résolution du worktree côté serveur, cwd de commande borné (anti-traversal), commandIds outrepasse enabled. - GET /repos/:id/launch/detect : détection package.json / Procfile / docker-compose. - Shell de login interactif ($SHELL -l -i, charge le PATH nvm/asdf) + auto-type de la commande ; le shell survit à la commande (échec visible). Web : LaunchProjectModal + actions (ProjectTreeNode, SessionsPanel, CommandPalette), stores sessions/worktrees, i18n EN/FR. Alignement du reste du projet : - Extension VS Code 0.4.0 : commande Start Project (repo/worktree), Stop Launch, badge « launch » dans l'arbre, méthode REST startLaunch. - Site vitrine : 16e feature card (Rocket) + section showcase « Start the project » (mockup fidèle au modal), i18n EN/FR. - Documentation : README (EN + FR), help-content (EN + FR), CHANGELOGs server + vscode. Vérifié : 430 tests, typecheck, build (web + site + vscode), acceptance-p13 ALL GREEN, VSIX packagé, garde anti-tirets, vérif visuelle du site (thèmes clair et sombre).
387 lines
17 KiB
TypeScript
387 lines
17 KiB
TypeScript
// Protocole WebSocket Arboretum : une connexion multiplexée par client.
|
|
// Messages de contrôle : frames TEXTE JSON. Sortie terminal : frames BINAIRES
|
|
// (un chunk PTY peut couper un caractère UTF-8 en frontière de frame ; le
|
|
// décodage incombe à xterm.write(Uint8Array) côté client, jamais au transport).
|
|
|
|
export const PROTOCOL_VERSION = 1;
|
|
|
|
// ---- Frames binaires ----
|
|
// Layout : [type u8][channel u32le][payload...]
|
|
export const BINARY_FRAME = {
|
|
HEADER_BYTES: 5,
|
|
/** serveur → client : sortie terminal */
|
|
OUTPUT: 0x01,
|
|
/** serveur → client : resync, le client doit reset son terminal avant d'écrire le payload */
|
|
RESYNC: 0x02,
|
|
} as const;
|
|
|
|
export function encodeBinaryFrame(type: number, channel: number, payload: Uint8Array): Uint8Array {
|
|
const frame = new Uint8Array(BINARY_FRAME.HEADER_BYTES + payload.byteLength);
|
|
const view = new DataView(frame.buffer);
|
|
view.setUint8(0, type);
|
|
view.setUint32(1, channel, true);
|
|
frame.set(payload, BINARY_FRAME.HEADER_BYTES);
|
|
return frame;
|
|
}
|
|
|
|
export function decodeBinaryFrame(data: Uint8Array): { type: number; channel: number; payload: Uint8Array } {
|
|
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
return {
|
|
type: view.getUint8(0),
|
|
channel: view.getUint32(1, true),
|
|
payload: data.subarray(BINARY_FRAME.HEADER_BYTES),
|
|
};
|
|
}
|
|
|
|
// ---- Flow control (pattern officiel xterm.js, watermarks du design) ----
|
|
// INVARIANT anti-deadlock : ACK_EVERY_BYTES <= LOW_WATERMARK. Le client n'ACK
|
|
// qu'à réception de données ; si le serveur pouvait se mettre en pause avec un
|
|
// reliquat non-ACKé > LOW mais < pas d'ACK, plus aucun des deux ne progresserait.
|
|
// Avec ACK_EVERY <= LOW, une fois tout le flux en vol traité, le reliquat est
|
|
// < LOW et le serveur reprend toujours. (Complété côté client par un ACK
|
|
// traînant débouncé.)
|
|
export const FLOW = {
|
|
/** le client ACK tous les N octets réellement traités par xterm.write */
|
|
ACK_EVERY_BYTES: 64 * 1024,
|
|
/** pause du PTY quand TOUS les clients interactifs dépassent HIGH */
|
|
HIGH_WATERMARK: 384 * 1024,
|
|
/** reprise quand le min repasse sous LOW */
|
|
LOW_WATERMARK: 128 * 1024,
|
|
/** au-delà : client marqué lagging, flux coupé, resync au rattrapage */
|
|
LAGGING_BYTES: 2 * 1024 * 1024,
|
|
} as const;
|
|
|
|
/**
|
|
* Replay à l'attach : reset terminal + queue du ring (l'écran TUI se repeint en continu).
|
|
* 1 Mo (≈ 10-15k lignes) pour permettre de remonter une vraie conversation Claude dans le terminal ;
|
|
* reste < LAGGING_BYTES (pas de faux lagging) et bien dans RING_CAPACITY.
|
|
*/
|
|
export const REPLAY_TAIL_BYTES = 1024 * 1024;
|
|
|
|
// ---- États de session (sous-ensemble P1 ; étendu en P3) ----
|
|
export type SessionRuntimeStatus =
|
|
| 'starting'
|
|
| 'running' // P1 : pas encore de distinction busy/waiting/idle (P3 via claude-adapter)
|
|
| 'exited';
|
|
|
|
/** Origine d'une session listée par Arboretum. */
|
|
export type SessionSource =
|
|
| 'managed' // PTY lancé par Arboretum (vit dans le PtyManager)
|
|
| 'discovered'; // session Claude externe lue sur disque (~/.claude)
|
|
|
|
/** Statut brut tel qu'écrit par le CLI dans ~/.claude/sessions (interprété finement en P3). */
|
|
export type SessionRegistryStatus = 'busy' | 'idle' | 'waiting';
|
|
|
|
// ---- États fins de session (P3-B, claude-adapter) ----
|
|
/** busy = Claude traite ; waiting = bloqué sur un dialogue ; idle = prêt pour une instruction. */
|
|
export type SessionActivity = 'busy' | 'waiting' | 'idle';
|
|
export type DialogKind = 'trust' | 'permission' | 'question' | 'plan';
|
|
export interface DialogOption {
|
|
n: number;
|
|
label: string;
|
|
selected: boolean;
|
|
}
|
|
export interface SessionDialog {
|
|
kind: DialogKind;
|
|
waitingFor: string | null;
|
|
options: DialogOption[];
|
|
}
|
|
|
|
export interface SessionSummary {
|
|
id: string;
|
|
cwd: string;
|
|
command: string;
|
|
title: string | null;
|
|
status: SessionRuntimeStatus;
|
|
live: boolean;
|
|
createdAt: string;
|
|
endedAt: string | null;
|
|
exitCode: number | null;
|
|
clients: number;
|
|
// ---- P2 : découverte & reprise (additif) ----
|
|
/** 'managed' = PtyManager ; 'discovered' = lue sur disque. */
|
|
source: SessionSource;
|
|
/** ID interne du CLI claude (clé de corrélation managé↔découvert) ; null pour bash ou claude pas encore prêt. */
|
|
claudeSessionId: string | null;
|
|
/** pid du process claude (découvertes vivantes & managées vivantes) ; null sinon. */
|
|
pid: number | null;
|
|
/** true si un `--resume` direct est sûr (session morte) ; false pour une vivante (corruption). */
|
|
resumable: boolean;
|
|
/** true si Arboretum tient le PTY (managée vivante) ; jamais pour une découverte. */
|
|
attachable: boolean;
|
|
/** statut brut du registre ~/.claude/sessions (P2) ; interprété finement en P3 (claude-adapter). */
|
|
registryStatus: SessionRegistryStatus | null;
|
|
// ---- P3-B : états fins (optionnels, remplis par le claude-adapter) ----
|
|
/** absent/null = inconnu (bash, démarrage, historique). */
|
|
activity?: SessionActivity | null;
|
|
waitingFor?: string | null;
|
|
/** dialogue typé en cours (présent quand activity === 'waiting'). */
|
|
dialog?: SessionDialog | null;
|
|
// ---- P6 : session de groupe multi-repo (additif) ----
|
|
/** répertoires supplémentaires couverts via `--add-dir` ; absent/[] pour une session mono-repo. */
|
|
addedDirs?: string[];
|
|
/** groupe propriétaire d'une session de groupe (couvre plusieurs repos) ; null/absent sinon. */
|
|
groupId?: string | null;
|
|
// ---- Lancement de projet multi-terminaux (additif) ----
|
|
/** identifiant partagé par tous les terminaux d'un même « Démarrer le projet » ; null/absent sinon. */
|
|
launchRunId?: string | null;
|
|
// ---- Masquage (additif) ----
|
|
/** true = session découverte masquée par l'utilisateur (exclue de la liste sauf includeHidden). */
|
|
hidden?: boolean;
|
|
// ---- Archivage auto (P10, additif) ----
|
|
/** true = session managée auto-archivée par ancienneté (exclue de la liste sauf includeArchived). */
|
|
archived?: boolean;
|
|
}
|
|
|
|
// ---- Worktrees & repos (P3) ----
|
|
/** Hook lancé après création d'un worktree (commande shell exécutée dans le nouveau worktree). */
|
|
export interface PostCreateHook {
|
|
id: string;
|
|
label: string;
|
|
run: string;
|
|
enabled: boolean;
|
|
}
|
|
|
|
/**
|
|
* Commande de démarrage d'un projet (« Démarrer le projet ») : une commande shell longue durée
|
|
* lancée dans un terminal PTY managé (serveur front, back, base…). Plusieurs commandes activées
|
|
* = plusieurs terminaux ouverts d'un seul geste. Persistée en JSON sur le repo.
|
|
*/
|
|
export interface LaunchCommand {
|
|
id: string;
|
|
/** libellé court affiché dans l'onglet du terminal (ex. « web », « api »). */
|
|
label: string;
|
|
/** commande shell auto-tapée dans un shell de login interactif (ex. `npm run dev`). */
|
|
run: string;
|
|
/** sous-répertoire relatif au worktree où exécuter la commande (borné, jamais hors du worktree). */
|
|
cwd?: string;
|
|
enabled: boolean;
|
|
}
|
|
|
|
export interface RepoSummary {
|
|
id: string;
|
|
/** chemin absolu de la racine du repo (main worktree). */
|
|
path: string;
|
|
label: string;
|
|
defaultBranch: string | null;
|
|
postCreateHooks: PostCreateHook[];
|
|
/** commandes de démarrage du projet (« Démarrer le projet ») ; [] si aucune définie. */
|
|
launchCommands: LaunchCommand[];
|
|
/** pré-écrire hasTrustDialogAccepted dans ~/.claude.json à la création d'un worktree. */
|
|
preTrust: boolean;
|
|
createdAt: string;
|
|
/** false si le chemin n'est plus un repo git accessible. */
|
|
valid: boolean;
|
|
/** true = masqué du dashboard (conservé en DB → non ré-ajouté au re-scan). */
|
|
hidden: boolean;
|
|
}
|
|
|
|
export interface WorktreeGitStatus {
|
|
ahead: number;
|
|
behind: number;
|
|
dirtyCount: number;
|
|
upstream: string | null;
|
|
// ---- Champs additifs P7 (optionnels, rétrocompat) : compteurs bon marché calculés dans le
|
|
// même passage `status --porcelain=v2` que dirtyCount. La liste détaillée des changements
|
|
// (FileChange[]) n'est JAMAIS diffusée ici : elle est servie à la demande (REST) et poussée
|
|
// uniquement aux clients qui « regardent » le worktree (voir `worktree_changes`).
|
|
/** fichiers avec des modifications indexées (staged). */
|
|
stagedCount?: number;
|
|
/** fichiers avec des modifications non indexées (unstaged, untracked inclus). */
|
|
unstagedCount?: number;
|
|
/** fichiers en conflit de merge. */
|
|
conflictCount?: number;
|
|
/** hash court du dernier commit (HEAD), null si le dépôt n'a aucun commit. */
|
|
lastCommitHash?: string | null;
|
|
/** sujet (première ligne) du dernier commit. */
|
|
lastCommitSubject?: string | null;
|
|
}
|
|
|
|
export interface WorktreeSummary {
|
|
repoId: string;
|
|
/** chemin absolu du worktree (clé de corrélation avec le cwd des sessions). */
|
|
path: string;
|
|
branch: string | null;
|
|
head: string;
|
|
detached: boolean;
|
|
locked: boolean;
|
|
prunable: boolean;
|
|
isMain: boolean;
|
|
git: WorktreeGitStatus;
|
|
/** sessions corrélées par cwd (managées + découvertes) ; leur `activity` est remplie en P3-B. */
|
|
sessions: SessionSummary[];
|
|
}
|
|
|
|
// ---- Groupes de travail (P5) ----
|
|
// Un groupe regroupe plusieurs repos pour piloter des sessions Claude en simultané sur
|
|
// plusieurs worktrees. Membership légère : seule la liste d'ids de repos est persistée ;
|
|
// les repos/worktrees/sessions du groupe sont dérivés par filtrage sur `repoId` côté client.
|
|
export interface GroupSummary {
|
|
id: string;
|
|
label: string;
|
|
description: string | null;
|
|
/** couleur d'accent UI (hex `#rrggbb`) ou null. */
|
|
color: string | null;
|
|
/** ids de repos membres, ordonnés par leur position dans le groupe. */
|
|
repoIds: string[];
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
// ---- Réglages diffusables (P11) ----
|
|
// Sous-ensemble NON sensible des réglages, transporté tel quel par `settings_update` et par la
|
|
// réponse REST GET /settings (source de vérité unique du type). JAMAIS de secret ici.
|
|
export interface SettingsBroadcast {
|
|
/** racines absolues scannées pour la découverte auto des repos (défaut : aucune → pas de scan). */
|
|
scanRoots: string[];
|
|
/** intervalle du re-scan périodique en minutes ; 0 = périodique désactivé. */
|
|
scanIntervalMin: number;
|
|
/** chemin explicite du binaire `claude` ; null = auto-détection via PATH. */
|
|
claudeBinPath: string | null;
|
|
/** override de la racine ~/.claude ; null = défaut. */
|
|
claudeHome: string | null;
|
|
/** rétention des sessions terminées : auto-archivage après N jours ; 0 = jamais (P10). */
|
|
retentionDays: number;
|
|
/** purge définitive après N jours ; 0 = désactivée. */
|
|
purgeDays: number;
|
|
}
|
|
|
|
// ---- Clone d'un dépôt distant (P12) ----
|
|
export type CloneState = 'pending' | 'running' | 'done' | 'error';
|
|
/** État d'une opération de clone (poussé en WS sous le topic 'clones' ET lu en REST). */
|
|
export interface CloneOperation {
|
|
id: string;
|
|
state: CloneState;
|
|
/** progression 0-100 si git la communique, sinon null. */
|
|
progress: number | null;
|
|
/** phase courante rapportée par git (« Receiving objects », « Resolving deltas »…). */
|
|
phase: string | null;
|
|
error: string | null;
|
|
/** id du repo enregistré une fois le clone terminé (null avant). */
|
|
repoId: string | null;
|
|
/** dossier de destination du clone. */
|
|
dest: string;
|
|
}
|
|
|
|
// ---- Messages client → serveur ----
|
|
export type ClientMessage =
|
|
| { type: 'hello'; protocol: number }
|
|
| { type: 'attach'; sessionId: string; mode: 'interactive' | 'observer'; cols: number; rows: number }
|
|
| { type: 'detach'; channel: number }
|
|
| { type: 'stdin'; channel: number; data: string }
|
|
// P4-A : répondre à un dialogue Claude sans clavier. Le serveur traduit l'intention
|
|
// en keystrokes (chiffre+Entrée pour 'select', Entrée pour 'confirm', Esc pour 'deny')
|
|
// et valide l'option contre le dialogue courant (anti-frappe fantôme mobile).
|
|
| { type: 'answer'; channel: number; action: 'select' | 'confirm' | 'deny'; optionN?: number }
|
|
| { type: 'resize'; channel: number; cols: number; rows: number }
|
|
| { type: 'ack'; channel: number; bytes: number }
|
|
| { type: 'sub'; topics: Array<'sessions' | 'worktrees' | 'groups' | 'settings' | 'clones'> }
|
|
// P7 : abonnement ciblé au détail d'un worktree (diff/statut fin) : on ne pousse le détail
|
|
// qu'aux connexions qui « regardent » ce worktree (clé = repoId + path), indépendamment du
|
|
// topic global 'worktrees' (qui ne transporte que les compteurs légers).
|
|
| { type: 'watch'; repoId: string; path: string }
|
|
| { type: 'unwatch'; repoId: string; path: string }
|
|
| { type: 'ping' };
|
|
|
|
// ---- Messages serveur → client ----
|
|
export type ServerMessage =
|
|
| { type: 'hello_ok'; protocol: number; serverVersion: string }
|
|
| { type: 'attached'; channel: number; sessionId: string; mode: 'interactive' | 'observer'; controlling: boolean }
|
|
| { type: 'detached'; channel: number; reason: 'client' | 'session_exit' | 'replaced' }
|
|
| { type: 'control_changed'; channel: number; controlling: boolean }
|
|
| { type: 'session_update'; session: SessionSummary }
|
|
| { type: 'session_exit'; sessionId: string; exitCode: number | null; signal: number | null }
|
|
// P10 : une session managée vient d'être archivée (auto par ancienneté ou manuellement). Signal
|
|
// léger : le client met à jour son row (badge archived) et le retire si « Show archived » est off.
|
|
| { type: 'session_archived'; sessionId: string }
|
|
// P11 : un réglage a changé (PATCH /settings) : on diffuse le snapshot non sensible aux abonnés
|
|
// du topic 'settings' pour que tous les clients (et onglets) se synchronisent sans polling.
|
|
| { type: 'settings_update'; settings: SettingsBroadcast }
|
|
// P12 : progression/fin/erreur d'un clone, poussé aux abonnés du topic 'clones'. L'état complet
|
|
// (state) discrimine progress/done/error en un seul message.
|
|
| { type: 'clone_update'; operation: CloneOperation }
|
|
| { type: 'repo_update'; repo: RepoSummary }
|
|
| { type: 'repo_removed'; repoId: string }
|
|
| { type: 'worktree_update'; repoId: string; worktree: WorktreeSummary }
|
|
| { type: 'worktree_removed'; repoId: string; path: string }
|
|
// P7 : signal léger « le détail (diff/fichiers modifiés) de ce worktree a changé, refais un
|
|
// GET /changes ou /diff ». Envoyé UNIQUEMENT aux connexions ayant `watch`é cette clé. On ne
|
|
// pousse pas le diff complet dans la frame (taille/flow control) : le client re-fetch en REST.
|
|
| { type: 'worktree_changes'; repoId: string; path: string }
|
|
| { type: 'group_update'; group: GroupSummary }
|
|
| { type: 'group_removed'; groupId: string }
|
|
| { type: 'error'; code: ErrorCode; message: string; channel?: number }
|
|
| { type: 'pong' };
|
|
|
|
export type ErrorCode =
|
|
| 'BAD_PROTOCOL'
|
|
| 'BAD_MESSAGE'
|
|
| 'NOT_FOUND'
|
|
| 'NOT_ATTACHED'
|
|
| 'NOT_CONTROLLING'
|
|
| 'SESSION_EXITED'
|
|
| 'INVALID_ANSWER'
|
|
| 'INTERNAL';
|
|
|
|
export function parseClientMessage(raw: string): ClientMessage | null {
|
|
let obj: unknown;
|
|
try {
|
|
obj = JSON.parse(raw);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (typeof obj !== 'object' || obj === null || typeof (obj as { type?: unknown }).type !== 'string') return null;
|
|
const m = obj as Record<string, unknown>;
|
|
const isU32 = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 && v <= 0xffffffff;
|
|
const isCount = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0;
|
|
const isDim = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 2 && v <= 1000;
|
|
switch (m.type) {
|
|
case 'hello':
|
|
return isCount(m.protocol) ? { type: 'hello', protocol: m.protocol } : null;
|
|
case 'attach':
|
|
return typeof m.sessionId === 'string' && (m.mode === 'interactive' || m.mode === 'observer') && isDim(m.cols) && isDim(m.rows)
|
|
? { type: 'attach', sessionId: m.sessionId, mode: m.mode, cols: m.cols, rows: m.rows }
|
|
: null;
|
|
case 'detach':
|
|
return isU32(m.channel) ? { type: 'detach', channel: m.channel } : null;
|
|
case 'stdin':
|
|
return isU32(m.channel) && typeof m.data === 'string' && m.data.length <= 65536
|
|
? { type: 'stdin', channel: m.channel, data: m.data }
|
|
: null;
|
|
case 'answer': {
|
|
if (!isU32(m.channel)) return null;
|
|
if (m.action === 'select')
|
|
return isCount(m.optionN) && (m.optionN as number) >= 1 && (m.optionN as number) <= 99
|
|
? { type: 'answer', channel: m.channel, action: 'select', optionN: m.optionN as number }
|
|
: null;
|
|
return m.action === 'confirm' || m.action === 'deny'
|
|
? { type: 'answer', channel: m.channel, action: m.action }
|
|
: null;
|
|
}
|
|
case 'resize':
|
|
return isU32(m.channel) && isDim(m.cols) && isDim(m.rows)
|
|
? { type: 'resize', channel: m.channel, cols: m.cols, rows: m.rows }
|
|
: null;
|
|
case 'ack':
|
|
return isU32(m.channel) && isCount(m.bytes)
|
|
? { type: 'ack', channel: m.channel, bytes: m.bytes }
|
|
: null;
|
|
case 'sub':
|
|
return Array.isArray(m.topics) && m.topics.every((t) => t === 'sessions' || t === 'worktrees' || t === 'groups' || t === 'settings' || t === 'clones')
|
|
? { type: 'sub', topics: m.topics as Array<'sessions' | 'worktrees' | 'groups' | 'settings' | 'clones'> }
|
|
: null;
|
|
case 'watch':
|
|
case 'unwatch':
|
|
// repoId/path bornés ici (typage + longueur) ; l'existence repo/worktree et la sûreté du
|
|
// chemin sont re-validées côté serveur (isSafeAbsolutePath + findWorktree) avant d'armer.
|
|
return typeof m.repoId === 'string' && m.repoId.length > 0 && m.repoId.length <= 128 &&
|
|
typeof m.path === 'string' && m.path.length > 0 && m.path.length <= 4096
|
|
? { type: m.type, repoId: m.repoId, path: m.path }
|
|
: null;
|
|
case 'ping':
|
|
return { type: 'ping' };
|
|
default:
|
|
return null;
|
|
}
|
|
}
|