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:
2026-06-15 12:05:27 +02:00
parent 7c536f79ea
commit c43fb25c94
14 changed files with 265 additions and 6 deletions

View File

@@ -166,6 +166,10 @@ export type ClientMessage =
| { 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'> }
@@ -193,6 +197,7 @@ export type ErrorCode =
| 'NOT_ATTACHED'
| 'NOT_CONTROLLING'
| 'SESSION_EXITED'
| 'INVALID_ANSWER'
| 'INTERNAL';
export function parseClientMessage(raw: string): ClientMessage | null {
@@ -220,6 +225,16 @@ export function parseClientMessage(raw: string): ClientMessage | null {
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 }