P4-A: commande WS answer — répondre aux dialogues sans clavier

Nouvelle commande WS de haut niveau `answer {channel, action, optionN?}`
traduite côté serveur en keystrokes (chiffre+Entrée / Entrée / Esc) et
validée contre le dialogue courant du tracker (anti-frappe fantôme mobile).

- protocol.ts : message `answer` + validation parseClientMessage + ErrorCode INVALID_ANSWER
- pty-manager.answer() : mappe l'intention en write PTY, réutilise le modèle mono-utilisateur
- gateway : case `answer` (NOT_CONTROLLING / SESSION_EXITED / INVALID_ANSWER)
- web : Attachment.answer(), DialogPrompt.vue (attache interactive légère sink no-op),
  intégré dans SessionView, SessionsListView, WorktreeCard + i18n en/fr
- tests : parseClientMessage (answer valide/malformé + fuzz), pty-manager.answer (163 verts)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Johan LEROY
2026-06-15 12:05:27 +02:00
parent 8b41140af7
commit 9f7c13dddb
14 changed files with 265 additions and 6 deletions

View File

@@ -256,6 +256,36 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
return 'ok';
}
/**
* 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.
* - '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).
*/
answer(
sessionId: string,
binding: ClientBinding,
action: 'select' | 'confirm' | 'deny',
optionN?: number,
): 'ok' | 'not_controlling' | 'gone' | 'invalid' {
const s = this.live.get(sessionId);
if (!s || s.exited) return 'gone';
if (binding.mode !== 'interactive') return 'not_controlling';
const act = s.tracker?.snapshot();
if (action === 'select') {
// L'option doit exister dans le dialogue courant (anti-frappe fantôme mobile).
if (!act?.dialog?.options.some((o) => o.n === optionN)) return 'invalid';
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).
if (act?.activity !== 'waiting') return 'invalid';
s.proc.write(action === 'deny' ? '\x1b' : '\r');
return 'ok';
}
resize(sessionId: string, binding: ClientBinding, cols: number, rows: number): void {
const s = this.live.get(sessionId);
if (!s || s.exited || !binding.controlling) return;

View File

@@ -161,6 +161,22 @@ export function registerWsGateway(
}
return;
}
case 'answer': {
const st = channels.get(msg.channel);
if (!st) {
send({ type: 'error', code: 'NOT_ATTACHED', message: 'Unknown channel', channel: msg.channel });
return;
}
const r = manager.answer(st.sessionId, st.binding, msg.action, msg.optionN);
if (r === 'not_controlling') {
send({ type: 'error', code: 'NOT_CONTROLLING', message: 'Observer mode is read-only', channel: msg.channel });
} else if (r === 'gone') {
send({ type: 'error', code: 'SESSION_EXITED', message: 'Session has exited', channel: msg.channel });
} else if (r === 'invalid') {
send({ type: 'error', code: 'INVALID_ANSWER', message: 'No such option in the current dialog', channel: msg.channel });
}
return;
}
case 'resize': {
const st = channels.get(msg.channel);
if (st) manager.resize(st.sessionId, st.binding, msg.cols, msg.rows);