feat(settings): réglages Claude CLI (binaire + ~/.claude) & notif « disponible »

- Réglages → Claude CLI : override du chemin du binaire `claude` (effet à la
  prochaine session, fallback `which claude`), diagnostic de détection, et
  override de la racine ~/.claude (effet au redémarrage). Validateurs stricts.
- Push : notifie aussi sur le front busy→idle (session redevenue disponible).
- Renomme l'état affiché idle → « disponible » / « available » (web EN/FR + VS Code) ;
  l'enum SessionActivity du protocole reste inchangé.
This commit is contained in:
2026-06-24 10:15:29 +02:00
parent 2506dfb1f3
commit b5236b41c8
16 changed files with 519 additions and 27 deletions

View File

@@ -7,6 +7,7 @@ import pty from '@homebridge/node-pty-prebuilt-multiarch';
import { FLOW, REPLAY_TAIL_BYTES, type SessionActivity, type SessionSummary } from '@arboretum/shared';
import { RingBuffer } from './ring-buffer.js';
import { buildSpawnSpec } from './claude-launcher.js';
import { readClaudeBinPath } from './claude-settings.js';
import { findByPid } from './session-registry.js';
import { SessionActivityTracker } from './claude-adapter.js';
import type { PushService } from './push-service.js';
@@ -111,8 +112,11 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
}
// 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;
const spec = buildSpawnSpec({
command,
...(claudeBinPath ? { claudeBinPath } : {}),
...(opts.resume ? { resume: opts.resume } : {}),
...(addedDirs.length ? { addDirs: addedDirs } : {}),
});
@@ -403,30 +407,35 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
// ---- interne ----
/**
* Push P4-B : notifie sur le FRONT MONTANT vers `waiting` uniquement (le tracker réémet
* souvent le même état), avec un debounce annulable — un `waiting` ultra-bref (Claude répond
* tout seul) ne déclenche pas de notif. Cible tous les abonnements (un seul utilisateur).
* 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 —
* à 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 {
const prev = s.prevActivity;
s.prevActivity = next;
if (!this.push) return;
if (next === 'waiting' && prev !== 'waiting') {
const notifiable =
(next === 'waiting' && prev !== 'waiting') || (next === 'idle' && prev === 'busy');
if (notifiable) {
const target = next; // 'waiting' | 'idle'
if (s.notifyTimer) clearTimeout(s.notifyTimer);
s.notifyTimer = setTimeout(() => {
s.notifyTimer = null;
const act = s.tracker?.snapshot();
if (s.exited || act?.activity !== 'waiting') return; // faux positif : annulé
void this.push?.notify({
sessionId: s.id,
title: basename(s.cwd) || s.cwd,
body: act.dialog?.waitingFor ?? act.waitingFor ?? 'waiting for your input',
kind: act.dialog?.kind ?? null,
url: `/sessions/${s.id}`,
});
if (s.exited || act?.activity !== target) return; // faux positif : l'état a déjà changé
const base = { sessionId: s.id, title: basename(s.cwd) || s.cwd, url: `/sessions/${s.id}` };
void this.push?.notify(
target === 'idle'
? { ...base, body: 'available for new instructions', kind: null }
: { ...base, body: act.dialog?.waitingFor ?? act.waitingFor ?? 'waiting for your input', kind: act.dialog?.kind ?? null },
);
}, NOTIFY_DEBOUNCE_MS);
s.notifyTimer.unref();
} else if (next !== 'waiting' && s.notifyTimer) {
} else if (s.notifyTimer && next !== 'waiting' && next !== 'idle') {
// On quitte un état notifiable avant l'échéance (ex. Claude repart en busy) → annule.
clearTimeout(s.notifyTimer);
s.notifyTimer = null;
}